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