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