[FIX] stock_picking_wave: fixed picking wave report
[odoo/odoo.git] / addons / web / http.py
1 # -*- coding: utf-8 -*-
2 #----------------------------------------------------------
3 # OpenERP Web HTTP layer
4 #----------------------------------------------------------
5 import ast
6 import cgi
7 import contextlib
8 import functools
9 import getpass
10 import logging
11 import mimetypes
12 import os
13 import pprint
14 import random
15 import sys
16 import tempfile
17 import threading
18 import time
19 import traceback
20 import urlparse
21 import uuid
22
23 import babel.core
24 import simplejson
25 import werkzeug.contrib.sessions
26 import werkzeug.datastructures
27 import werkzeug.exceptions
28 import werkzeug.utils
29 import werkzeug.wrappers
30 import werkzeug.wsgi
31
32 import openerp
33
34 import session
35
36 _logger = logging.getLogger(__name__)
37
38 #----------------------------------------------------------
39 # RequestHandler
40 #----------------------------------------------------------
41 class WebRequest(object):
42     """ Parent class for all OpenERP Web request types, mostly deals with
43     initialization and setup of the request object (the dispatching itself has
44     to be handled by the subclasses)
45
46     :param request: a wrapped werkzeug Request object
47     :type request: :class:`werkzeug.wrappers.BaseRequest`
48
49     .. attribute:: httprequest
50
51         the original :class:`werkzeug.wrappers.Request` object provided to the
52         request
53
54     .. attribute:: httpsession
55
56         a :class:`~collections.Mapping` holding the HTTP session data for the
57         current http session
58
59     .. attribute:: params
60
61         :class:`~collections.Mapping` of request parameters, not generally
62         useful as they're provided directly to the handler method as keyword
63         arguments
64
65     .. attribute:: session_id
66
67         opaque identifier for the :class:`session.OpenERPSession` instance of
68         the current request
69
70     .. attribute:: session
71
72         :class:`~session.OpenERPSession` instance for the current request
73
74     .. attribute:: context
75
76         :class:`~collections.Mapping` of context values for the current request
77
78     .. attribute:: debug
79
80         ``bool``, indicates whether the debug mode is active on the client
81     """
82     def __init__(self, request):
83         self.httprequest = request
84         self.httpresponse = None
85         self.httpsession = request.session
86
87     def init(self, params):
88         self.params = dict(params)
89         # OpenERP session setup
90         self.session_id = self.params.pop("session_id", None) or uuid.uuid4().hex
91         self.session = self.httpsession.get(self.session_id)
92         if not self.session:
93             self.session = session.OpenERPSession()
94             self.httpsession[self.session_id] = self.session
95
96         # set db/uid trackers - they're cleaned up at the WSGI
97         # dispatching phase in openerp.service.wsgi_server.application
98         if self.session._db:
99             threading.current_thread().dbname = self.session._db
100         if self.session._uid:
101             threading.current_thread().uid = self.session._uid
102
103         self.context = self.params.pop('context', {})
104         self.debug = self.params.pop('debug', False) is not False
105         # Determine self.lang
106         lang = self.params.get('lang', None)
107         if lang is None:
108             lang = self.context.get('lang')
109         if lang is None:
110             lang = self.httprequest.cookies.get('lang')
111         if lang is None:
112             lang = self.httprequest.accept_languages.best
113         if not lang:
114             lang = 'en_US'
115         # tranform 2 letters lang like 'en' into 5 letters like 'en_US'
116         lang = babel.core.LOCALE_ALIASES.get(lang, lang)
117         # we use _ as seprator where RFC2616 uses '-'
118         self.lang = lang.replace('-', '_')
119
120 def reject_nonliteral(dct):
121     if '__ref' in dct:
122         raise ValueError(
123             "Non literal contexts can not be sent to the server anymore (%r)" % (dct,))
124     return dct
125
126 class JsonRequest(WebRequest):
127     """ JSON-RPC2 over HTTP.
128
129     Sucessful request::
130
131       --> {"jsonrpc": "2.0",
132            "method": "call",
133            "params": {"session_id": "SID",
134                       "context": {},
135                       "arg1": "val1" },
136            "id": null}
137
138       <-- {"jsonrpc": "2.0",
139            "result": { "res1": "val1" },
140            "id": null}
141
142     Request producing a error::
143
144       --> {"jsonrpc": "2.0",
145            "method": "call",
146            "params": {"session_id": "SID",
147                       "context": {},
148                       "arg1": "val1" },
149            "id": null}
150
151       <-- {"jsonrpc": "2.0",
152            "error": {"code": 1,
153                      "message": "End user error message.",
154                      "data": {"code": "codestring",
155                               "debug": "traceback" } },
156            "id": null}
157
158     """
159     def dispatch(self, method):
160         """ Calls the method asked for by the JSON-RPC2 or JSONP request
161
162         :param method: the method which received the request
163
164         :returns: an utf8 encoded JSON-RPC2 or JSONP reply
165         """
166         args = self.httprequest.args
167         jsonp = args.get('jsonp')
168         requestf = None
169         request = None
170         request_id = args.get('id')
171
172         if jsonp and self.httprequest.method == 'POST':
173             # jsonp 2 steps step1 POST: save call
174             self.init(args)
175             self.session.jsonp_requests[request_id] = self.httprequest.form['r']
176             headers=[('Content-Type', 'text/plain; charset=utf-8')]
177             r = werkzeug.wrappers.Response(request_id, headers=headers)
178             return r
179         elif jsonp and args.get('r'):
180             # jsonp method GET
181             request = args.get('r')
182         elif jsonp and request_id:
183             # jsonp 2 steps step2 GET: run and return result
184             self.init(args)
185             request = self.session.jsonp_requests.pop(request_id, "")
186         else:
187             # regular jsonrpc2
188             requestf = self.httprequest.stream
189
190         response = {"jsonrpc": "2.0" }
191         error = None
192         try:
193             # Read POST content or POST Form Data named "request"
194             if requestf:
195                 self.jsonrequest = simplejson.load(requestf, object_hook=reject_nonliteral)
196             else:
197                 self.jsonrequest = simplejson.loads(request, object_hook=reject_nonliteral)
198             self.init(self.jsonrequest.get("params", {}))
199             if _logger.isEnabledFor(logging.DEBUG):
200                 _logger.debug("--> %s.%s\n%s", method.im_class.__name__, method.__name__, pprint.pformat(self.jsonrequest))
201             response['id'] = self.jsonrequest.get('id')
202             response["result"] = method(self, **self.params)
203         except session.AuthenticationError:
204             se = serialize_exception(e)
205             error = {
206                 'code': 100,
207                 'message': "OpenERP Session Invalid",
208                 'data': se
209             }
210         except Exception, e:
211             se = serialize_exception(e)
212             error = {
213                 'code': 200,
214                 'message': "OpenERP Server Error",
215                 'data': se
216             }
217         if error:
218             response["error"] = error
219
220         if _logger.isEnabledFor(logging.DEBUG):
221             _logger.debug("<--\n%s", pprint.pformat(response))
222
223         if jsonp:
224             # If we use jsonp, that's mean we are called from another host
225             # Some browser (IE and Safari) do no allow third party cookies
226             # We need then to manage http sessions manually.
227             response['httpsessionid'] = self.httpsession.sid
228             mime = 'application/javascript'
229             body = "%s(%s);" % (jsonp, simplejson.dumps(response),)
230         else:
231             mime = 'application/json'
232             body = simplejson.dumps(response)
233
234         r = werkzeug.wrappers.Response(body, headers=[('Content-Type', mime), ('Content-Length', len(body))])
235         return r
236
237 def serialize_exception(e):
238     tmp = {
239         "name": type(e).__module__ + "." + type(e).__name__ if type(e).__module__ else type(e).__name__,
240         "debug": traceback.format_exc(),
241         "message": u"%s" % e,
242         "arguments": to_jsonable(e.args),
243     }
244     if isinstance(e, openerp.osv.osv.except_osv):
245         tmp["exception_type"] = "except_osv"
246     elif isinstance(e, openerp.exceptions.Warning):
247         tmp["exception_type"] = "warning"
248     elif isinstance(e, openerp.exceptions.AccessError):
249         tmp["exception_type"] = "access_error"
250     elif isinstance(e, openerp.exceptions.AccessDenied):
251         tmp["exception_type"] = "access_denied"
252     return tmp
253
254 def to_jsonable(o):
255     if isinstance(o, str) or isinstance(o,unicode) or isinstance(o, int) or isinstance(o, long) \
256         or isinstance(o, bool) or o is None or isinstance(o, float):
257         return o
258     if isinstance(o, list) or isinstance(o, tuple):
259         return [to_jsonable(x) for x in o]
260     if isinstance(o, dict):
261         tmp = {}
262         for k, v in o.items():
263             tmp[u"%s" % k] = to_jsonable(v)
264         return tmp
265     return u"%s" % o
266
267 def jsonrequest(f):
268     """ Decorator marking the decorated method as being a handler for a
269     JSON-RPC request (the exact request path is specified via the
270     ``$(Controller._cp_path)/$methodname`` combination.
271
272     If the method is called, it will be provided with a :class:`JsonRequest`
273     instance and all ``params`` sent during the JSON-RPC request, apart from
274     the ``session_id``, ``context`` and ``debug`` keys (which are stripped out
275     beforehand)
276     """
277     f.exposed = 'json'
278     return f
279
280 class HttpRequest(WebRequest):
281     """ Regular GET/POST request
282     """
283     def dispatch(self, method):
284         params = dict(self.httprequest.args)
285         params.update(self.httprequest.form)
286         params.update(self.httprequest.files)
287         self.init(params)
288         akw = {}
289         for key, value in self.httprequest.args.iteritems():
290             if isinstance(value, basestring) and len(value) < 1024:
291                 akw[key] = value
292             else:
293                 akw[key] = type(value)
294         _logger.debug("%s --> %s.%s %r", self.httprequest.method, method.im_class.__name__, method.__name__, akw)
295         try:
296             r = method(self, **self.params)
297         except Exception, e:
298             _logger.exception("An exception occured during an http request")
299             se = serialize_exception(e)
300             error = {
301                 'code': 200,
302                 'message': "OpenERP Server Error",
303                 'data': se
304             }
305             r = werkzeug.exceptions.InternalServerError(cgi.escape(simplejson.dumps(error)))
306         if self.debug or 1:
307             if isinstance(r, (werkzeug.wrappers.BaseResponse, werkzeug.exceptions.HTTPException)):
308                 _logger.debug('<-- %s', r)
309             else:
310                 _logger.debug("<-- size: %s", len(r))
311         return r
312
313     def make_response(self, data, headers=None, cookies=None):
314         """ Helper for non-HTML responses, or HTML responses with custom
315         response headers or cookies.
316
317         While handlers can just return the HTML markup of a page they want to
318         send as a string if non-HTML data is returned they need to create a
319         complete response object, or the returned data will not be correctly
320         interpreted by the clients.
321
322         :param basestring data: response body
323         :param headers: HTTP headers to set on the response
324         :type headers: ``[(name, value)]``
325         :param collections.Mapping cookies: cookies to set on the client
326         """
327         response = werkzeug.wrappers.Response(data, headers=headers)
328         if cookies:
329             for k, v in cookies.iteritems():
330                 response.set_cookie(k, v)
331         return response
332
333     def not_found(self, description=None):
334         """ Helper for 404 response, return its result from the method
335         """
336         return werkzeug.exceptions.NotFound(description)
337
338 def httprequest(f):
339     """ Decorator marking the decorated method as being a handler for a
340     normal HTTP request (the exact request path is specified via the
341     ``$(Controller._cp_path)/$methodname`` combination.
342
343     If the method is called, it will be provided with a :class:`HttpRequest`
344     instance and all ``params`` sent during the request (``GET`` and ``POST``
345     merged in the same dictionary), apart from the ``session_id``, ``context``
346     and ``debug`` keys (which are stripped out beforehand)
347     """
348     f.exposed = 'http'
349     return f
350
351 #----------------------------------------------------------
352 # Controller registration with a metaclass
353 #----------------------------------------------------------
354 addons_module = {}
355 addons_manifest = {}
356 controllers_class = []
357 controllers_object = {}
358 controllers_path = {}
359
360 class ControllerType(type):
361     def __init__(cls, name, bases, attrs):
362         super(ControllerType, cls).__init__(name, bases, attrs)
363         controllers_class.append(("%s.%s" % (cls.__module__, cls.__name__), cls))
364
365 class Controller(object):
366     __metaclass__ = ControllerType
367
368 #----------------------------------------------------------
369 # Session context manager
370 #----------------------------------------------------------
371 @contextlib.contextmanager
372 def session_context(request, session_store, session_lock, sid):
373     with session_lock:
374         if sid:
375             request.session = session_store.get(sid)
376         else:
377             request.session = session_store.new()
378     try:
379         yield request.session
380     finally:
381         # Remove all OpenERPSession instances with no uid, they're generated
382         # either by login process or by HTTP requests without an OpenERP
383         # session id, and are generally noise
384         removed_sessions = set()
385         for key, value in request.session.items():
386             if not isinstance(value, session.OpenERPSession):
387                 continue
388             if getattr(value, '_suicide', False) or (
389                         not value._uid
390                     and not value.jsonp_requests
391                     # FIXME do not use a fixed value
392                     and value._creation_time + (60*5) < time.time()):
393                 _logger.debug('remove session %s', key)
394                 removed_sessions.add(key)
395                 del request.session[key]
396
397         with session_lock:
398             if sid:
399                 # Re-load sessions from storage and merge non-literal
400                 # contexts and domains (they're indexed by hash of the
401                 # content so conflicts should auto-resolve), otherwise if
402                 # two requests alter those concurrently the last to finish
403                 # will overwrite the previous one, leading to loss of data
404                 # (a non-literal is lost even though it was sent to the
405                 # client and client errors)
406                 #
407                 # note that domains_store and contexts_store are append-only (we
408                 # only ever add items to them), so we can just update one with the
409                 # other to get the right result, if we want to merge the
410                 # ``context`` dict we'll need something smarter
411                 in_store = session_store.get(sid)
412                 for k, v in request.session.iteritems():
413                     stored = in_store.get(k)
414                     if stored and isinstance(v, session.OpenERPSession):
415                         if hasattr(v, 'contexts_store'):
416                             del v.contexts_store
417                         if hasattr(v, 'domains_store'):
418                             del v.domains_store
419                         if not hasattr(v, 'jsonp_requests'):
420                             v.jsonp_requests = {}
421                         v.jsonp_requests.update(getattr(
422                             stored, 'jsonp_requests', {}))
423
424                 # add missing keys
425                 for k, v in in_store.iteritems():
426                     if k not in request.session and k not in removed_sessions:
427                         request.session[k] = v
428
429             session_store.save(request.session)
430
431 def session_gc(session_store):
432     if random.random() < 0.001:
433         # we keep session one week
434         last_week = time.time() - 60*60*24*7
435         for fname in os.listdir(session_store.path):
436             path = os.path.join(session_store.path, fname)
437             try:
438                 if os.path.getmtime(path) < last_week:
439                     os.unlink(path)
440             except OSError:
441                 pass
442
443 #----------------------------------------------------------
444 # WSGI Application
445 #----------------------------------------------------------
446 # Add potentially missing (older ubuntu) font mime types
447 mimetypes.add_type('application/font-woff', '.woff')
448 mimetypes.add_type('application/vnd.ms-fontobject', '.eot')
449 mimetypes.add_type('application/x-font-ttf', '.ttf')
450
451 class DisableCacheMiddleware(object):
452     def __init__(self, app):
453         self.app = app
454     def __call__(self, environ, start_response):
455         def start_wrapped(status, headers):
456             referer = environ.get('HTTP_REFERER', '')
457             parsed = urlparse.urlparse(referer)
458             debug = parsed.query.count('debug') >= 1
459
460             new_headers = []
461             unwanted_keys = ['Last-Modified']
462             if debug:
463                 new_headers = [('Cache-Control', 'no-cache')]
464                 unwanted_keys += ['Expires', 'Etag', 'Cache-Control']
465
466             for k, v in headers:
467                 if k not in unwanted_keys:
468                     new_headers.append((k, v))
469
470             start_response(status, new_headers)
471         return self.app(environ, start_wrapped)
472
473 def session_path():
474     try:
475         username = getpass.getuser()
476     except Exception:
477         username = "unknown"
478     path = os.path.join(tempfile.gettempdir(), "oe-sessions-" + username)
479     if not os.path.exists(path):
480         os.mkdir(path, 0700)
481     return path
482
483 class Root(object):
484     """Root WSGI application for the OpenERP Web Client.
485     """
486     def __init__(self):
487         self.addons = {}
488         self.statics = {}
489
490         self.load_addons()
491
492         # Setup http sessions
493         path = session_path()
494         self.session_store = werkzeug.contrib.sessions.FilesystemSessionStore(path)
495         self.session_lock = threading.Lock()
496         _logger.debug('HTTP sessions stored in: %s', path)
497
498     def __call__(self, environ, start_response):
499         """ Handle a WSGI request
500         """
501         return self.dispatch(environ, start_response)
502
503     def dispatch(self, environ, start_response):
504         """
505         Performs the actual WSGI dispatching for the application, may be
506         wrapped during the initialization of the object.
507
508         Call the object directly.
509         """
510         request = werkzeug.wrappers.Request(environ)
511         request.parameter_storage_class = werkzeug.datastructures.ImmutableDict
512         request.app = self
513
514         handler = self.find_handler(*(request.path.split('/')[1:]))
515
516         if not handler:
517             response = werkzeug.exceptions.NotFound()
518         else:
519             sid = request.cookies.get('sid')
520             if not sid:
521                 sid = request.args.get('sid')
522
523             session_gc(self.session_store)
524
525             with session_context(request, self.session_store, self.session_lock, sid) as session:
526                 result = handler(request)
527
528                 if isinstance(result, basestring):
529                     headers=[('Content-Type', 'text/html; charset=utf-8'), ('Content-Length', len(result))]
530                     response = werkzeug.wrappers.Response(result, headers=headers)
531                 else:
532                     response = result
533
534                 if hasattr(response, 'set_cookie'):
535                     response.set_cookie('sid', session.sid)
536
537         return response(environ, start_response)
538
539     def load_addons(self):
540         """ Load all addons from addons patch containg static files and
541         controllers and configure them.  """
542
543         for addons_path in openerp.modules.module.ad_paths:
544             for module in sorted(os.listdir(addons_path)):
545                 if module not in addons_module:
546                     manifest_path = os.path.join(addons_path, module, '__openerp__.py')
547                     path_static = os.path.join(addons_path, module, 'static')
548                     if os.path.isfile(manifest_path) and os.path.isdir(path_static):
549                         manifest = ast.literal_eval(open(manifest_path).read())
550                         manifest['addons_path'] = addons_path
551                         _logger.debug("Loading %s", module)
552                         if 'openerp.addons' in sys.modules:
553                             m = __import__('openerp.addons.' + module)
554                         else:
555                             m = __import__(module)
556                         addons_module[module] = m
557                         addons_manifest[module] = manifest
558                         self.statics['/%s/static' % module] = path_static
559
560         for k, v in controllers_class:
561             if k not in controllers_object:
562                 o = v()
563                 controllers_object[k] = o
564                 if hasattr(o, '_cp_path'):
565                     controllers_path[o._cp_path] = o
566
567         app = werkzeug.wsgi.SharedDataMiddleware(self.dispatch, self.statics)
568         self.dispatch = DisableCacheMiddleware(app)
569
570     def find_handler(self, *l):
571         """
572         Tries to discover the controller handling the request for the path
573         specified by the provided parameters
574
575         :param l: path sections to a controller or controller method
576         :returns: a callable matching the path sections, or ``None``
577         :rtype: ``Controller | None``
578         """
579         if l:
580             ps = '/' + '/'.join(filter(None, l))
581             method_name = 'index'
582             while ps:
583                 c = controllers_path.get(ps)
584                 if c:
585                     method = getattr(c, method_name, None)
586                     if method:
587                         exposed = getattr(method, 'exposed', False)
588                         if exposed == 'json':
589                             _logger.debug("Dispatch json to %s %s %s", ps, c, method_name)
590                             return lambda request: JsonRequest(request).dispatch(method)
591                         elif exposed == 'http':
592                             _logger.debug("Dispatch http to %s %s %s", ps, c, method_name)
593                             return lambda request: HttpRequest(request).dispatch(method)
594                 ps, _slash, method_name = ps.rpartition('/')
595                 if not ps and method_name:
596                     ps = '/'
597         return None
598
599 def wsgi_postload():
600     openerp.wsgi.register_wsgi_handler(Root())
601
602 # vim:et:ts=4:sw=4: