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
40 _logger = logging.getLogger(__name__)
42 #----------------------------------------------------------
44 #----------------------------------------------------------
45 class WebRequest(object):
46 """ Parent class for all OpenERP Web request types, mostly deals with
47 initialization and setup of the request object (the dispatching itself has
48 to be handled by the subclasses)
50 :param request: a wrapped werkzeug Request object
51 :type request: :class:`werkzeug.wrappers.BaseRequest`
53 .. attribute:: httprequest
55 the original :class:`werkzeug.wrappers.Request` object provided to the
58 .. attribute:: httpsession
60 a :class:`~collections.Mapping` holding the HTTP session data for the
65 :class:`~collections.Mapping` of request parameters, not generally
66 useful as they're provided directly to the handler method as keyword
69 .. attribute:: session_id
71 opaque identifier for the :class:`session.OpenERPSession` instance of
74 .. attribute:: session
76 :class:`~session.OpenERPSession` instance for the current request
78 .. attribute:: context
80 :class:`~collections.Mapping` of context values for the current request
84 ``bool``, indicates whether the debug mode is active on the client
86 def __init__(self, request):
87 self.httprequest = request
88 self.httpresponse = None
89 self.httpsession = request.session
91 def init(self, params):
92 self.params = dict(params)
93 # OpenERP session setup
94 self.session_id = self.params.pop("session_id", None) or uuid.uuid4().hex
95 self.session = self.httpsession.get(self.session_id)
97 self.session = session.OpenERPSession()
98 self.httpsession[self.session_id] = self.session
100 # set db/uid trackers - they're cleaned up at the WSGI
101 # dispatching phase in openerp.service.wsgi_server.application
103 threading.current_thread().dbname = self.session._db
104 if self.session._uid:
105 threading.current_thread().uid = self.session._uid
107 self.context = self.params.pop('context', {})
108 self.debug = self.params.pop('debug', False) is not False
109 # Determine self.lang
110 lang = self.params.get('lang', None)
112 lang = self.context.get('lang')
114 lang = self.httprequest.cookies.get('lang')
116 lang = self.httprequest.accept_languages.best
119 # tranform 2 letters lang like 'en' into 5 letters like 'en_US'
120 lang = babel.core.LOCALE_ALIASES.get(lang, lang)
121 # we use _ as seprator where RFC2616 uses '-'
122 self.lang = lang.replace('-', '_')
124 def reject_nonliteral(dct):
127 "Non literal contexts can not be sent to the server anymore (%r)" % (dct,))
130 class JsonRequest(WebRequest):
131 """ JSON-RPC2 over HTTP.
135 --> {"jsonrpc": "2.0",
137 "params": {"session_id": "SID",
142 <-- {"jsonrpc": "2.0",
143 "result": { "res1": "val1" },
146 Request producing a error::
148 --> {"jsonrpc": "2.0",
150 "params": {"session_id": "SID",
155 <-- {"jsonrpc": "2.0",
157 "message": "End user error message.",
158 "data": {"code": "codestring",
159 "debug": "traceback" } },
163 def dispatch(self, method):
164 """ Calls the method asked for by the JSON-RPC2 or JSONP request
166 :param method: the method which received the request
168 :returns: an utf8 encoded JSON-RPC2 or JSONP reply
170 args = self.httprequest.args
171 jsonp = args.get('jsonp')
174 request_id = args.get('id')
176 if jsonp and self.httprequest.method == 'POST':
177 # jsonp 2 steps step1 POST: save call
179 self.session.jsonp_requests[request_id] = self.httprequest.form['r']
180 headers=[('Content-Type', 'text/plain; charset=utf-8')]
181 r = werkzeug.wrappers.Response(request_id, headers=headers)
183 elif jsonp and args.get('r'):
185 request = args.get('r')
186 elif jsonp and request_id:
187 # jsonp 2 steps step2 GET: run and return result
189 request = self.session.jsonp_requests.pop(request_id, "")
192 requestf = self.httprequest.stream
194 response = {"jsonrpc": "2.0" }
197 # Read POST content or POST Form Data named "request"
199 self.jsonrequest = simplejson.load(requestf, object_hook=reject_nonliteral)
201 self.jsonrequest = simplejson.loads(request, object_hook=reject_nonliteral)
202 self.init(self.jsonrequest.get("params", {}))
203 #if _logger.isEnabledFor(logging.DEBUG):
204 # _logger.debug("--> %s.%s\n%s", method.im_class.__name__, method.__name__, pprint.pformat(self.jsonrequest))
205 response['id'] = self.jsonrequest.get('id')
206 response["result"] = method(**self.params)
207 except session.AuthenticationError, e:
208 se = serialize_exception(e)
211 'message': "OpenERP Session Invalid",
215 se = serialize_exception(e)
218 'message': "OpenERP Server Error",
222 response["error"] = error
224 if _logger.isEnabledFor(logging.DEBUG):
225 _logger.debug("<--\n%s", pprint.pformat(response))
228 # If we use jsonp, that's mean we are called from another host
229 # Some browser (IE and Safari) do no allow third party cookies
230 # We need then to manage http sessions manually.
231 response['httpsessionid'] = self.httpsession.sid
232 mime = 'application/javascript'
233 body = "%s(%s);" % (jsonp, simplejson.dumps(response),)
235 mime = 'application/json'
236 body = simplejson.dumps(response)
238 r = werkzeug.wrappers.Response(body, headers=[('Content-Type', mime), ('Content-Length', len(body))])
241 def serialize_exception(e):
243 "name": type(e).__module__ + "." + type(e).__name__ if type(e).__module__ else type(e).__name__,
244 "debug": traceback.format_exc(),
245 "message": u"%s" % e,
246 "arguments": to_jsonable(e.args),
248 if isinstance(e, openerp.osv.osv.except_osv):
249 tmp["exception_type"] = "except_osv"
250 elif isinstance(e, openerp.exceptions.Warning):
251 tmp["exception_type"] = "warning"
252 elif isinstance(e, openerp.exceptions.AccessError):
253 tmp["exception_type"] = "access_error"
254 elif isinstance(e, openerp.exceptions.AccessDenied):
255 tmp["exception_type"] = "access_denied"
259 if isinstance(o, str) or isinstance(o,unicode) or isinstance(o, int) or isinstance(o, long) \
260 or isinstance(o, bool) or o is None or isinstance(o, float):
262 if isinstance(o, list) or isinstance(o, tuple):
263 return [to_jsonable(x) for x in o]
264 if isinstance(o, dict):
266 for k, v in o.items():
267 tmp[u"%s" % k] = to_jsonable(v)
272 """ Decorator marking the decorated method as being a handler for a
273 JSON-RPC request (the exact request path is specified via the
274 ``$(Controller._cp_path)/$methodname`` combination.
276 If the method is called, it will be provided with a :class:`JsonRequest`
277 instance and all ``params`` sent during the JSON-RPC request, apart from
278 the ``session_id``, ``context`` and ``debug`` keys (which are stripped out
284 class HttpRequest(WebRequest):
285 """ Regular GET/POST request
287 def dispatch(self, method):
288 params = dict(self.httprequest.args)
289 params.update(self.httprequest.form)
290 params.update(self.httprequest.files)
293 for key, value in self.httprequest.args.iteritems():
294 if isinstance(value, basestring) and len(value) < 1024:
297 akw[key] = type(value)
298 #_logger.debug("%s --> %s.%s %r", self.httprequest.method, method.im_class.__name__, method.__name__, akw)
300 r = method(**self.params)
302 _logger.exception("An exception occured during an http request")
303 se = serialize_exception(e)
306 'message': "OpenERP Server Error",
309 r = werkzeug.exceptions.InternalServerError(cgi.escape(simplejson.dumps(error)))
311 if isinstance(r, (werkzeug.wrappers.BaseResponse, werkzeug.exceptions.HTTPException)):
312 _logger.debug('<-- %s', r)
314 _logger.debug("<-- size: %s", len(r))
317 def make_response(self, data, headers=None, cookies=None):
318 """ Helper for non-HTML responses, or HTML responses with custom
319 response headers or cookies.
321 While handlers can just return the HTML markup of a page they want to
322 send as a string if non-HTML data is returned they need to create a
323 complete response object, or the returned data will not be correctly
324 interpreted by the clients.
326 :param basestring data: response body
327 :param headers: HTTP headers to set on the response
328 :type headers: ``[(name, value)]``
329 :param collections.Mapping cookies: cookies to set on the client
331 response = werkzeug.wrappers.Response(data, headers=headers)
333 for k, v in cookies.iteritems():
334 response.set_cookie(k, v)
337 def not_found(self, description=None):
338 """ Helper for 404 response, return its result from the method
340 return werkzeug.exceptions.NotFound(description)
343 """ Decorator marking the decorated method as being a handler for a
344 normal HTTP request (the exact request path is specified via the
345 ``$(Controller._cp_path)/$methodname`` combination.
347 If the method is called, it will be provided with a :class:`HttpRequest`
348 instance and all ``params`` sent during the request (``GET`` and ``POST``
349 merged in the same dictionary), apart from the ``session_id``, ``context``
350 and ``debug`` keys (which are stripped out beforehand)
355 #----------------------------------------------------------
356 # Local storage of requests
357 #----------------------------------------------------------
358 _thlocal = threading.local()
360 class RequestProxy(object):
361 def __getattr__(self, name):
362 return getattr(_thlocal.stack[-1], name)
363 def __setattr__(self, name, val):
364 return setattr(_thlocal.stack[-1], name, val)
365 def __delattr__(self, name):
366 return delattr(_thlocal.stack[-1], name)
368 def set_request(cls, request):
371 if getattr(_thlocal, "stack", None) is None:
373 _thlocal.stack.append(request)
374 def __exit__(self, *args):
378 request = RequestProxy()
380 #----------------------------------------------------------
381 # Controller registration with a metaclass
382 #----------------------------------------------------------
385 controllers_class = []
386 controllers_class_path = {}
387 controllers_object = {}
388 controllers_object_path = {}
389 controllers_path = {}
391 class ControllerType(type):
392 def __init__(cls, name, bases, attrs):
393 super(ControllerType, cls).__init__(name, bases, attrs)
395 # create wrappers for old-style methods with req as first argument
396 cls._methods_wrapper = {}
397 for k, v in attrs.items():
398 if inspect.isfunction(v):
399 spec = inspect.getargspec(v)
400 first_arg = spec.args[1] if len(spec.args) >= 2 else None
401 if first_arg in ["req", "request"]:
403 return lambda self, *args, **kwargs: nv(self, request, *args, **kwargs)
404 cls._methods_wrapper[k] = build_new(v)
406 # store the controller in the controllers list
407 name_class = ("%s.%s" % (cls.__module__, cls.__name__), cls)
408 controllers_class.append(name_class)
409 path = attrs.get('_cp_path')
410 if path not in controllers_class_path:
411 controllers_class_path[path] = name_class
413 class Controller(object):
414 __metaclass__ = ControllerType
416 def __new__(cls, *args, **kwargs):
417 subclasses = [c for c in cls.__subclasses__() if c._cp_path == cls._cp_path]
419 name = "%s (extended by %s)" % (cls.__name__, ', '.join(sub.__name__ for sub in subclasses))
420 cls = type(name, tuple(reversed(subclasses)), {})
422 return object.__new__(cls)
424 def get_wrapped_method(self, name):
425 if name in self.__class__._methods_wrapper:
426 return functools.partial(self.__class__._methods_wrapper[name], self)
428 return getattr(self, name)
430 #----------------------------------------------------------
431 # Session context manager
432 #----------------------------------------------------------
433 @contextlib.contextmanager
434 def session_context(request, session_store, session_lock, sid):
437 request.session = session_store.get(sid)
439 request.session = session_store.new()
441 yield request.session
443 # Remove all OpenERPSession instances with no uid, they're generated
444 # either by login process or by HTTP requests without an OpenERP
445 # session id, and are generally noise
446 removed_sessions = set()
447 for key, value in request.session.items():
448 if not isinstance(value, session.OpenERPSession):
450 if getattr(value, '_suicide', False) or (
452 and not value.jsonp_requests
453 # FIXME do not use a fixed value
454 and value._creation_time + (60*5) < time.time()):
455 _logger.debug('remove session %s', key)
456 removed_sessions.add(key)
457 del request.session[key]
461 # Re-load sessions from storage and merge non-literal
462 # contexts and domains (they're indexed by hash of the
463 # content so conflicts should auto-resolve), otherwise if
464 # two requests alter those concurrently the last to finish
465 # will overwrite the previous one, leading to loss of data
466 # (a non-literal is lost even though it was sent to the
467 # client and client errors)
469 # note that domains_store and contexts_store are append-only (we
470 # only ever add items to them), so we can just update one with the
471 # other to get the right result, if we want to merge the
472 # ``context`` dict we'll need something smarter
473 in_store = session_store.get(sid)
474 for k, v in request.session.iteritems():
475 stored = in_store.get(k)
476 if stored and isinstance(v, session.OpenERPSession):
477 if hasattr(v, 'contexts_store'):
479 if hasattr(v, 'domains_store'):
481 if not hasattr(v, 'jsonp_requests'):
482 v.jsonp_requests = {}
483 v.jsonp_requests.update(getattr(
484 stored, 'jsonp_requests', {}))
487 for k, v in in_store.iteritems():
488 if k not in request.session and k not in removed_sessions:
489 request.session[k] = v
491 session_store.save(request.session)
493 def session_gc(session_store):
494 if random.random() < 0.001:
495 # we keep session one week
496 last_week = time.time() - 60*60*24*7
497 for fname in os.listdir(session_store.path):
498 path = os.path.join(session_store.path, fname)
500 if os.path.getmtime(path) < last_week:
505 #----------------------------------------------------------
507 #----------------------------------------------------------
508 # Add potentially missing (older ubuntu) font mime types
509 mimetypes.add_type('application/font-woff', '.woff')
510 mimetypes.add_type('application/vnd.ms-fontobject', '.eot')
511 mimetypes.add_type('application/x-font-ttf', '.ttf')
513 class DisableCacheMiddleware(object):
514 def __init__(self, app):
516 def __call__(self, environ, start_response):
517 def start_wrapped(status, headers):
518 referer = environ.get('HTTP_REFERER', '')
519 parsed = urlparse.urlparse(referer)
520 debug = parsed.query.count('debug') >= 1
523 unwanted_keys = ['Last-Modified']
525 new_headers = [('Cache-Control', 'no-cache')]
526 unwanted_keys += ['Expires', 'Etag', 'Cache-Control']
529 if k not in unwanted_keys:
530 new_headers.append((k, v))
532 start_response(status, new_headers)
533 return self.app(environ, start_wrapped)
537 username = getpass.getuser()
540 path = os.path.join(tempfile.gettempdir(), "oe-sessions-" + username)
543 except OSError as exc:
544 if exc.errno == errno.EEXIST:
545 # directory exists: ensure it has the correct permissions
546 # this will fail if the directory is not owned by the current user
553 """Root WSGI application for the OpenERP Web Client.
561 # Setup http sessions
562 path = session_path()
563 self.session_store = werkzeug.contrib.sessions.FilesystemSessionStore(path)
564 self.session_lock = threading.Lock()
565 _logger.debug('HTTP sessions stored in: %s', path)
567 def __call__(self, environ, start_response):
568 """ Handle a WSGI request
570 return self.dispatch(environ, start_response)
572 def dispatch(self, environ, start_response):
574 Performs the actual WSGI dispatching for the application, may be
575 wrapped during the initialization of the object.
577 Call the object directly.
579 request = werkzeug.wrappers.Request(environ)
580 request.parameter_storage_class = werkzeug.datastructures.ImmutableDict
583 handler = self.find_handler(*(request.path.split('/')[1:]))
586 response = werkzeug.exceptions.NotFound()
588 sid = request.cookies.get('sid')
590 sid = request.args.get('sid')
592 session_gc(self.session_store)
594 with session_context(request, self.session_store, self.session_lock, sid) as session:
595 result = handler(request)
597 if isinstance(result, basestring):
598 headers=[('Content-Type', 'text/html; charset=utf-8'), ('Content-Length', len(result))]
599 response = werkzeug.wrappers.Response(result, headers=headers)
603 if hasattr(response, 'set_cookie'):
604 response.set_cookie('sid', session.sid)
606 return response(environ, start_response)
608 def load_addons(self):
609 """ Load all addons from addons patch containg static files and
610 controllers and configure them. """
612 for addons_path in openerp.modules.module.ad_paths:
613 for module in sorted(os.listdir(str(addons_path))):
614 if module not in addons_module:
615 manifest_path = os.path.join(addons_path, module, '__openerp__.py')
616 path_static = os.path.join(addons_path, module, 'static')
617 if os.path.isfile(manifest_path) and os.path.isdir(path_static):
618 manifest = ast.literal_eval(open(manifest_path).read())
619 manifest['addons_path'] = addons_path
620 _logger.debug("Loading %s", module)
621 if 'openerp.addons' in sys.modules:
622 m = __import__('openerp.addons.' + module)
624 m = __import__(module)
625 addons_module[module] = m
626 addons_manifest[module] = manifest
627 self.statics['/%s/static' % module] = path_static
629 for k, v in controllers_class_path.items():
630 if k not in controllers_object_path and hasattr(v[1], '_cp_path'):
632 controllers_object[v[0]] = o
633 controllers_object_path[k] = o
634 if hasattr(o, '_cp_path'):
635 controllers_path[o._cp_path] = o
637 app = werkzeug.wsgi.SharedDataMiddleware(self.dispatch, self.statics)
638 self.dispatch = DisableCacheMiddleware(app)
640 def find_handler(self, *l):
642 Tries to discover the controller handling the request for the path
643 specified by the provided parameters
645 :param l: path sections to a controller or controller method
646 :returns: a callable matching the path sections, or ``None``
647 :rtype: ``Controller | None``
650 ps = '/' + '/'.join(filter(None, l))
651 method_name = 'index'
653 c = controllers_path.get(ps)
655 method = getattr(c, method_name, None)
657 exposed = getattr(method, 'exposed', False)
658 method = c.get_wrapped_method(method_name)
659 if exposed == 'json':
660 _logger.debug("Dispatch json to %s %s %s", ps, c, method_name)
662 req = JsonRequest(request)
663 with RequestProxy.set_request(req):
664 return req.dispatch(method)
666 elif exposed == 'http':
667 _logger.debug("Dispatch http to %s %s %s", ps, c, method_name)
669 req = HttpRequest(request)
670 with RequestProxy.set_request(req):
671 return req.dispatch(method)
673 if method_name != "index":
674 method_name = "index"
676 ps, _slash, method_name = ps.rpartition('/')
677 if not ps and method_name:
682 openerp.wsgi.register_wsgi_handler(Root())