1 # -*- coding: utf-8 -*-
2 #----------------------------------------------------------
3 # OpenERP Web HTTP layer
4 #----------------------------------------------------------
26 import werkzeug.contrib.sessions
27 import werkzeug.datastructures
28 import werkzeug.exceptions
30 import werkzeug.wrappers
32 import werkzeug.routing as routing
42 _logger = logging.getLogger(__name__)
44 #----------------------------------------------------------
46 #----------------------------------------------------------
47 class WebRequest(object):
48 """ Parent class for all OpenERP Web request types, mostly deals with
49 initialization and setup of the request object (the dispatching itself has
50 to be handled by the subclasses)
52 :param request: a wrapped werkzeug Request object
53 :type request: :class:`werkzeug.wrappers.BaseRequest`
55 .. attribute:: httprequest
57 the original :class:`werkzeug.wrappers.Request` object provided to the
60 .. attribute:: httpsession
62 a :class:`~collections.Mapping` holding the HTTP session data for the
67 :class:`~collections.Mapping` of request parameters, not generally
68 useful as they're provided directly to the handler method as keyword
71 .. attribute:: session_id
73 opaque identifier for the :class:`session.OpenERPSession` instance of
76 .. attribute:: session
78 :class:`~session.OpenERPSession` instance for the current request
80 .. attribute:: context
82 :class:`~collections.Mapping` of context values for the current request
86 ``bool``, indicates whether the debug mode is active on the client
90 ``str``, the name of the database linked to the current request. Can be ``None``
91 if the current request uses the ``nodb`` authentication.
95 ``int``, the id of the user related to the current request. Can be ``None``
96 if the current request uses the ``nodb`` or the ``noauth`` authenticatoin.
98 def __init__(self, httprequest):
99 self.httprequest = httprequest
100 self.httpresponse = None
101 self.httpsession = httprequest.session
105 self.auth_method = None
108 self.func_request_type = None
110 def init(self, params):
111 self.params = dict(params)
112 # OpenERP session setup
113 self.session_id = self.params.pop("session_id", None)
114 if not self.session_id:
115 i0 = self.httprequest.cookies.get("instance0|session_id", None)
117 self.session_id = simplejson.loads(urllib2.unquote(i0))
119 self.session_id = uuid.uuid4().hex
120 self.session = self.httpsession.get(self.session_id)
122 self.session = session.OpenERPSession()
123 self.httpsession[self.session_id] = self.session
125 with set_request(self):
126 self.db = (self.session._db or openerp.addons.web.controllers.main.db_monodb()).lower()
129 # set db/uid trackers - they're cleaned up at the WSGI
130 # dispatching phase in openerp.service.wsgi_server.application
132 threading.current_thread().dbname = self.session._db
133 if self.session._uid:
134 threading.current_thread().uid = self.session._uid
136 self.context = self.params.pop('context', {})
137 self.debug = self.params.pop('debug', False) is not False
138 # Determine self.lang
139 lang = self.params.get('lang', None)
141 lang = self.context.get('lang')
143 lang = self.httprequest.cookies.get('lang')
145 lang = self.httprequest.accept_languages.best
148 # tranform 2 letters lang like 'en' into 5 letters like 'en_US'
149 lang = babel.core.LOCALE_ALIASES.get(lang, lang)
150 # we use _ as seprator where RFC2616 uses '-'
151 self.lang = lang.replace('-', '_')
153 def _authenticate(self):
154 if self.auth_method == "nodb":
157 elif self.auth_method == "noauth":
158 self.db = (self.session._db or openerp.addons.web.controllers.main.db_monodb()).lower()
160 raise session.SessionExpiredException("No valid database for request %s" % self.httprequest)
164 self.session.check_security()
165 except session.SessionExpiredException, e:
166 raise session.SessionExpiredException("Session expired for request %s" % self.httprequest)
167 self.db = self.session._db
168 self.uid = self.session._uid
173 The registry to the database linked to this request. Can be ``None`` if the current request uses the
174 ``nodb'' authentication.
176 return openerp.modules.registry.RegistryManager.get(self.db) if self.db else None
181 The cursor initialized for the current method call. If the current request uses the ``nodb`` authentication
182 trying to access this property will raise an exception.
184 # some magic to lazy create the cr
186 self._cr_cm = self.registry.cursor()
187 self._cr = self._cr_cm.__enter__()
190 def _call_function(self, *args, **kwargs):
193 # ugly syntax only to get the __exit__ arguments to pass to self._cr
195 class with_obj(object):
198 def __exit__(self, *args):
200 request._cr_cm.__exit__(*args)
201 request._cr_cm = None
205 if self.func_request_type != self._request_type:
206 raise Exception("%s, %s: Function declared as capable of handling request of type '%s' but called with a request of type '%s'" \
207 % (self.func, self.httprequest.path, self.func_request_type, self._request_type))
208 return self.func(*args, **kwargs)
210 # just to be sure no one tries to re-use the request
214 def route(route, type="http", authentication="auth"):
216 Decorator marking the decorated method as being a handler for requests. The method must be part of a subclass
219 Decorator to put on a controller method to inform it does not require a user to be logged. When this decorator
220 is used, ``request.uid`` will be ``None``. The request will still try to detect the database and an exception
221 will be launched if there is no way to guess it.
223 :param route: string or array. The route part that will determine which http requests will match the decorated
224 method. Can be a single string or an array of strings. See werkzeug's routing documentation for the format of
225 route expression ( http://werkzeug.pocoo.org/docs/routing/ ).
226 :param type: The type of request, can be ``'http'`` or ``'json'``.
227 :param authentication: The type of authentication method, can on of the following:
229 * ``auth``: The user must be authenticated.
230 * ``noauth``: There is no need for the user to be authenticated but there must be a way to find the current
232 * ``nodb``: The method is always active, even if there is no database. Mainly used by the framework and
233 authentication modules.
235 assert type in ["http", "json"]
236 assert authentication in ["auth", "noauth", "nodb"]
238 if isinstance(route, list):
243 if getattr(f, "auth", None) is None:
244 f.auth = authentication
248 def reject_nonliteral(dct):
251 "Non literal contexts can not be sent to the server anymore (%r)" % (dct,))
254 class JsonRequest(WebRequest):
255 """ JSON-RPC2 over HTTP.
259 --> {"jsonrpc": "2.0",
261 "params": {"session_id": "SID",
266 <-- {"jsonrpc": "2.0",
267 "result": { "res1": "val1" },
270 Request producing a error::
272 --> {"jsonrpc": "2.0",
274 "params": {"session_id": "SID",
279 <-- {"jsonrpc": "2.0",
281 "message": "End user error message.",
282 "data": {"code": "codestring",
283 "debug": "traceback" } },
287 _request_type = "json"
289 def __init__(self, *args):
290 super(JsonRequest, self).__init__(*args)
292 self.jsonp_handler = None
294 args = self.httprequest.args
295 jsonp = args.get('jsonp')
298 request_id = args.get('id')
300 if jsonp and self.httprequest.method == 'POST':
301 # jsonp 2 steps step1 POST: save call
305 self.session.jsonp_requests[request_id] = self.httprequest.form['r']
306 headers=[('Content-Type', 'text/plain; charset=utf-8')]
307 r = werkzeug.wrappers.Response(request_id, headers=headers)
309 self.jsonp_handler = handler
311 elif jsonp and args.get('r'):
313 request = args.get('r')
314 elif jsonp and request_id:
315 # jsonp 2 steps step2 GET: run and return result
317 request = self.session.jsonp_requests.pop(request_id, "")
320 request = self.httprequest.stream.read()
322 # Read POST content or POST Form Data named "request"
323 self.jsonrequest = simplejson.loads(request, object_hook=reject_nonliteral)
324 self.init(self.jsonrequest.get("params", {}))
327 """ Calls the method asked for by the JSON-RPC2 or JSONP request
329 :returns: an utf8 encoded JSON-RPC2 or JSONP reply
331 if self.jsonp_handler:
332 return self.jsonp_handler()
333 response = {"jsonrpc": "2.0" }
336 #if _logger.isEnabledFor(logging.DEBUG):
337 # _logger.debug("--> %s.%s\n%s", func.im_class.__name__, func.__name__, pprint.pformat(self.jsonrequest))
338 response['id'] = self.jsonrequest.get('id')
339 response["result"] = self._call_function(**self.params)
340 except session.AuthenticationError, e:
341 _logger.exception("Exception during JSON request handling.")
342 se = serialize_exception(e)
345 'message': "OpenERP Session Invalid",
349 _logger.exception("Exception during JSON request handling.")
350 se = serialize_exception(e)
353 'message': "OpenERP Server Error",
357 response["error"] = error
359 if _logger.isEnabledFor(logging.DEBUG):
360 _logger.debug("<--\n%s", pprint.pformat(response))
363 # If we use jsonp, that's mean we are called from another host
364 # Some browser (IE and Safari) do no allow third party cookies
365 # We need then to manage http sessions manually.
366 response['httpsessionid'] = self.httpsession.sid
367 mime = 'application/javascript'
368 body = "%s(%s);" % (self.jsonp, simplejson.dumps(response),)
370 mime = 'application/json'
371 body = simplejson.dumps(response)
373 r = werkzeug.wrappers.Response(body, headers=[('Content-Type', mime), ('Content-Length', len(body))])
376 def serialize_exception(e):
378 "name": type(e).__module__ + "." + type(e).__name__ if type(e).__module__ else type(e).__name__,
379 "debug": traceback.format_exc(),
380 "message": u"%s" % e,
381 "arguments": to_jsonable(e.args),
383 if isinstance(e, openerp.osv.osv.except_osv):
384 tmp["exception_type"] = "except_osv"
385 elif isinstance(e, openerp.exceptions.Warning):
386 tmp["exception_type"] = "warning"
387 elif isinstance(e, openerp.exceptions.AccessError):
388 tmp["exception_type"] = "access_error"
389 elif isinstance(e, openerp.exceptions.AccessDenied):
390 tmp["exception_type"] = "access_denied"
394 if isinstance(o, str) or isinstance(o,unicode) or isinstance(o, int) or isinstance(o, long) \
395 or isinstance(o, bool) or o is None or isinstance(o, float):
397 if isinstance(o, list) or isinstance(o, tuple):
398 return [to_jsonable(x) for x in o]
399 if isinstance(o, dict):
401 for k, v in o.items():
402 tmp[u"%s" % k] = to_jsonable(v)
407 """ Decorator marking the decorated method as being a handler for a
408 JSON-RPC request (the exact request path is specified via the
409 ``$(Controller._cp_path)/$methodname`` combination.
411 If the method is called, it will be provided with a :class:`JsonRequest`
412 instance and all ``params`` sent during the JSON-RPC request, apart from
413 the ``session_id``, ``context`` and ``debug`` keys (which are stripped out
418 if f.__name__ == "index":
420 return route([base, os.path.join(base, "<path:_ignored_path>")], type="json", authentication="auth")(f)
422 class HttpRequest(WebRequest):
423 """ Regular GET/POST request
425 _request_type = "http"
427 def __init__(self, *args):
428 super(HttpRequest, self).__init__(*args)
429 params = dict(self.httprequest.args)
430 params.update(self.httprequest.form)
431 params.update(self.httprequest.files)
436 for key, value in self.httprequest.args.iteritems():
437 if isinstance(value, basestring) and len(value) < 1024:
440 akw[key] = type(value)
441 #_logger.debug("%s --> %s.%s %r", self.httprequest.func, func.im_class.__name__, func.__name__, akw)
443 r = self._call_function(**self.params)
444 except werkzeug.exceptions.HTTPException, e:
447 _logger.exception("An exception occured during an http request")
448 se = serialize_exception(e)
451 'message': "OpenERP Server Error",
454 r = werkzeug.exceptions.InternalServerError(cgi.escape(simplejson.dumps(error)))
457 r = werkzeug.wrappers.Response(status=204) # no content
458 if isinstance(r, (werkzeug.wrappers.BaseResponse, werkzeug.exceptions.HTTPException)):
459 _logger.debug('<-- %s', r)
461 _logger.debug("<-- size: %s", len(r))
464 def make_response(self, data, headers=None, cookies=None):
465 """ Helper for non-HTML responses, or HTML responses with custom
466 response headers or cookies.
468 While handlers can just return the HTML markup of a page they want to
469 send as a string if non-HTML data is returned they need to create a
470 complete response object, or the returned data will not be correctly
471 interpreted by the clients.
473 :param basestring data: response body
474 :param headers: HTTP headers to set on the response
475 :type headers: ``[(name, value)]``
476 :param collections.Mapping cookies: cookies to set on the client
478 response = werkzeug.wrappers.Response(data, headers=headers)
480 for k, v in cookies.iteritems():
481 response.set_cookie(k, v)
484 def not_found(self, description=None):
485 """ Helper for 404 response, return its result from the method
487 return werkzeug.exceptions.NotFound(description)
490 """ Decorator marking the decorated method as being a handler for a
491 normal HTTP request (the exact request path is specified via the
492 ``$(Controller._cp_path)/$methodname`` combination.
494 If the method is called, it will be provided with a :class:`HttpRequest`
495 instance and all ``params`` sent during the request (``GET`` and ``POST``
496 merged in the same dictionary), apart from the ``session_id``, ``context``
497 and ``debug`` keys (which are stripped out beforehand)
501 if f.__name__ == "index":
503 return route([base, os.path.join(base, "<path:_ignored_path>")], type="http", authentication="auth")(f)
505 #----------------------------------------------------------
506 # Local storage of requests
507 #----------------------------------------------------------
508 from werkzeug.local import LocalStack
510 _request_stack = LocalStack()
512 def set_request(request):
513 class with_obj(object):
515 _request_stack.push(request)
516 def __exit__(self, *args):
521 A global proxy that always redirect to the current request object.
523 request = _request_stack()
525 #----------------------------------------------------------
526 # Controller registration with a metaclass
527 #----------------------------------------------------------
530 controllers_per_module = {}
532 class ControllerType(type):
533 def __init__(cls, name, bases, attrs):
534 super(ControllerType, cls).__init__(name, bases, attrs)
536 # create wrappers for old-style methods with req as first argument
537 cls._methods_wrapper = {}
538 for k, v in attrs.items():
539 if inspect.isfunction(v):
540 spec = inspect.getargspec(v)
541 first_arg = spec.args[1] if len(spec.args) >= 2 else None
542 if first_arg in ["req", "request"]:
544 return lambda self, *args, **kwargs: nv(self, request, *args, **kwargs)
545 cls._methods_wrapper[k] = build_new(v)
547 # store the controller in the controllers list
548 name_class = ("%s.%s" % (cls.__module__, cls.__name__), cls)
549 class_path = name_class[0].split(".")
550 if not class_path[:2] == ["openerp", "addons"]:
552 module = class_path[2]
553 controllers_per_module.setdefault(module, []).append(name_class)
555 class Controller(object):
556 __metaclass__ = ControllerType
558 """def __new__(cls, *args, **kwargs):
559 subclasses = [c for c in cls.__subclasses__() if getattr(c, "_cp_path", None) == getattr(cls, "_cp_path", None)]
561 name = "%s (extended by %s)" % (cls.__name__, ', '.join(sub.__name__ for sub in subclasses))
562 cls = type(name, tuple(reversed(subclasses)), {})
564 return object.__new__(cls)"""
566 def get_wrapped_method(self, name):
567 if name in self.__class__._methods_wrapper:
568 return functools.partial(self.__class__._methods_wrapper[name], self)
570 return getattr(self, name)
572 #----------------------------------------------------------
573 # Session context manager
574 #----------------------------------------------------------
575 @contextlib.contextmanager
576 def session_context(request, session_store, session_lock, sid):
579 request.session = session_store.get(sid)
581 request.session = session_store.new()
583 yield request.session
585 # Remove all OpenERPSession instances with no uid, they're generated
586 # either by login process or by HTTP requests without an OpenERP
587 # session id, and are generally noise
588 removed_sessions = set()
589 for key, value in request.session.items():
590 if not isinstance(value, session.OpenERPSession):
592 if getattr(value, '_suicide', False) or (
594 and not value.jsonp_requests
595 # FIXME do not use a fixed value
596 and value._creation_time + (60*5) < time.time()):
597 _logger.debug('remove session %s', key)
598 removed_sessions.add(key)
599 del request.session[key]
603 # Re-load sessions from storage and merge non-literal
604 # contexts and domains (they're indexed by hash of the
605 # content so conflicts should auto-resolve), otherwise if
606 # two requests alter those concurrently the last to finish
607 # will overwrite the previous one, leading to loss of data
608 # (a non-literal is lost even though it was sent to the
609 # client and client errors)
611 # note that domains_store and contexts_store are append-only (we
612 # only ever add items to them), so we can just update one with the
613 # other to get the right result, if we want to merge the
614 # ``context`` dict we'll need something smarter
615 in_store = session_store.get(sid)
616 for k, v in request.session.iteritems():
617 stored = in_store.get(k)
618 if stored and isinstance(v, session.OpenERPSession):
619 if hasattr(v, 'contexts_store'):
621 if hasattr(v, 'domains_store'):
623 if not hasattr(v, 'jsonp_requests'):
624 v.jsonp_requests = {}
625 v.jsonp_requests.update(getattr(
626 stored, 'jsonp_requests', {}))
629 for k, v in in_store.iteritems():
630 if k not in request.session and k not in removed_sessions:
631 request.session[k] = v
633 session_store.save(request.session)
635 def session_gc(session_store):
636 if random.random() < 0.001:
637 # we keep session one week
638 last_week = time.time() - 60*60*24*7
639 for fname in os.listdir(session_store.path):
640 path = os.path.join(session_store.path, fname)
642 if os.path.getmtime(path) < last_week:
647 #----------------------------------------------------------
649 #----------------------------------------------------------
650 # Add potentially missing (older ubuntu) font mime types
651 mimetypes.add_type('application/font-woff', '.woff')
652 mimetypes.add_type('application/vnd.ms-fontobject', '.eot')
653 mimetypes.add_type('application/x-font-ttf', '.ttf')
655 class DisableCacheMiddleware(object):
656 def __init__(self, app):
658 def __call__(self, environ, start_response):
659 def start_wrapped(status, headers):
660 referer = environ.get('HTTP_REFERER', '')
661 parsed = urlparse.urlparse(referer)
662 debug = parsed.query.count('debug') >= 1
665 unwanted_keys = ['Last-Modified']
667 new_headers = [('Cache-Control', 'no-cache')]
668 unwanted_keys += ['Expires', 'Etag', 'Cache-Control']
671 if k not in unwanted_keys:
672 new_headers.append((k, v))
674 start_response(status, new_headers)
675 return self.app(environ, start_wrapped)
679 username = getpass.getuser()
682 path = os.path.join(tempfile.gettempdir(), "oe-sessions-" + username)
685 except OSError as exc:
686 if exc.errno == errno.EEXIST:
687 # directory exists: ensure it has the correct permissions
688 # this will fail if the directory is not owned by the current user
695 """Root WSGI application for the OpenERP Web Client.
702 self.db_routers_lock = threading.Lock()
706 # Setup http sessions
707 path = session_path()
708 self.session_store = werkzeug.contrib.sessions.FilesystemSessionStore(path)
709 self.session_lock = threading.Lock()
710 _logger.debug('HTTP sessions stored in: %s', path)
713 def __call__(self, environ, start_response):
714 """ Handle a WSGI request
716 return self.dispatch(environ, start_response)
718 def dispatch(self, environ, start_response):
720 Performs the actual WSGI dispatching for the application, may be
721 wrapped during the initialization of the object.
723 Call the object directly.
725 httprequest = werkzeug.wrappers.Request(environ)
726 httprequest.parameter_storage_class = werkzeug.datastructures.ImmutableDict
727 httprequest.app = self
729 sid = httprequest.cookies.get('sid')
731 sid = httprequest.args.get('sid')
733 session_gc(self.session_store)
735 with session_context(httprequest, self.session_store, self.session_lock, sid) as session:
736 request = self._build_request(httprequest)
740 updated = openerp.modules.registry.RegistryManager.check_registry_signaling(db)
742 with self.db_routers_lock:
743 del self.db_routers[db]
745 with set_request(request):
747 result = request.dispatch()
750 openerp.modules.registry.RegistryManager.signal_caches_change(db)
752 if isinstance(result, basestring):
753 headers=[('Content-Type', 'text/html; charset=utf-8'), ('Content-Length', len(result))]
754 response = werkzeug.wrappers.Response(result, headers=headers)
758 if hasattr(response, 'set_cookie'):
759 response.set_cookie('sid', session.sid)
761 return response(environ, start_response)
763 def _build_request(self, httprequest):
764 if httprequest.args.get('jsonp'):
765 return JsonRequest(httprequest)
767 content = httprequest.stream.read()
769 httprequest.stream = cStringIO.StringIO(content)
771 simplejson.loads(content)
772 return JsonRequest(httprequest)
774 return HttpRequest(httprequest)
776 def load_addons(self):
777 """ Load all addons from addons patch containg static files and
778 controllers and configure them. """
780 for addons_path in openerp.modules.module.ad_paths:
781 for module in sorted(os.listdir(str(addons_path))):
782 if module not in addons_module:
783 manifest_path = os.path.join(addons_path, module, '__openerp__.py')
784 path_static = os.path.join(addons_path, module, 'static')
785 if os.path.isfile(manifest_path) and os.path.isdir(path_static):
786 manifest = ast.literal_eval(open(manifest_path).read())
787 manifest['addons_path'] = addons_path
788 _logger.debug("Loading %s", module)
789 if 'openerp.addons' in sys.modules:
790 m = __import__('openerp.addons.' + module)
792 m = __import__(module)
793 addons_module[module] = m
794 addons_manifest[module] = manifest
795 self.statics['/%s/static' % module] = path_static
797 app = werkzeug.wsgi.SharedDataMiddleware(self.dispatch, self.statics)
798 self.dispatch = DisableCacheMiddleware(app)
800 def _build_router(self, db):
801 _logger.info("Generating routing configuration for database %s" % db)
802 routing_map = routing.Map()
803 modules_set = set(controllers_per_module.keys())
804 modules_set -= set("web")
806 modules = ["web"] + sorted(modules_set)
807 # building all nodb methods
808 for module in modules:
809 for v in controllers_per_module[module]:
810 members = inspect.getmembers(v[1]())
811 for mk, mv in members:
812 if inspect.ismethod(mv) and getattr(mv, 'exposed', False) and getattr(mv, 'auth', None) == "nodb":
814 function = (o.get_wrapped_method(mk), mv)
815 for url in mv.routes:
816 if getattr(mv, "combine", False):
817 url = os.path.join(o._cp_path, url)
818 if url.endswith("/") and len(url) > 1:
820 routing_map.add(routing.Rule(url, endpoint=function))
825 registry = openerp.modules.registry.RegistryManager.get(db)
826 with registry.cursor() as cr:
827 m = registry.get('ir.module.module')
828 ids = m.search(cr, openerp.SUPERUSER_ID, [('state','=','installed')])
829 installed = set([x['name'] for x in m.read(cr, 1, ids, ['name'])])
830 modules_set -= set(installed)
831 modules = ["web"] + sorted(modules_set)
832 # building all other methods
833 for module in modules:
834 for v in controllers_per_module[module]:
836 members = inspect.getmembers(o)
837 for mk, mv in members:
838 if inspect.ismethod(mv) and getattr(mv, 'exposed', False) and getattr(mv, 'auth', None) != "nodb":
839 function = (o.get_wrapped_method(mk), mv)
840 for url in mv.routes:
841 if getattr(mv, "combine", False):
842 url = os.path.join(o._cp_path, url)
843 if url.endswith("/") and len(url) > 1:
845 routing_map.add(routing.Rule(url, endpoint=function))
848 def get_db_router(self, db):
849 with self.db_routers_lock:
850 router = self.db_routers.get(db)
852 router = self._build_router(db)
853 with self.db_routers_lock:
854 router = self.db_routers[db] = router
857 def find_handler(self):
859 Tries to discover the controller handling the request for the path
860 specified by the provided parameters
862 :param path: path to match
863 :returns: a callable matching the path sections
864 :rtype: ``Controller | None``
866 path = request.httprequest.path
867 urls = self.get_db_router(request.db).bind("")
868 matched, arguments = urls.match(path)
869 arguments = dict([(k, v) for k, v in arguments.items() if not k.startswith("_ignored_")])
870 func, original = matched
872 def nfunc(*args, **kwargs):
873 kwargs.update(arguments)
874 return func(*args, **kwargs)
877 request.auth_method = getattr(original, "auth", "auth")
878 request.func_request_type = original.exposed
881 openerp.wsgi.register_wsgi_handler(Root())