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