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 from werkzeug.local import LocalStack
360 _request_stack = LocalStack()
362 def set_request(request):
363 class with_obj(object):
365 _request_stack.push(request)
366 def __exit__(self, *args):
370 request = _request_stack()
372 #----------------------------------------------------------
373 # Controller registration with a metaclass
374 #----------------------------------------------------------
377 controllers_class = []
378 controllers_class_path = {}
379 controllers_object = {}
380 controllers_object_path = {}
381 controllers_path = {}
383 class ControllerType(type):
384 def __init__(cls, name, bases, attrs):
385 super(ControllerType, cls).__init__(name, bases, attrs)
387 # create wrappers for old-style methods with req as first argument
388 cls._methods_wrapper = {}
389 for k, v in attrs.items():
390 if inspect.isfunction(v):
391 spec = inspect.getargspec(v)
392 first_arg = spec.args[1] if len(spec.args) >= 2 else None
393 if first_arg in ["req", "request"]:
395 return lambda self, *args, **kwargs: nv(self, request, *args, **kwargs)
396 cls._methods_wrapper[k] = build_new(v)
398 # store the controller in the controllers list
399 name_class = ("%s.%s" % (cls.__module__, cls.__name__), cls)
400 controllers_class.append(name_class)
401 path = attrs.get('_cp_path')
402 if path not in controllers_class_path:
403 controllers_class_path[path] = name_class
405 class Controller(object):
406 __metaclass__ = ControllerType
408 def __new__(cls, *args, **kwargs):
409 subclasses = [c for c in cls.__subclasses__() if c._cp_path == cls._cp_path]
411 name = "%s (extended by %s)" % (cls.__name__, ', '.join(sub.__name__ for sub in subclasses))
412 cls = type(name, tuple(reversed(subclasses)), {})
414 return object.__new__(cls)
416 def get_wrapped_method(self, name):
417 if name in self.__class__._methods_wrapper:
418 return functools.partial(self.__class__._methods_wrapper[name], self)
420 return getattr(self, name)
422 #----------------------------------------------------------
423 # Session context manager
424 #----------------------------------------------------------
425 @contextlib.contextmanager
426 def session_context(request, session_store, session_lock, sid):
429 request.session = session_store.get(sid)
431 request.session = session_store.new()
433 yield request.session
435 # Remove all OpenERPSession instances with no uid, they're generated
436 # either by login process or by HTTP requests without an OpenERP
437 # session id, and are generally noise
438 removed_sessions = set()
439 for key, value in request.session.items():
440 if not isinstance(value, session.OpenERPSession):
442 if getattr(value, '_suicide', False) or (
444 and not value.jsonp_requests
445 # FIXME do not use a fixed value
446 and value._creation_time + (60*5) < time.time()):
447 _logger.debug('remove session %s', key)
448 removed_sessions.add(key)
449 del request.session[key]
453 # Re-load sessions from storage and merge non-literal
454 # contexts and domains (they're indexed by hash of the
455 # content so conflicts should auto-resolve), otherwise if
456 # two requests alter those concurrently the last to finish
457 # will overwrite the previous one, leading to loss of data
458 # (a non-literal is lost even though it was sent to the
459 # client and client errors)
461 # note that domains_store and contexts_store are append-only (we
462 # only ever add items to them), so we can just update one with the
463 # other to get the right result, if we want to merge the
464 # ``context`` dict we'll need something smarter
465 in_store = session_store.get(sid)
466 for k, v in request.session.iteritems():
467 stored = in_store.get(k)
468 if stored and isinstance(v, session.OpenERPSession):
469 if hasattr(v, 'contexts_store'):
471 if hasattr(v, 'domains_store'):
473 if not hasattr(v, 'jsonp_requests'):
474 v.jsonp_requests = {}
475 v.jsonp_requests.update(getattr(
476 stored, 'jsonp_requests', {}))
479 for k, v in in_store.iteritems():
480 if k not in request.session and k not in removed_sessions:
481 request.session[k] = v
483 session_store.save(request.session)
485 def session_gc(session_store):
486 if random.random() < 0.001:
487 # we keep session one week
488 last_week = time.time() - 60*60*24*7
489 for fname in os.listdir(session_store.path):
490 path = os.path.join(session_store.path, fname)
492 if os.path.getmtime(path) < last_week:
497 #----------------------------------------------------------
499 #----------------------------------------------------------
500 # Add potentially missing (older ubuntu) font mime types
501 mimetypes.add_type('application/font-woff', '.woff')
502 mimetypes.add_type('application/vnd.ms-fontobject', '.eot')
503 mimetypes.add_type('application/x-font-ttf', '.ttf')
505 class DisableCacheMiddleware(object):
506 def __init__(self, app):
508 def __call__(self, environ, start_response):
509 def start_wrapped(status, headers):
510 referer = environ.get('HTTP_REFERER', '')
511 parsed = urlparse.urlparse(referer)
512 debug = parsed.query.count('debug') >= 1
515 unwanted_keys = ['Last-Modified']
517 new_headers = [('Cache-Control', 'no-cache')]
518 unwanted_keys += ['Expires', 'Etag', 'Cache-Control']
521 if k not in unwanted_keys:
522 new_headers.append((k, v))
524 start_response(status, new_headers)
525 return self.app(environ, start_wrapped)
529 username = getpass.getuser()
532 path = os.path.join(tempfile.gettempdir(), "oe-sessions-" + username)
535 except OSError as exc:
536 if exc.errno == errno.EEXIST:
537 # directory exists: ensure it has the correct permissions
538 # this will fail if the directory is not owned by the current user
545 """Root WSGI application for the OpenERP Web Client.
553 # Setup http sessions
554 path = session_path()
555 self.session_store = werkzeug.contrib.sessions.FilesystemSessionStore(path)
556 self.session_lock = threading.Lock()
557 _logger.debug('HTTP sessions stored in: %s', path)
559 def __call__(self, environ, start_response):
560 """ Handle a WSGI request
562 return self.dispatch(environ, start_response)
564 def dispatch(self, environ, start_response):
566 Performs the actual WSGI dispatching for the application, may be
567 wrapped during the initialization of the object.
569 Call the object directly.
571 request = werkzeug.wrappers.Request(environ)
572 request.parameter_storage_class = werkzeug.datastructures.ImmutableDict
575 handler = self.find_handler(*(request.path.split('/')[1:]))
578 response = werkzeug.exceptions.NotFound()
580 sid = request.cookies.get('sid')
582 sid = request.args.get('sid')
584 session_gc(self.session_store)
586 with session_context(request, self.session_store, self.session_lock, sid) as session:
587 result = handler(request)
589 if isinstance(result, basestring):
590 headers=[('Content-Type', 'text/html; charset=utf-8'), ('Content-Length', len(result))]
591 response = werkzeug.wrappers.Response(result, headers=headers)
595 if hasattr(response, 'set_cookie'):
596 response.set_cookie('sid', session.sid)
598 return response(environ, start_response)
600 def load_addons(self):
601 """ Load all addons from addons patch containg static files and
602 controllers and configure them. """
604 for addons_path in openerp.modules.module.ad_paths:
605 for module in sorted(os.listdir(str(addons_path))):
606 if module not in addons_module:
607 manifest_path = os.path.join(addons_path, module, '__openerp__.py')
608 path_static = os.path.join(addons_path, module, 'static')
609 if os.path.isfile(manifest_path) and os.path.isdir(path_static):
610 manifest = ast.literal_eval(open(manifest_path).read())
611 manifest['addons_path'] = addons_path
612 _logger.debug("Loading %s", module)
613 if 'openerp.addons' in sys.modules:
614 m = __import__('openerp.addons.' + module)
616 m = __import__(module)
617 addons_module[module] = m
618 addons_manifest[module] = manifest
619 self.statics['/%s/static' % module] = path_static
621 for k, v in controllers_class_path.items():
622 if k not in controllers_object_path and hasattr(v[1], '_cp_path'):
624 controllers_object[v[0]] = o
625 controllers_object_path[k] = o
626 if hasattr(o, '_cp_path'):
627 controllers_path[o._cp_path] = o
629 app = werkzeug.wsgi.SharedDataMiddleware(self.dispatch, self.statics)
630 self.dispatch = DisableCacheMiddleware(app)
632 def find_handler(self, *l):
634 Tries to discover the controller handling the request for the path
635 specified by the provided parameters
637 :param l: path sections to a controller or controller method
638 :returns: a callable matching the path sections, or ``None``
639 :rtype: ``Controller | None``
642 ps = '/' + '/'.join(filter(None, l))
643 method_name = 'index'
645 c = controllers_path.get(ps)
647 method = getattr(c, method_name, None)
649 exposed = getattr(method, 'exposed', False)
650 method = c.get_wrapped_method(method_name)
651 if exposed == 'json':
652 _logger.debug("Dispatch json to %s %s %s", ps, c, method_name)
654 _req = JsonRequest(_request)
655 with set_request(_req):
656 return request.dispatch(method)
658 elif exposed == 'http':
659 _logger.debug("Dispatch http to %s %s %s", ps, c, method_name)
661 _req = HttpRequest(_request)
662 with set_request(_req):
663 return request.dispatch(method)
665 if method_name != "index":
666 method_name = "index"
668 ps, _slash, method_name = ps.rpartition('/')
669 if not ps and method_name:
674 openerp.wsgi.register_wsgi_handler(Root())