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.
236 if isinstance(route, list):
241 if getattr(f, "auth", None) is None:
242 f.auth = authentication
254 def reject_nonliteral(dct):
257 "Non literal contexts can not be sent to the server anymore (%r)" % (dct,))
260 class JsonRequest(WebRequest):
261 """ JSON-RPC2 over HTTP.
265 --> {"jsonrpc": "2.0",
267 "params": {"session_id": "SID",
272 <-- {"jsonrpc": "2.0",
273 "result": { "res1": "val1" },
276 Request producing a error::
278 --> {"jsonrpc": "2.0",
280 "params": {"session_id": "SID",
285 <-- {"jsonrpc": "2.0",
287 "message": "End user error message.",
288 "data": {"code": "codestring",
289 "debug": "traceback" } },
293 _request_type = "json"
295 def __init__(self, *args):
296 super(JsonRequest, self).__init__(*args)
298 self.jsonp_handler = None
300 args = self.httprequest.args
301 jsonp = args.get('jsonp')
304 request_id = args.get('id')
306 if jsonp and self.httprequest.method == 'POST':
307 # jsonp 2 steps step1 POST: save call
311 self.session.jsonp_requests[request_id] = self.httprequest.form['r']
312 headers=[('Content-Type', 'text/plain; charset=utf-8')]
313 r = werkzeug.wrappers.Response(request_id, headers=headers)
315 self.jsonp_handler = handler
317 elif jsonp and args.get('r'):
319 request = args.get('r')
320 elif jsonp and request_id:
321 # jsonp 2 steps step2 GET: run and return result
323 request = self.session.jsonp_requests.pop(request_id, "")
326 request = self.httprequest.stream.read()
328 # Read POST content or POST Form Data named "request"
329 self.jsonrequest = simplejson.loads(request, object_hook=reject_nonliteral)
330 self.init(self.jsonrequest.get("params", {}))
333 """ Calls the method asked for by the JSON-RPC2 or JSONP request
335 :returns: an utf8 encoded JSON-RPC2 or JSONP reply
337 if self.jsonp_handler:
338 return self.jsonp_handler()
339 response = {"jsonrpc": "2.0" }
342 #if _logger.isEnabledFor(logging.DEBUG):
343 # _logger.debug("--> %s.%s\n%s", func.im_class.__name__, func.__name__, pprint.pformat(self.jsonrequest))
344 response['id'] = self.jsonrequest.get('id')
345 response["result"] = self._call_function(**self.params)
346 except session.AuthenticationError, e:
347 _logger.exception("Exception during JSON request handling.")
348 se = serialize_exception(e)
351 'message': "OpenERP Session Invalid",
355 _logger.exception("Exception during JSON request handling.")
356 se = serialize_exception(e)
359 'message': "OpenERP Server Error",
363 response["error"] = error
365 if _logger.isEnabledFor(logging.DEBUG):
366 _logger.debug("<--\n%s", pprint.pformat(response))
369 # If we use jsonp, that's mean we are called from another host
370 # Some browser (IE and Safari) do no allow third party cookies
371 # We need then to manage http sessions manually.
372 response['httpsessionid'] = self.httpsession.sid
373 mime = 'application/javascript'
374 body = "%s(%s);" % (self.jsonp, simplejson.dumps(response),)
376 mime = 'application/json'
377 body = simplejson.dumps(response)
379 r = werkzeug.wrappers.Response(body, headers=[('Content-Type', mime), ('Content-Length', len(body))])
382 def serialize_exception(e):
384 "name": type(e).__module__ + "." + type(e).__name__ if type(e).__module__ else type(e).__name__,
385 "debug": traceback.format_exc(),
386 "message": u"%s" % e,
387 "arguments": to_jsonable(e.args),
389 if isinstance(e, openerp.osv.osv.except_osv):
390 tmp["exception_type"] = "except_osv"
391 elif isinstance(e, openerp.exceptions.Warning):
392 tmp["exception_type"] = "warning"
393 elif isinstance(e, openerp.exceptions.AccessError):
394 tmp["exception_type"] = "access_error"
395 elif isinstance(e, openerp.exceptions.AccessDenied):
396 tmp["exception_type"] = "access_denied"
400 if isinstance(o, str) or isinstance(o,unicode) or isinstance(o, int) or isinstance(o, long) \
401 or isinstance(o, bool) or o is None or isinstance(o, float):
403 if isinstance(o, list) or isinstance(o, tuple):
404 return [to_jsonable(x) for x in o]
405 if isinstance(o, dict):
407 for k, v in o.items():
408 tmp[u"%s" % k] = to_jsonable(v)
413 """ Decorator marking the decorated method as being a handler for a
414 JSON-RPC request (the exact request path is specified via the
415 ``$(Controller._cp_path)/$methodname`` combination.
417 If the method is called, it will be provided with a :class:`JsonRequest`
418 instance and all ``params`` sent during the JSON-RPC request, apart from
419 the ``session_id``, ``context`` and ``debug`` keys (which are stripped out
424 if f.__name__ == "index":
426 return route([base, os.path.join(base, "<path:path>")], type="json", authentication="auth")(f)
428 class HttpRequest(WebRequest):
429 """ Regular GET/POST request
431 _request_type = "http"
433 def __init__(self, *args):
434 super(HttpRequest, self).__init__(*args)
435 params = dict(self.httprequest.args)
436 params.update(self.httprequest.form)
437 params.update(self.httprequest.files)
442 for key, value in self.httprequest.args.iteritems():
443 if isinstance(value, basestring) and len(value) < 1024:
446 akw[key] = type(value)
447 #_logger.debug("%s --> %s.%s %r", self.httprequest.func, func.im_class.__name__, func.__name__, akw)
449 r = self._call_function(**self.params)
450 except werkzeug.exceptions.HTTPException, e:
453 _logger.exception("An exception occured during an http request")
454 se = serialize_exception(e)
457 'message': "OpenERP Server Error",
460 r = werkzeug.exceptions.InternalServerError(cgi.escape(simplejson.dumps(error)))
463 r = werkzeug.wrappers.Response(status=204) # no content
464 if isinstance(r, (werkzeug.wrappers.BaseResponse, werkzeug.exceptions.HTTPException)):
465 _logger.debug('<-- %s', r)
467 _logger.debug("<-- size: %s", len(r))
470 def make_response(self, data, headers=None, cookies=None):
471 """ Helper for non-HTML responses, or HTML responses with custom
472 response headers or cookies.
474 While handlers can just return the HTML markup of a page they want to
475 send as a string if non-HTML data is returned they need to create a
476 complete response object, or the returned data will not be correctly
477 interpreted by the clients.
479 :param basestring data: response body
480 :param headers: HTTP headers to set on the response
481 :type headers: ``[(name, value)]``
482 :param collections.Mapping cookies: cookies to set on the client
484 response = werkzeug.wrappers.Response(data, headers=headers)
486 for k, v in cookies.iteritems():
487 response.set_cookie(k, v)
490 def not_found(self, description=None):
491 """ Helper for 404 response, return its result from the method
493 return werkzeug.exceptions.NotFound(description)
496 """ Decorator marking the decorated method as being a handler for a
497 normal HTTP request (the exact request path is specified via the
498 ``$(Controller._cp_path)/$methodname`` combination.
500 If the method is called, it will be provided with a :class:`HttpRequest`
501 instance and all ``params`` sent during the request (``GET`` and ``POST``
502 merged in the same dictionary), apart from the ``session_id``, ``context``
503 and ``debug`` keys (which are stripped out beforehand)
507 if f.__name__ == "index":
509 return route([base, os.path.join(base, "<path:path>")], type="http", authentication="auth")(f)
511 #----------------------------------------------------------
512 # Local storage of requests
513 #----------------------------------------------------------
514 from werkzeug.local import LocalStack
516 _request_stack = LocalStack()
518 def set_request(request):
519 class with_obj(object):
521 _request_stack.push(request)
522 def __exit__(self, *args):
527 A global proxy that always redirect to the current request object.
529 request = _request_stack()
531 #----------------------------------------------------------
532 # Controller registration with a metaclass
533 #----------------------------------------------------------
536 controllers_per_module = {}
538 class ControllerType(type):
539 def __init__(cls, name, bases, attrs):
540 super(ControllerType, cls).__init__(name, bases, attrs)
542 # create wrappers for old-style methods with req as first argument
543 cls._methods_wrapper = {}
544 for k, v in attrs.items():
545 if inspect.isfunction(v):
546 spec = inspect.getargspec(v)
547 first_arg = spec.args[1] if len(spec.args) >= 2 else None
548 if first_arg in ["req", "request"]:
550 return lambda self, *args, **kwargs: nv(self, request, *args, **kwargs)
551 cls._methods_wrapper[k] = build_new(v)
553 # store the controller in the controllers list
554 name_class = ("%s.%s" % (cls.__module__, cls.__name__), cls)
555 class_path = name_class[0].split(".")
556 if not class_path[:2] == ["openerp", "addons"]:
558 module = class_path[2]
559 controllers_per_module.setdefault(module, []).append(name_class)
561 class Controller(object):
562 __metaclass__ = ControllerType
564 """def __new__(cls, *args, **kwargs):
565 subclasses = [c for c in cls.__subclasses__() if getattr(c, "_cp_path", None) == getattr(cls, "_cp_path", None)]
567 name = "%s (extended by %s)" % (cls.__name__, ', '.join(sub.__name__ for sub in subclasses))
568 cls = type(name, tuple(reversed(subclasses)), {})
570 return object.__new__(cls)"""
572 def get_wrapped_method(self, name):
573 if name in self.__class__._methods_wrapper:
574 return functools.partial(self.__class__._methods_wrapper[name], self)
576 return getattr(self, name)
578 #----------------------------------------------------------
579 # Session context manager
580 #----------------------------------------------------------
581 @contextlib.contextmanager
582 def session_context(request, session_store, session_lock, sid):
585 request.session = session_store.get(sid)
587 request.session = session_store.new()
589 yield request.session
591 # Remove all OpenERPSession instances with no uid, they're generated
592 # either by login process or by HTTP requests without an OpenERP
593 # session id, and are generally noise
594 removed_sessions = set()
595 for key, value in request.session.items():
596 if not isinstance(value, session.OpenERPSession):
598 if getattr(value, '_suicide', False) or (
600 and not value.jsonp_requests
601 # FIXME do not use a fixed value
602 and value._creation_time + (60*5) < time.time()):
603 _logger.debug('remove session %s', key)
604 removed_sessions.add(key)
605 del request.session[key]
609 # Re-load sessions from storage and merge non-literal
610 # contexts and domains (they're indexed by hash of the
611 # content so conflicts should auto-resolve), otherwise if
612 # two requests alter those concurrently the last to finish
613 # will overwrite the previous one, leading to loss of data
614 # (a non-literal is lost even though it was sent to the
615 # client and client errors)
617 # note that domains_store and contexts_store are append-only (we
618 # only ever add items to them), so we can just update one with the
619 # other to get the right result, if we want to merge the
620 # ``context`` dict we'll need something smarter
621 in_store = session_store.get(sid)
622 for k, v in request.session.iteritems():
623 stored = in_store.get(k)
624 if stored and isinstance(v, session.OpenERPSession):
625 if hasattr(v, 'contexts_store'):
627 if hasattr(v, 'domains_store'):
629 if not hasattr(v, 'jsonp_requests'):
630 v.jsonp_requests = {}
631 v.jsonp_requests.update(getattr(
632 stored, 'jsonp_requests', {}))
635 for k, v in in_store.iteritems():
636 if k not in request.session and k not in removed_sessions:
637 request.session[k] = v
639 session_store.save(request.session)
641 def session_gc(session_store):
642 if random.random() < 0.001:
643 # we keep session one week
644 last_week = time.time() - 60*60*24*7
645 for fname in os.listdir(session_store.path):
646 path = os.path.join(session_store.path, fname)
648 if os.path.getmtime(path) < last_week:
653 #----------------------------------------------------------
655 #----------------------------------------------------------
656 # Add potentially missing (older ubuntu) font mime types
657 mimetypes.add_type('application/font-woff', '.woff')
658 mimetypes.add_type('application/vnd.ms-fontobject', '.eot')
659 mimetypes.add_type('application/x-font-ttf', '.ttf')
661 class DisableCacheMiddleware(object):
662 def __init__(self, app):
664 def __call__(self, environ, start_response):
665 def start_wrapped(status, headers):
666 referer = environ.get('HTTP_REFERER', '')
667 parsed = urlparse.urlparse(referer)
668 debug = parsed.query.count('debug') >= 1
671 unwanted_keys = ['Last-Modified']
673 new_headers = [('Cache-Control', 'no-cache')]
674 unwanted_keys += ['Expires', 'Etag', 'Cache-Control']
677 if k not in unwanted_keys:
678 new_headers.append((k, v))
680 start_response(status, new_headers)
681 return self.app(environ, start_wrapped)
685 username = getpass.getuser()
688 path = os.path.join(tempfile.gettempdir(), "oe-sessions-" + username)
691 except OSError as exc:
692 if exc.errno == errno.EEXIST:
693 # directory exists: ensure it has the correct permissions
694 # this will fail if the directory is not owned by the current user
701 """Root WSGI application for the OpenERP Web Client.
708 self.db_routers_lock = threading.Lock()
712 # Setup http sessions
713 path = session_path()
714 self.session_store = werkzeug.contrib.sessions.FilesystemSessionStore(path)
715 self.session_lock = threading.Lock()
716 _logger.debug('HTTP sessions stored in: %s', path)
719 def __call__(self, environ, start_response):
720 """ Handle a WSGI request
722 return self.dispatch(environ, start_response)
724 def dispatch(self, environ, start_response):
726 Performs the actual WSGI dispatching for the application, may be
727 wrapped during the initialization of the object.
729 Call the object directly.
731 httprequest = werkzeug.wrappers.Request(environ)
732 httprequest.parameter_storage_class = werkzeug.datastructures.ImmutableDict
733 httprequest.app = self
735 sid = httprequest.cookies.get('sid')
737 sid = httprequest.args.get('sid')
739 session_gc(self.session_store)
741 with session_context(httprequest, self.session_store, self.session_lock, sid) as session:
742 request = self._build_request(httprequest)
746 updated = openerp.modules.registry.RegistryManager.check_registry_signaling(db)
748 with self.db_routers_lock:
749 del self.db_routers[db]
751 with set_request(request):
753 result = request.dispatch()
756 openerp.modules.registry.RegistryManager.signal_caches_change(db)
758 if isinstance(result, basestring):
759 headers=[('Content-Type', 'text/html; charset=utf-8'), ('Content-Length', len(result))]
760 response = werkzeug.wrappers.Response(result, headers=headers)
764 if hasattr(response, 'set_cookie'):
765 response.set_cookie('sid', session.sid)
767 return response(environ, start_response)
769 def _build_request(self, httprequest):
770 if httprequest.args.get('jsonp'):
771 return JsonRequest(httprequest)
773 content = httprequest.stream.read()
775 httprequest.stream = cStringIO.StringIO(content)
777 simplejson.loads(content)
778 return JsonRequest(httprequest)
780 return HttpRequest(httprequest)
782 def load_addons(self):
783 """ Load all addons from addons patch containg static files and
784 controllers and configure them. """
786 for addons_path in openerp.modules.module.ad_paths:
787 for module in sorted(os.listdir(str(addons_path))):
788 if module not in addons_module:
789 manifest_path = os.path.join(addons_path, module, '__openerp__.py')
790 path_static = os.path.join(addons_path, module, 'static')
791 if os.path.isfile(manifest_path) and os.path.isdir(path_static):
792 manifest = ast.literal_eval(open(manifest_path).read())
793 manifest['addons_path'] = addons_path
794 _logger.debug("Loading %s", module)
795 if 'openerp.addons' in sys.modules:
796 m = __import__('openerp.addons.' + module)
798 m = __import__(module)
799 addons_module[module] = m
800 addons_manifest[module] = manifest
801 self.statics['/%s/static' % module] = path_static
803 app = werkzeug.wsgi.SharedDataMiddleware(self.dispatch, self.statics)
804 self.dispatch = DisableCacheMiddleware(app)
806 def _build_router(self, db):
807 _logger.info("Generating routing configuration for database %s" % db)
808 routing_map = routing.Map()
809 modules_set = set(controllers_per_module.keys())
810 modules_set -= set("web")
812 modules = ["web"] + sorted(modules_set)
813 # building all nodb methods
814 for module in modules:
815 for v in controllers_per_module[module]:
816 members = inspect.getmembers(v[1]())
817 for mk, mv in members:
818 if inspect.ismethod(mv) and getattr(mv, 'exposed', False) and getattr(mv, 'auth', None) == "nodb":
820 function = (o.get_wrapped_method(mk), mv)
821 for url in mv.routes:
822 if getattr(mv, "combine", False):
823 url = os.path.join(o._cp_path, url)
824 if url.endswith("/") and len(url) > 1:
826 print "<<<<<<<<<<<<<<<< nodb", url
827 routing_map.add(routing.Rule(url, endpoint=function))
832 registry = openerp.modules.registry.RegistryManager.get(db)
833 with registry.cursor() as cr:
834 m = registry.get('ir.module.module')
835 ids = m.search(cr, openerp.SUPERUSER_ID, [('state','=','installed')])
836 installed = set([x['name'] for x in m.read(cr, 1, ids, ['name'])])
837 modules_set -= set(installed)
838 modules = ["web"] + sorted(modules_set)
839 # building all other methods
840 for module in modules:
841 for v in controllers_per_module[module]:
843 members = inspect.getmembers(o)
844 for mk, mv in members:
845 if inspect.ismethod(mv) and getattr(mv, 'exposed', False) and getattr(mv, 'auth', None) != "nodb":
846 function = (o.get_wrapped_method(mk), mv)
847 for url in mv.routes:
848 if getattr(mv, "combine", False):
849 url = os.path.join(o._cp_path, url)
850 if url.endswith("/") and len(url) > 1:
852 print "<<<<<<<<<<<<<<<< db", url
853 routing_map.add(routing.Rule(url, endpoint=function))
856 def get_db_router(self, db):
857 with self.db_routers_lock:
858 router = self.db_routers.get(db)
860 router = self._build_router(db)
861 with self.db_routers_lock:
862 router = self.db_routers[db] = router
865 def find_handler(self):
867 Tries to discover the controller handling the request for the path
868 specified by the provided parameters
870 :param path: path to match
871 :returns: a callable matching the path sections
872 :rtype: ``Controller | None``
874 path = request.httprequest.path
875 urls = self.get_db_router(request.db).bind("")
876 func, original = urls.match(path)[0]
879 request.auth_method = getattr(original, "auth", "auth")
880 request.func_request_type = original.exposed
883 openerp.wsgi.register_wsgi_handler(Root())