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
41 _logger = logging.getLogger(__name__)
43 #----------------------------------------------------------
45 #----------------------------------------------------------
46 class WebRequest(object):
47 """ Parent class for all OpenERP Web request types, mostly deals with
48 initialization and setup of the request object (the dispatching itself has
49 to be handled by the subclasses)
51 :param request: a wrapped werkzeug Request object
52 :type request: :class:`werkzeug.wrappers.BaseRequest`
54 .. attribute:: httprequest
56 the original :class:`werkzeug.wrappers.Request` object provided to the
59 .. attribute:: httpsession
61 a :class:`~collections.Mapping` holding the HTTP session data for the
66 :class:`~collections.Mapping` of request parameters, not generally
67 useful as they're provided directly to the handler method as keyword
70 .. attribute:: session_id
72 opaque identifier for the :class:`session.OpenERPSession` instance of
75 .. attribute:: session
77 :class:`~session.OpenERPSession` instance for the current request
79 .. attribute:: context
81 :class:`~collections.Mapping` of context values for the current request
85 ``bool``, indicates whether the debug mode is active on the client
87 def __init__(self, httprequest, func, auth_method="auth"):
88 self.httprequest = httprequest
89 self.httpresponse = None
90 self.httpsession = httprequest.session
94 self.auth_method = auth_method
98 def init(self, params):
99 self.params = dict(params)
100 # OpenERP session setup
101 self.session_id = self.params.pop("session_id", None)
102 if not self.session_id:
103 i0 = self.httprequest.cookies.get("instance0|session_id", None)
105 self.session_id = simplejson.loads(urllib2.unquote(i0))
107 self.session_id = uuid.uuid4().hex
108 self.session = self.httpsession.get(self.session_id)
110 self.session = session.OpenERPSession()
111 self.httpsession[self.session_id] = self.session
113 # TODO: remove this shit
114 # set db/uid trackers - they're cleaned up at the WSGI
115 # dispatching phase in openerp.service.wsgi_server.application
117 threading.current_thread().dbname = self.session._db
118 if self.session._uid:
119 threading.current_thread().uid = self.session._uid
121 self.context = self.params.pop('context', {})
122 self.debug = self.params.pop('debug', False) is not False
123 # Determine self.lang
124 lang = self.params.get('lang', None)
126 lang = self.context.get('lang')
128 lang = self.httprequest.cookies.get('lang')
130 lang = self.httprequest.accept_languages.best
133 # tranform 2 letters lang like 'en' into 5 letters like 'en_US'
134 lang = babel.core.LOCALE_ALIASES.get(lang, lang)
135 # we use _ as seprator where RFC2616 uses '-'
136 self.lang = lang.replace('-', '_')
138 def authenticate(self):
139 if self.auth_method == "nodb":
142 elif self.auth_method == "noauth":
143 self.db = (self.session._db or openerp.addons.web.controllers.main.db_monodb()).lower()
147 self.session.check_security()
148 except session.SessionExpiredException, e:
149 raise session.SessionExpiredException("Session expired for request %s" % self.httprequest)
150 self.db = self.session._db
151 self.uid = self.session._uid
155 return openerp.modules.registry.RegistryManager.get(self.db) if self.db else None
159 # some magic to lazy create the cr
161 self._cr_cm = self.registry.cursor()
162 self._cr = self._cr_cm.__enter__()
166 @contextlib.contextmanager
167 def registry_cr(self):
168 return (self.registry, self.cr)
170 def _call_function(self, *args, **kwargs):
173 # ugly syntax only to get the __exit__ arguments to pass to self._cr
175 class with_obj(object):
178 def __exit__(self, *args):
180 request._cr_cm.__exit__(*args)
181 request._cr_cm = None
185 return self.func(*args, **kwargs)
187 # just to be sure no one tries to re-use the request
200 def reject_nonliteral(dct):
203 "Non literal contexts can not be sent to the server anymore (%r)" % (dct,))
206 class JsonRequest(WebRequest):
207 """ JSON-RPC2 over HTTP.
211 --> {"jsonrpc": "2.0",
213 "params": {"session_id": "SID",
218 <-- {"jsonrpc": "2.0",
219 "result": { "res1": "val1" },
222 Request producing a error::
224 --> {"jsonrpc": "2.0",
226 "params": {"session_id": "SID",
231 <-- {"jsonrpc": "2.0",
233 "message": "End user error message.",
234 "data": {"code": "codestring",
235 "debug": "traceback" } },
240 """ Calls the method asked for by the JSON-RPC2 or JSONP request
242 :returns: an utf8 encoded JSON-RPC2 or JSONP reply
244 args = self.httprequest.args
245 jsonp = args.get('jsonp')
248 request_id = args.get('id')
250 if jsonp and self.httprequest.method == 'POST':
251 # jsonp 2 steps step1 POST: save call
253 self.session.jsonp_requests[request_id] = self.httprequest.form['r']
254 headers=[('Content-Type', 'text/plain; charset=utf-8')]
255 r = werkzeug.wrappers.Response(request_id, headers=headers)
257 elif jsonp and args.get('r'):
259 request = args.get('r')
260 elif jsonp and request_id:
261 # jsonp 2 steps step2 GET: run and return result
263 request = self.session.jsonp_requests.pop(request_id, "")
266 requestf = self.httprequest.stream
268 response = {"jsonrpc": "2.0" }
271 # Read POST content or POST Form Data named "request"
273 self.jsonrequest = simplejson.load(requestf, object_hook=reject_nonliteral)
275 self.jsonrequest = simplejson.loads(request, object_hook=reject_nonliteral)
276 self.init(self.jsonrequest.get("params", {}))
277 #if _logger.isEnabledFor(logging.DEBUG):
278 # _logger.debug("--> %s.%s\n%s", func.im_class.__name__, func.__name__, pprint.pformat(self.jsonrequest))
279 response['id'] = self.jsonrequest.get('id')
280 response["result"] = self._call_function(**self.params)
281 except session.AuthenticationError, e:
282 _logger.exception("Exception during JSON request handling.")
283 se = serialize_exception(e)
286 'message': "OpenERP Session Invalid",
290 _logger.exception("Exception during JSON request handling.")
291 se = serialize_exception(e)
294 'message': "OpenERP Server Error",
298 response["error"] = error
300 if _logger.isEnabledFor(logging.DEBUG):
301 _logger.debug("<--\n%s", pprint.pformat(response))
304 # If we use jsonp, that's mean we are called from another host
305 # Some browser (IE and Safari) do no allow third party cookies
306 # We need then to manage http sessions manually.
307 response['httpsessionid'] = self.httpsession.sid
308 mime = 'application/javascript'
309 body = "%s(%s);" % (jsonp, simplejson.dumps(response),)
311 mime = 'application/json'
312 body = simplejson.dumps(response)
314 r = werkzeug.wrappers.Response(body, headers=[('Content-Type', mime), ('Content-Length', len(body))])
317 def serialize_exception(e):
319 "name": type(e).__module__ + "." + type(e).__name__ if type(e).__module__ else type(e).__name__,
320 "debug": traceback.format_exc(),
321 "message": u"%s" % e,
322 "arguments": to_jsonable(e.args),
324 if isinstance(e, openerp.osv.osv.except_osv):
325 tmp["exception_type"] = "except_osv"
326 elif isinstance(e, openerp.exceptions.Warning):
327 tmp["exception_type"] = "warning"
328 elif isinstance(e, openerp.exceptions.AccessError):
329 tmp["exception_type"] = "access_error"
330 elif isinstance(e, openerp.exceptions.AccessDenied):
331 tmp["exception_type"] = "access_denied"
335 if isinstance(o, str) or isinstance(o,unicode) or isinstance(o, int) or isinstance(o, long) \
336 or isinstance(o, bool) or o is None or isinstance(o, float):
338 if isinstance(o, list) or isinstance(o, tuple):
339 return [to_jsonable(x) for x in o]
340 if isinstance(o, dict):
342 for k, v in o.items():
343 tmp[u"%s" % k] = to_jsonable(v)
348 """ Decorator marking the decorated method as being a handler for a
349 JSON-RPC request (the exact request path is specified via the
350 ``$(Controller._cp_path)/$methodname`` combination.
352 If the method is called, it will be provided with a :class:`JsonRequest`
353 instance and all ``params`` sent during the JSON-RPC request, apart from
354 the ``session_id``, ``context`` and ``debug`` keys (which are stripped out
360 class HttpRequest(WebRequest):
361 """ Regular GET/POST request
364 params = dict(self.httprequest.args)
365 params.update(self.httprequest.form)
366 params.update(self.httprequest.files)
369 for key, value in self.httprequest.args.iteritems():
370 if isinstance(value, basestring) and len(value) < 1024:
373 akw[key] = type(value)
374 #_logger.debug("%s --> %s.%s %r", self.httprequest.func, func.im_class.__name__, func.__name__, akw)
376 r = self._call_function(**self.params)
377 except werkzeug.exceptions.HTTPException, e:
380 _logger.exception("An exception occured during an http request")
381 se = serialize_exception(e)
384 'message': "OpenERP Server Error",
387 r = werkzeug.exceptions.InternalServerError(cgi.escape(simplejson.dumps(error)))
390 r = werkzeug.wrappers.Response(status=204) # no content
391 if isinstance(r, (werkzeug.wrappers.BaseResponse, werkzeug.exceptions.HTTPException)):
392 _logger.debug('<-- %s', r)
394 _logger.debug("<-- size: %s", len(r))
397 def make_response(self, data, headers=None, cookies=None):
398 """ Helper for non-HTML responses, or HTML responses with custom
399 response headers or cookies.
401 While handlers can just return the HTML markup of a page they want to
402 send as a string if non-HTML data is returned they need to create a
403 complete response object, or the returned data will not be correctly
404 interpreted by the clients.
406 :param basestring data: response body
407 :param headers: HTTP headers to set on the response
408 :type headers: ``[(name, value)]``
409 :param collections.Mapping cookies: cookies to set on the client
411 response = werkzeug.wrappers.Response(data, headers=headers)
413 for k, v in cookies.iteritems():
414 response.set_cookie(k, v)
417 def not_found(self, description=None):
418 """ Helper for 404 response, return its result from the method
420 return werkzeug.exceptions.NotFound(description)
423 """ Decorator marking the decorated method as being a handler for a
424 normal HTTP request (the exact request path is specified via the
425 ``$(Controller._cp_path)/$methodname`` combination.
427 If the method is called, it will be provided with a :class:`HttpRequest`
428 instance and all ``params`` sent during the request (``GET`` and ``POST``
429 merged in the same dictionary), apart from the ``session_id``, ``context``
430 and ``debug`` keys (which are stripped out beforehand)
435 #----------------------------------------------------------
436 # Local storage of requests
437 #----------------------------------------------------------
438 from werkzeug.local import LocalStack
440 _request_stack = LocalStack()
442 def set_request(request):
443 class with_obj(object):
445 _request_stack.push(request)
446 def __exit__(self, *args):
450 request = _request_stack()
452 #----------------------------------------------------------
453 # Controller registration with a metaclass
454 #----------------------------------------------------------
457 controllers_class = []
458 controllers_class_path = {}
459 controllers_object = {}
460 controllers_object_path = {}
461 controllers_path = {}
463 class ControllerType(type):
464 def __init__(cls, name, bases, attrs):
465 super(ControllerType, cls).__init__(name, bases, attrs)
467 # create wrappers for old-style methods with req as first argument
468 cls._methods_wrapper = {}
469 for k, v in attrs.items():
470 if inspect.isfunction(v):
471 spec = inspect.getargspec(v)
472 first_arg = spec.args[1] if len(spec.args) >= 2 else None
473 if first_arg in ["req", "request"]:
475 return lambda self, *args, **kwargs: nv(self, request, *args, **kwargs)
476 cls._methods_wrapper[k] = build_new(v)
478 # store the controller in the controllers list
479 name_class = ("%s.%s" % (cls.__module__, cls.__name__), cls)
480 controllers_class.append(name_class)
481 path = attrs.get('_cp_path')
482 if path not in controllers_class_path:
483 controllers_class_path[path] = name_class
485 class Controller(object):
486 __metaclass__ = ControllerType
488 def __new__(cls, *args, **kwargs):
489 subclasses = [c for c in cls.__subclasses__() if c._cp_path == cls._cp_path]
491 name = "%s (extended by %s)" % (cls.__name__, ', '.join(sub.__name__ for sub in subclasses))
492 cls = type(name, tuple(reversed(subclasses)), {})
494 return object.__new__(cls)
496 def get_wrapped_method(self, name):
497 if name in self.__class__._methods_wrapper:
498 return functools.partial(self.__class__._methods_wrapper[name], self)
500 return getattr(self, name)
502 #----------------------------------------------------------
503 # Session context manager
504 #----------------------------------------------------------
505 @contextlib.contextmanager
506 def session_context(request, session_store, session_lock, sid):
509 request.session = session_store.get(sid)
511 request.session = session_store.new()
513 yield request.session
515 # Remove all OpenERPSession instances with no uid, they're generated
516 # either by login process or by HTTP requests without an OpenERP
517 # session id, and are generally noise
518 removed_sessions = set()
519 for key, value in request.session.items():
520 if not isinstance(value, session.OpenERPSession):
522 if getattr(value, '_suicide', False) or (
524 and not value.jsonp_requests
525 # FIXME do not use a fixed value
526 and value._creation_time + (60*5) < time.time()):
527 _logger.debug('remove session %s', key)
528 removed_sessions.add(key)
529 del request.session[key]
533 # Re-load sessions from storage and merge non-literal
534 # contexts and domains (they're indexed by hash of the
535 # content so conflicts should auto-resolve), otherwise if
536 # two requests alter those concurrently the last to finish
537 # will overwrite the previous one, leading to loss of data
538 # (a non-literal is lost even though it was sent to the
539 # client and client errors)
541 # note that domains_store and contexts_store are append-only (we
542 # only ever add items to them), so we can just update one with the
543 # other to get the right result, if we want to merge the
544 # ``context`` dict we'll need something smarter
545 in_store = session_store.get(sid)
546 for k, v in request.session.iteritems():
547 stored = in_store.get(k)
548 if stored and isinstance(v, session.OpenERPSession):
549 if hasattr(v, 'contexts_store'):
551 if hasattr(v, 'domains_store'):
553 if not hasattr(v, 'jsonp_requests'):
554 v.jsonp_requests = {}
555 v.jsonp_requests.update(getattr(
556 stored, 'jsonp_requests', {}))
559 for k, v in in_store.iteritems():
560 if k not in request.session and k not in removed_sessions:
561 request.session[k] = v
563 session_store.save(request.session)
565 def session_gc(session_store):
566 if random.random() < 0.001:
567 # we keep session one week
568 last_week = time.time() - 60*60*24*7
569 for fname in os.listdir(session_store.path):
570 path = os.path.join(session_store.path, fname)
572 if os.path.getmtime(path) < last_week:
577 #----------------------------------------------------------
579 #----------------------------------------------------------
580 # Add potentially missing (older ubuntu) font mime types
581 mimetypes.add_type('application/font-woff', '.woff')
582 mimetypes.add_type('application/vnd.ms-fontobject', '.eot')
583 mimetypes.add_type('application/x-font-ttf', '.ttf')
585 class DisableCacheMiddleware(object):
586 def __init__(self, app):
588 def __call__(self, environ, start_response):
589 def start_wrapped(status, headers):
590 referer = environ.get('HTTP_REFERER', '')
591 parsed = urlparse.urlparse(referer)
592 debug = parsed.query.count('debug') >= 1
595 unwanted_keys = ['Last-Modified']
597 new_headers = [('Cache-Control', 'no-cache')]
598 unwanted_keys += ['Expires', 'Etag', 'Cache-Control']
601 if k not in unwanted_keys:
602 new_headers.append((k, v))
604 start_response(status, new_headers)
605 return self.app(environ, start_wrapped)
609 username = getpass.getuser()
612 path = os.path.join(tempfile.gettempdir(), "oe-sessions-" + username)
615 except OSError as exc:
616 if exc.errno == errno.EEXIST:
617 # directory exists: ensure it has the correct permissions
618 # this will fail if the directory is not owned by the current user
625 """Root WSGI application for the OpenERP Web Client.
633 # Setup http sessions
634 path = session_path()
635 self.session_store = werkzeug.contrib.sessions.FilesystemSessionStore(path)
636 self.session_lock = threading.Lock()
637 _logger.debug('HTTP sessions stored in: %s', path)
639 def __call__(self, environ, start_response):
640 """ Handle a WSGI request
642 return self.dispatch(environ, start_response)
644 def dispatch(self, environ, start_response):
646 Performs the actual WSGI dispatching for the application, may be
647 wrapped during the initialization of the object.
649 Call the object directly.
651 request = werkzeug.wrappers.Request(environ)
652 request.parameter_storage_class = werkzeug.datastructures.ImmutableDict
655 handler = self.find_handler(*(request.path.split('/')[1:]))
658 response = werkzeug.exceptions.NotFound()
660 sid = request.cookies.get('sid')
662 sid = request.args.get('sid')
664 session_gc(self.session_store)
666 with session_context(request, self.session_store, self.session_lock, sid) as session:
667 result = handler(request)
669 if isinstance(result, basestring):
670 headers=[('Content-Type', 'text/html; charset=utf-8'), ('Content-Length', len(result))]
671 response = werkzeug.wrappers.Response(result, headers=headers)
675 if hasattr(response, 'set_cookie'):
676 response.set_cookie('sid', session.sid)
678 return response(environ, start_response)
680 def load_addons(self):
681 """ Load all addons from addons patch containg static files and
682 controllers and configure them. """
684 for addons_path in openerp.modules.module.ad_paths:
685 for module in sorted(os.listdir(str(addons_path))):
686 if module not in addons_module:
687 manifest_path = os.path.join(addons_path, module, '__openerp__.py')
688 path_static = os.path.join(addons_path, module, 'static')
689 if os.path.isfile(manifest_path) and os.path.isdir(path_static):
690 manifest = ast.literal_eval(open(manifest_path).read())
691 manifest['addons_path'] = addons_path
692 _logger.debug("Loading %s", module)
693 if 'openerp.addons' in sys.modules:
694 m = __import__('openerp.addons.' + module)
696 m = __import__(module)
697 addons_module[module] = m
698 addons_manifest[module] = manifest
699 self.statics['/%s/static' % module] = path_static
701 for k, v in controllers_class_path.items():
702 if k not in controllers_object_path and hasattr(v[1], '_cp_path'):
704 controllers_object[v[0]] = o
705 controllers_object_path[k] = o
706 if hasattr(o, '_cp_path'):
707 controllers_path[o._cp_path] = o
709 app = werkzeug.wsgi.SharedDataMiddleware(self.dispatch, self.statics)
710 self.dispatch = DisableCacheMiddleware(app)
712 def find_handler(self, *l):
714 Tries to discover the controller handling the request for the path
715 specified by the provided parameters
717 :param l: path sections to a controller or controller method
718 :returns: a callable matching the path sections, or ``None``
719 :rtype: ``Controller | None``
722 ps = '/' + '/'.join(filter(None, l))
723 method_name = 'index'
725 c = controllers_path.get(ps)
727 method = getattr(c, method_name, None)
729 exposed = getattr(method, 'exposed', False)
730 auth = getattr(method, 'auth', "auth")
731 method = c.get_wrapped_method(method_name)
732 if exposed == 'json':
733 _logger.debug("Dispatch json to %s %s %s", ps, c, method_name)
735 _req = JsonRequest(_request, method, auth)
736 with set_request(_req):
737 return request.dispatch()
739 elif exposed == 'http':
740 _logger.debug("Dispatch http to %s %s %s", ps, c, method_name)
742 _req = HttpRequest(_request, method, auth)
743 with set_request(_req):
744 return request.dispatch()
746 if method_name != "index":
747 method_name = "index"
749 ps, _slash, method_name = ps.rpartition('/')
750 if not ps and method_name:
755 openerp.wsgi.register_wsgi_handler(Root())