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