1 # -*- coding: utf-8 -*-
2 #----------------------------------------------------------
3 # OpenERP Web HTTP layer
4 #----------------------------------------------------------
27 import werkzeug.contrib.sessions
28 import werkzeug.datastructures
29 import werkzeug.exceptions
31 import werkzeug.wrappers
39 _logger = logging.getLogger(__name__)
41 #----------------------------------------------------------
43 #----------------------------------------------------------
44 class WebRequest(object):
45 """ Parent class for all OpenERP Web request types, mostly deals with
46 initialization and setup of the request object (the dispatching itself has
47 to be handled by the subclasses)
49 :param request: a wrapped werkzeug Request object
50 :type request: :class:`werkzeug.wrappers.BaseRequest`
52 .. attribute:: httprequest
54 the original :class:`werkzeug.wrappers.Request` object provided to the
57 .. attribute:: httpsession
59 a :class:`~collections.Mapping` holding the HTTP session data for the
64 :class:`~collections.Mapping` of request parameters, not generally
65 useful as they're provided directly to the handler method as keyword
68 .. attribute:: session_id
70 opaque identifier for the :class:`session.OpenERPSession` instance of
73 .. attribute:: session
75 :class:`~session.OpenERPSession` instance for the current request
77 .. attribute:: context
79 :class:`~collections.Mapping` of context values for the current request
83 ``bool``, indicates whether the debug mode is active on the client
85 def __init__(self, request):
86 self.httprequest = request
87 self.httpresponse = None
88 self.httpsession = request.session
90 def init(self, params):
91 self.params = dict(params)
92 # OpenERP session setup
93 self.session_id = self.params.pop("session_id", None) or uuid.uuid4().hex
94 self.session = self.httpsession.get(self.session_id)
96 self.session = session.OpenERPSession()
97 self.httpsession[self.session_id] = self.session
98 self.context = self.params.pop('context', None)
99 self.debug = self.params.pop('debug', False) is not False
100 # Determine self.lang
101 lang = self.params.get('lang', None)
103 lang = self.session.eval_context(self.context).get('lang')
105 lang = self.httprequest.cookies.get('lang')
107 lang = self.httprequest.accept_languages.best
110 # tranform 2 letters lang like 'en' into 5 letters like 'en_US'
111 lang = babel.core.LOCALE_ALIASES.get(lang, lang)
112 # we use _ as seprator where RFC2616 uses '-'
113 self.lang = lang.replace('-', '_')
116 class JsonRequest(WebRequest):
117 """ JSON-RPC2 over HTTP.
121 --> {"jsonrpc": "2.0",
123 "params": {"session_id": "SID",
128 <-- {"jsonrpc": "2.0",
129 "result": { "res1": "val1" },
132 Request producing a error::
134 --> {"jsonrpc": "2.0",
136 "params": {"session_id": "SID",
141 <-- {"jsonrpc": "2.0",
143 "message": "End user error message.",
144 "data": {"code": "codestring",
145 "debug": "traceback" } },
149 def dispatch(self, method):
150 """ Calls the method asked for by the JSON-RPC2 or JSONP request
152 :param method: the method which received the request
154 :returns: an utf8 encoded JSON-RPC2 or JSONP reply
156 args = self.httprequest.args
157 jsonp = args.get('jsonp')
160 request_id = args.get('id')
162 if jsonp and self.httprequest.method == 'POST':
163 # jsonp 2 steps step1 POST: save call
165 self.session.jsonp_requests[request_id] = self.httprequest.form['r']
166 headers=[('Content-Type', 'text/plain; charset=utf-8')]
167 r = werkzeug.wrappers.Response(request_id, headers=headers)
169 elif jsonp and args.get('r'):
171 request = args.get('r')
172 elif jsonp and request_id:
173 # jsonp 2 steps step2 GET: run and return result
175 request = self.session.jsonp_requests.pop(request_id, "")
178 requestf = self.httprequest.stream
180 response = {"jsonrpc": "2.0" }
183 # Read POST content or POST Form Data named "request"
185 self.jsonrequest = simplejson.load(requestf, object_hook=nonliterals.non_literal_decoder)
187 self.jsonrequest = simplejson.loads(request, object_hook=nonliterals.non_literal_decoder)
188 self.init(self.jsonrequest.get("params", {}))
189 if _logger.isEnabledFor(logging.DEBUG):
190 _logger.debug("--> %s.%s\n%s", method.im_class.__name__, method.__name__, pprint.pformat(self.jsonrequest))
191 response['id'] = self.jsonrequest.get('id')
192 response["result"] = method(self, **self.params)
193 except session.AuthenticationError:
196 'message': "OpenERP Session Invalid",
198 'type': 'session_invalid',
199 'debug': traceback.format_exc()
202 except xmlrpclib.Fault, e:
205 'message': "OpenERP Server Error",
207 'type': 'server_exception',
208 'fault_code': e.faultCode,
209 'debug': "Client %s\nServer %s" % (
210 "".join(traceback.format_exception("", None, sys.exc_traceback)), e.faultString)
214 logging.getLogger(__name__ + '.JSONRequest.dispatch').exception\
215 ("An error occured while handling a json request")
218 'message': "OpenERP WebClient Error",
220 'type': 'client_exception',
221 'debug': "Client %s" % traceback.format_exc()
225 response["error"] = error
227 if _logger.isEnabledFor(logging.DEBUG):
228 _logger.debug("<--\n%s", pprint.pformat(response))
231 # If we use jsonp, that's mean we are called from another host
232 # Some browser (IE and Safari) do no allow third party cookies
233 # We need then to manage http sessions manually.
234 response['httpsessionid'] = self.httpsession.sid
235 mime = 'application/javascript'
236 body = "%s(%s);" % (jsonp, simplejson.dumps(response, cls=nonliterals.NonLiteralEncoder),)
238 mime = 'application/json'
239 body = simplejson.dumps(response, cls=nonliterals.NonLiteralEncoder)
241 r = werkzeug.wrappers.Response(body, headers=[('Content-Type', mime), ('Content-Length', len(body))])
245 """ Decorator marking the decorated method as being a handler for a
246 JSON-RPC request (the exact request path is specified via the
247 ``$(Controller._cp_path)/$methodname`` combination.
249 If the method is called, it will be provided with a :class:`JsonRequest`
250 instance and all ``params`` sent during the JSON-RPC request, apart from
251 the ``session_id``, ``context`` and ``debug`` keys (which are stripped out
257 class HttpRequest(WebRequest):
258 """ Regular GET/POST request
260 def dispatch(self, method):
261 params = dict(self.httprequest.args)
262 params.update(self.httprequest.form)
263 params.update(self.httprequest.files)
266 for key, value in self.httprequest.args.iteritems():
267 if isinstance(value, basestring) and len(value) < 1024:
270 akw[key] = type(value)
271 _logger.debug("%s --> %s.%s %r", self.httprequest.method, method.im_class.__name__, method.__name__, akw)
273 r = method(self, **self.params)
274 except xmlrpclib.Fault, e:
275 r = werkzeug.exceptions.InternalServerError(cgi.escape(simplejson.dumps({
277 'message': "OpenERP Server Error",
279 'type': 'server_exception',
280 'fault_code': e.faultCode,
281 'debug': "Server %s\nClient %s" % (
282 e.faultString, traceback.format_exc())
286 logging.getLogger(__name__ + '.HttpRequest.dispatch').exception(
287 "An error occurred while handling a json request")
288 r = werkzeug.exceptions.InternalServerError(cgi.escape(simplejson.dumps({
290 'message': "OpenERP WebClient Error",
292 'type': 'client_exception',
293 'debug': "Client %s" % traceback.format_exc()
297 if isinstance(r, (werkzeug.wrappers.BaseResponse, werkzeug.exceptions.HTTPException)):
298 _logger.debug('<-- %s', r)
300 _logger.debug("<-- size: %s", len(r))
303 def make_response(self, data, headers=None, cookies=None):
304 """ Helper for non-HTML responses, or HTML responses with custom
305 response headers or cookies.
307 While handlers can just return the HTML markup of a page they want to
308 send as a string if non-HTML data is returned they need to create a
309 complete response object, or the returned data will not be correctly
310 interpreted by the clients.
312 :param basestring data: response body
313 :param headers: HTTP headers to set on the response
314 :type headers: ``[(name, value)]``
315 :param collections.Mapping cookies: cookies to set on the client
317 response = werkzeug.wrappers.Response(data, headers=headers)
319 for k, v in cookies.iteritems():
320 response.set_cookie(k, v)
323 def not_found(self, description=None):
324 """ Helper for 404 response, return its result from the method
326 return werkzeug.exceptions.NotFound(description)
329 """ Decorator marking the decorated method as being a handler for a
330 normal HTTP request (the exact request path is specified via the
331 ``$(Controller._cp_path)/$methodname`` combination.
333 If the method is called, it will be provided with a :class:`HttpRequest`
334 instance and all ``params`` sent during the request (``GET`` and ``POST``
335 merged in the same dictionary), apart from the ``session_id``, ``context``
336 and ``debug`` keys (which are stripped out beforehand)
341 #----------------------------------------------------------
342 # Controller registration with a metaclass
343 #----------------------------------------------------------
346 controllers_class = []
347 controllers_object = {}
348 controllers_path = {}
350 class ControllerType(type):
351 def __init__(cls, name, bases, attrs):
352 super(ControllerType, cls).__init__(name, bases, attrs)
353 controllers_class.append(("%s.%s" % (cls.__module__, cls.__name__), cls))
355 class Controller(object):
356 __metaclass__ = ControllerType
358 #----------------------------------------------------------
359 # Session context manager
360 #----------------------------------------------------------
361 @contextlib.contextmanager
362 def session_context(request, session_store, session_lock, sid):
365 request.session = session_store.get(sid)
367 request.session = session_store.new()
369 yield request.session
371 # Remove all OpenERPSession instances with no uid, they're generated
372 # either by login process or by HTTP requests without an OpenERP
373 # session id, and are generally noise
374 removed_sessions = set()
375 for key, value in request.session.items():
376 if not isinstance(value, session.OpenERPSession):
378 if getattr(value, '_suicide', False) or (
380 and not value.jsonp_requests
381 # FIXME do not use a fixed value
382 and value._creation_time + (60*5) < time.time()):
383 _logger.debug('remove session %s', key)
384 removed_sessions.add(key)
385 del request.session[key]
389 # Re-load sessions from storage and merge non-literal
390 # contexts and domains (they're indexed by hash of the
391 # content so conflicts should auto-resolve), otherwise if
392 # two requests alter those concurrently the last to finish
393 # will overwrite the previous one, leading to loss of data
394 # (a non-literal is lost even though it was sent to the
395 # client and client errors)
397 # note that domains_store and contexts_store are append-only (we
398 # only ever add items to them), so we can just update one with the
399 # other to get the right result, if we want to merge the
400 # ``context`` dict we'll need something smarter
401 in_store = session_store.get(sid)
402 for k, v in request.session.iteritems():
403 stored = in_store.get(k)
404 if stored and isinstance(v, session.OpenERPSession):
405 v.contexts_store.update(stored.contexts_store)
406 v.domains_store.update(stored.domains_store)
407 if not hasattr(v, 'jsonp_requests'):
408 v.jsonp_requests = {}
409 v.jsonp_requests.update(getattr(
410 stored, 'jsonp_requests', {}))
413 for k, v in in_store.iteritems():
414 if k not in request.session and k not in removed_sessions:
415 request.session[k] = v
417 session_store.save(request.session)
419 def session_gc(session_store):
420 if random.random() < 0.001:
421 # we keep session one week
422 last_week = time.time() - 60*60*24*7
423 for fname in os.listdir(session_store.path):
424 path = os.path.join(session_store.path, fname)
426 if os.path.getmtime(path) < last_week:
431 #----------------------------------------------------------
433 #----------------------------------------------------------
434 # Add potentially missing (older ubuntu) font mime types
435 mimetypes.add_type('application/font-woff', '.woff')
436 mimetypes.add_type('application/vnd.ms-fontobject', '.eot')
437 mimetypes.add_type('application/x-font-ttf', '.ttf')
439 class DisableCacheMiddleware(object):
440 def __init__(self, app):
442 def __call__(self, environ, start_response):
443 def start_wrapped(status, headers):
444 referer = environ.get('HTTP_REFERER', '')
445 parsed = urlparse.urlparse(referer)
446 debug = parsed.query.count('debug') >= 1
449 unwanted_keys = ['Last-Modified']
451 new_headers = [('Cache-Control', 'no-cache')]
452 unwanted_keys += ['Expires', 'Etag', 'Cache-Control']
455 if k not in unwanted_keys:
456 new_headers.append((k, v))
458 start_response(status, new_headers)
459 return self.app(environ, start_wrapped)
463 username = getpass.getuser()
466 path = os.path.join(tempfile.gettempdir(), "oe-sessions-" + username)
467 if not os.path.exists(path):
472 """Root WSGI application for the OpenERP Web Client.
477 static_dirs = self._load_addons()
478 app = werkzeug.wsgi.SharedDataMiddleware( self.dispatch, static_dirs)
479 self.dispatch = DisableCacheMiddleware(app)
481 # Setup http sessions
482 path = session_path()
483 self.session_store = werkzeug.contrib.sessions.FilesystemSessionStore(path)
484 self.session_lock = threading.Lock()
485 _logger.debug('HTTP sessions stored in: %s', path)
487 def __call__(self, environ, start_response):
488 """ Handle a WSGI request
490 return self.dispatch(environ, start_response)
492 def dispatch(self, environ, start_response):
494 Performs the actual WSGI dispatching for the application, may be
495 wrapped during the initialization of the object.
497 Call the object directly.
499 request = werkzeug.wrappers.Request(environ)
500 request.parameter_storage_class = werkzeug.datastructures.ImmutableDict
503 handler = self.find_handler(*(request.path.split('/')[1:]))
506 response = werkzeug.exceptions.NotFound()
508 sid = request.cookies.get('sid')
510 sid = request.args.get('sid')
512 session_gc(self.session_store)
514 with session_context(request, self.session_store, self.session_lock, sid) as session:
515 result = handler(request)
517 if isinstance(result, basestring):
518 headers=[('Content-Type', 'text/html; charset=utf-8'), ('Content-Length', len(result))]
519 response = werkzeug.wrappers.Response(result, headers=headers)
523 if hasattr(response, 'set_cookie'):
524 response.set_cookie('sid', session.sid)
526 return response(environ, start_response)
528 def _load_addons(self):
530 Loads all addons at the specified addons path, returns a mapping of
531 static URLs to the corresponding directories
534 for addons_path in openerp.modules.module.ad_paths:
535 for module in os.listdir(addons_path):
536 if module not in addons_module:
537 manifest_path = os.path.join(addons_path, module, '__openerp__.py')
538 path_static = os.path.join(addons_path, module, 'static')
539 if os.path.isfile(manifest_path) and os.path.isdir(path_static):
540 manifest = ast.literal_eval(open(manifest_path).read())
541 manifest['addons_path'] = addons_path
542 _logger.debug("Loading %s", module)
543 if 'openerp.addons' in sys.modules:
544 m = __import__('openerp.addons.' + module)
546 m = __import__(module)
547 addons_module[module] = m
548 addons_manifest[module] = manifest
549 statics['/%s/static' % module] = path_static
550 for k, v in controllers_class:
551 if k not in controllers_object:
553 controllers_object[k] = o
554 if hasattr(o, '_cp_path'):
555 controllers_path[o._cp_path] = o
558 def find_handler(self, *l):
560 Tries to discover the controller handling the request for the path
561 specified by the provided parameters
563 :param l: path sections to a controller or controller method
564 :returns: a callable matching the path sections, or ``None``
565 :rtype: ``Controller | None``
568 ps = '/' + '/'.join(filter(None, l))
569 method_name = 'index'
571 c = controllers_path.get(ps)
573 method = getattr(c, method_name, None)
575 exposed = getattr(method, 'exposed', False)
576 if exposed == 'json':
577 _logger.debug("Dispatch json to %s %s %s", ps, c, method_name)
578 return lambda request: JsonRequest(request).dispatch(method)
579 elif exposed == 'http':
580 _logger.debug("Dispatch http to %s %s %s", ps, c, method_name)
581 return lambda request: HttpRequest(request).dispatch(method)
582 ps, _slash, method_name = ps.rpartition('/')
583 if not ps and method_name:
588 openerp.wsgi.register_wsgi_handler(Root())