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
37 _logger = logging.getLogger(__name__)
39 #----------------------------------------------------------
41 #----------------------------------------------------------
42 class WebRequest(object):
43 """ Parent class for all OpenERP Web request types, mostly deals with
44 initialization and setup of the request object (the dispatching itself has
45 to be handled by the subclasses)
47 :param request: a wrapped werkzeug Request object
48 :type request: :class:`werkzeug.wrappers.BaseRequest`
50 .. attribute:: httprequest
52 the original :class:`werkzeug.wrappers.Request` object provided to the
55 .. attribute:: httpsession
57 a :class:`~collections.Mapping` holding the HTTP session data for the
62 :class:`~collections.Mapping` of request parameters, not generally
63 useful as they're provided directly to the handler method as keyword
66 .. attribute:: session_id
68 opaque identifier for the :class:`session.OpenERPSession` instance of
71 .. attribute:: session
73 :class:`~session.OpenERPSession` instance for the current request
75 .. attribute:: context
77 :class:`~collections.Mapping` of context values for the current request
81 ``bool``, indicates whether the debug mode is active on the client
83 def __init__(self, request):
84 self.httprequest = request
85 self.httpresponse = None
86 self.httpsession = request.session
88 def init(self, params):
89 self.params = dict(params)
90 # OpenERP session setup
91 self.session_id = self.params.pop("session_id", None) or uuid.uuid4().hex
92 self.session = self.httpsession.get(self.session_id)
94 self.session = session.OpenERPSession()
95 self.httpsession[self.session_id] = self.session
97 # set db/uid trackers - they're cleaned up at the WSGI
98 # dispatching phase in openerp.service.wsgi_server.application
100 threading.current_thread().dbname = self.session._db
101 if self.session._uid:
102 threading.current_thread().uid = self.session._uid
104 self.context = self.params.pop('context', {})
105 self.debug = self.params.pop('debug', False) is not False
106 # Determine self.lang
107 lang = self.params.get('lang', None)
109 lang = self.context.get('lang')
111 lang = self.httprequest.cookies.get('lang')
113 lang = self.httprequest.accept_languages.best
116 # tranform 2 letters lang like 'en' into 5 letters like 'en_US'
117 lang = babel.core.LOCALE_ALIASES.get(lang, lang)
118 # we use _ as seprator where RFC2616 uses '-'
119 self.lang = lang.replace('-', '_')
121 def reject_nonliteral(dct):
124 "Non literal contexts can not be sent to the server anymore (%r)" % (dct,))
127 class JsonRequest(WebRequest):
128 """ JSON-RPC2 over HTTP.
132 --> {"jsonrpc": "2.0",
134 "params": {"session_id": "SID",
139 <-- {"jsonrpc": "2.0",
140 "result": { "res1": "val1" },
143 Request producing a error::
145 --> {"jsonrpc": "2.0",
147 "params": {"session_id": "SID",
152 <-- {"jsonrpc": "2.0",
154 "message": "End user error message.",
155 "data": {"code": "codestring",
156 "debug": "traceback" } },
160 def dispatch(self, method):
161 """ Calls the method asked for by the JSON-RPC2 or JSONP request
163 :param method: the method which received the request
165 :returns: an utf8 encoded JSON-RPC2 or JSONP reply
167 args = self.httprequest.args
168 jsonp = args.get('jsonp')
171 request_id = args.get('id')
173 if jsonp and self.httprequest.method == 'POST':
174 # jsonp 2 steps step1 POST: save call
176 self.session.jsonp_requests[request_id] = self.httprequest.form['r']
177 headers=[('Content-Type', 'text/plain; charset=utf-8')]
178 r = werkzeug.wrappers.Response(request_id, headers=headers)
180 elif jsonp and args.get('r'):
182 request = args.get('r')
183 elif jsonp and request_id:
184 # jsonp 2 steps step2 GET: run and return result
186 request = self.session.jsonp_requests.pop(request_id, "")
189 requestf = self.httprequest.stream
191 response = {"jsonrpc": "2.0" }
194 # Read POST content or POST Form Data named "request"
196 self.jsonrequest = simplejson.load(requestf, object_hook=reject_nonliteral)
198 self.jsonrequest = simplejson.loads(request, object_hook=reject_nonliteral)
199 self.init(self.jsonrequest.get("params", {}))
200 if _logger.isEnabledFor(logging.DEBUG):
201 _logger.debug("--> %s.%s\n%s", method.im_class.__name__, method.__name__, pprint.pformat(self.jsonrequest))
202 response['id'] = self.jsonrequest.get('id')
203 response["result"] = method(self, **self.params)
204 except session.AuthenticationError, e:
205 se = serialize_exception(e)
208 'message': "OpenERP Session Invalid",
212 se = serialize_exception(e)
215 'message': "OpenERP Server Error",
219 response["error"] = error
221 if _logger.isEnabledFor(logging.DEBUG):
222 _logger.debug("<--\n%s", pprint.pformat(response))
225 # If we use jsonp, that's mean we are called from another host
226 # Some browser (IE and Safari) do no allow third party cookies
227 # We need then to manage http sessions manually.
228 response['httpsessionid'] = self.httpsession.sid
229 mime = 'application/javascript'
230 body = "%s(%s);" % (jsonp, simplejson.dumps(response),)
232 mime = 'application/json'
233 body = simplejson.dumps(response)
235 r = werkzeug.wrappers.Response(body, headers=[('Content-Type', mime), ('Content-Length', len(body))])
238 def serialize_exception(e):
240 "name": type(e).__module__ + "." + type(e).__name__ if type(e).__module__ else type(e).__name__,
241 "debug": traceback.format_exc(),
242 "message": u"%s" % e,
243 "arguments": to_jsonable(e.args),
245 if isinstance(e, openerp.osv.osv.except_osv):
246 tmp["exception_type"] = "except_osv"
247 elif isinstance(e, openerp.exceptions.Warning):
248 tmp["exception_type"] = "warning"
249 elif isinstance(e, openerp.exceptions.AccessError):
250 tmp["exception_type"] = "access_error"
251 elif isinstance(e, openerp.exceptions.AccessDenied):
252 tmp["exception_type"] = "access_denied"
256 if isinstance(o, str) or isinstance(o,unicode) or isinstance(o, int) or isinstance(o, long) \
257 or isinstance(o, bool) or o is None or isinstance(o, float):
259 if isinstance(o, list) or isinstance(o, tuple):
260 return [to_jsonable(x) for x in o]
261 if isinstance(o, dict):
263 for k, v in o.items():
264 tmp[u"%s" % k] = to_jsonable(v)
269 """ Decorator marking the decorated method as being a handler for a
270 JSON-RPC request (the exact request path is specified via the
271 ``$(Controller._cp_path)/$methodname`` combination.
273 If the method is called, it will be provided with a :class:`JsonRequest`
274 instance and all ``params`` sent during the JSON-RPC request, apart from
275 the ``session_id``, ``context`` and ``debug`` keys (which are stripped out
281 class HttpRequest(WebRequest):
282 """ Regular GET/POST request
284 def dispatch(self, method):
285 params = dict(self.httprequest.args)
286 params.update(self.httprequest.form)
287 params.update(self.httprequest.files)
290 for key, value in self.httprequest.args.iteritems():
291 if isinstance(value, basestring) and len(value) < 1024:
294 akw[key] = type(value)
295 _logger.debug("%s --> %s.%s %r", self.httprequest.method, method.im_class.__name__, method.__name__, akw)
297 r = method(self, **self.params)
299 _logger.exception("An exception occured during an http request")
300 se = serialize_exception(e)
303 'message': "OpenERP Server Error",
306 r = werkzeug.exceptions.InternalServerError(cgi.escape(simplejson.dumps(error)))
308 if isinstance(r, (werkzeug.wrappers.BaseResponse, werkzeug.exceptions.HTTPException)):
309 _logger.debug('<-- %s', r)
311 _logger.debug("<-- size: %s", len(r))
314 def make_response(self, data, headers=None, cookies=None):
315 """ Helper for non-HTML responses, or HTML responses with custom
316 response headers or cookies.
318 While handlers can just return the HTML markup of a page they want to
319 send as a string if non-HTML data is returned they need to create a
320 complete response object, or the returned data will not be correctly
321 interpreted by the clients.
323 :param basestring data: response body
324 :param headers: HTTP headers to set on the response
325 :type headers: ``[(name, value)]``
326 :param collections.Mapping cookies: cookies to set on the client
328 response = werkzeug.wrappers.Response(data, headers=headers)
330 for k, v in cookies.iteritems():
331 response.set_cookie(k, v)
334 def not_found(self, description=None):
335 """ Helper for 404 response, return its result from the method
337 return werkzeug.exceptions.NotFound(description)
340 """ Decorator marking the decorated method as being a handler for a
341 normal HTTP request (the exact request path is specified via the
342 ``$(Controller._cp_path)/$methodname`` combination.
344 If the method is called, it will be provided with a :class:`HttpRequest`
345 instance and all ``params`` sent during the request (``GET`` and ``POST``
346 merged in the same dictionary), apart from the ``session_id``, ``context``
347 and ``debug`` keys (which are stripped out beforehand)
352 #----------------------------------------------------------
353 # Local storage of requests
354 #----------------------------------------------------------
355 _thlocal = threading.local()
357 class RequestProxy(object):
358 def __getattr__(self, name):
359 return getattr(_thlocal.stack[-1], name)
360 def __setattr__(self, name, val):
361 return setattr(_thlocal.stack[-1], name, val)
362 def __delattr__(self, name):
363 return delattr(_thlocal.stack[-1], name)
365 def set_request(cls, request):
368 if getattr(_thlocal, "stack", None) is None:
370 _thlocal.stack.append(request)
371 def __exit__(self, *args):
375 request = RequestProxy()
377 #----------------------------------------------------------
378 # Controller registration with a metaclass
379 #----------------------------------------------------------
382 controllers_class = []
383 controllers_class_path = {}
384 controllers_object = {}
385 controllers_object_path = {}
386 controllers_path = {}
388 class ControllerType(type):
389 def __init__(cls, name, bases, attrs):
390 super(ControllerType, cls).__init__(name, bases, attrs)
391 name_class = ("%s.%s" % (cls.__module__, cls.__name__), cls)
392 controllers_class.append(name_class)
393 path = attrs.get('_cp_path')
394 if path not in controllers_class_path:
395 controllers_class_path[path] = name_class
397 class Controller(object):
398 __metaclass__ = ControllerType
400 def __new__(cls, *args, **kwargs):
401 subclasses = [c for c in cls.__subclasses__() if c._cp_path == cls._cp_path]
403 name = "%s (extended by %s)" % (cls.__name__, ', '.join(sub.__name__ for sub in subclasses))
404 cls = type(name, tuple(reversed(subclasses)), {})
406 return object.__new__(cls)
408 #----------------------------------------------------------
409 # Session context manager
410 #----------------------------------------------------------
411 @contextlib.contextmanager
412 def session_context(request, session_store, session_lock, sid):
415 request.session = session_store.get(sid)
417 request.session = session_store.new()
419 yield request.session
421 # Remove all OpenERPSession instances with no uid, they're generated
422 # either by login process or by HTTP requests without an OpenERP
423 # session id, and are generally noise
424 removed_sessions = set()
425 for key, value in request.session.items():
426 if not isinstance(value, session.OpenERPSession):
428 if getattr(value, '_suicide', False) or (
430 and not value.jsonp_requests
431 # FIXME do not use a fixed value
432 and value._creation_time + (60*5) < time.time()):
433 _logger.debug('remove session %s', key)
434 removed_sessions.add(key)
435 del request.session[key]
439 # Re-load sessions from storage and merge non-literal
440 # contexts and domains (they're indexed by hash of the
441 # content so conflicts should auto-resolve), otherwise if
442 # two requests alter those concurrently the last to finish
443 # will overwrite the previous one, leading to loss of data
444 # (a non-literal is lost even though it was sent to the
445 # client and client errors)
447 # note that domains_store and contexts_store are append-only (we
448 # only ever add items to them), so we can just update one with the
449 # other to get the right result, if we want to merge the
450 # ``context`` dict we'll need something smarter
451 in_store = session_store.get(sid)
452 for k, v in request.session.iteritems():
453 stored = in_store.get(k)
454 if stored and isinstance(v, session.OpenERPSession):
455 if hasattr(v, 'contexts_store'):
457 if hasattr(v, 'domains_store'):
459 if not hasattr(v, 'jsonp_requests'):
460 v.jsonp_requests = {}
461 v.jsonp_requests.update(getattr(
462 stored, 'jsonp_requests', {}))
465 for k, v in in_store.iteritems():
466 if k not in request.session and k not in removed_sessions:
467 request.session[k] = v
469 session_store.save(request.session)
471 def session_gc(session_store):
472 if random.random() < 0.001:
473 # we keep session one week
474 last_week = time.time() - 60*60*24*7
475 for fname in os.listdir(session_store.path):
476 path = os.path.join(session_store.path, fname)
478 if os.path.getmtime(path) < last_week:
483 #----------------------------------------------------------
485 #----------------------------------------------------------
486 # Add potentially missing (older ubuntu) font mime types
487 mimetypes.add_type('application/font-woff', '.woff')
488 mimetypes.add_type('application/vnd.ms-fontobject', '.eot')
489 mimetypes.add_type('application/x-font-ttf', '.ttf')
491 class DisableCacheMiddleware(object):
492 def __init__(self, app):
494 def __call__(self, environ, start_response):
495 def start_wrapped(status, headers):
496 referer = environ.get('HTTP_REFERER', '')
497 parsed = urlparse.urlparse(referer)
498 debug = parsed.query.count('debug') >= 1
501 unwanted_keys = ['Last-Modified']
503 new_headers = [('Cache-Control', 'no-cache')]
504 unwanted_keys += ['Expires', 'Etag', 'Cache-Control']
507 if k not in unwanted_keys:
508 new_headers.append((k, v))
510 start_response(status, new_headers)
511 return self.app(environ, start_wrapped)
515 username = getpass.getuser()
518 path = os.path.join(tempfile.gettempdir(), "oe-sessions-" + username)
521 except OSError as exc:
522 if exc.errno == errno.EEXIST:
523 # directory exists: ensure it has the correct permissions
524 # this will fail if the directory is not owned by the current user
531 """Root WSGI application for the OpenERP Web Client.
539 # Setup http sessions
540 path = session_path()
541 self.session_store = werkzeug.contrib.sessions.FilesystemSessionStore(path)
542 self.session_lock = threading.Lock()
543 _logger.debug('HTTP sessions stored in: %s', path)
545 def __call__(self, environ, start_response):
546 """ Handle a WSGI request
548 return self.dispatch(environ, start_response)
550 def dispatch(self, environ, start_response):
552 Performs the actual WSGI dispatching for the application, may be
553 wrapped during the initialization of the object.
555 Call the object directly.
557 request = werkzeug.wrappers.Request(environ)
558 request.parameter_storage_class = werkzeug.datastructures.ImmutableDict
561 handler = self.find_handler(*(request.path.split('/')[1:]))
564 response = werkzeug.exceptions.NotFound()
566 sid = request.cookies.get('sid')
568 sid = request.args.get('sid')
570 session_gc(self.session_store)
572 with session_context(request, self.session_store, self.session_lock, sid) as session:
573 result = handler(request)
575 if isinstance(result, basestring):
576 headers=[('Content-Type', 'text/html; charset=utf-8'), ('Content-Length', len(result))]
577 response = werkzeug.wrappers.Response(result, headers=headers)
581 if hasattr(response, 'set_cookie'):
582 response.set_cookie('sid', session.sid)
584 return response(environ, start_response)
586 def load_addons(self):
587 """ Load all addons from addons patch containg static files and
588 controllers and configure them. """
590 for addons_path in openerp.modules.module.ad_paths:
591 for module in sorted(os.listdir(str(addons_path))):
592 if module not in addons_module:
593 manifest_path = os.path.join(addons_path, module, '__openerp__.py')
594 path_static = os.path.join(addons_path, module, 'static')
595 if os.path.isfile(manifest_path) and os.path.isdir(path_static):
596 manifest = ast.literal_eval(open(manifest_path).read())
597 manifest['addons_path'] = addons_path
598 _logger.debug("Loading %s", module)
599 if 'openerp.addons' in sys.modules:
600 m = __import__('openerp.addons.' + module)
602 m = __import__(module)
603 addons_module[module] = m
604 addons_manifest[module] = manifest
605 self.statics['/%s/static' % module] = path_static
607 for k, v in controllers_class_path.items():
608 if k not in controllers_object_path and hasattr(v[1], '_cp_path'):
610 controllers_object[v[0]] = o
611 controllers_object_path[k] = o
612 if hasattr(o, '_cp_path'):
613 controllers_path[o._cp_path] = o
615 app = werkzeug.wsgi.SharedDataMiddleware(self.dispatch, self.statics)
616 self.dispatch = DisableCacheMiddleware(app)
618 def find_handler(self, *l):
620 Tries to discover the controller handling the request for the path
621 specified by the provided parameters
623 :param l: path sections to a controller or controller method
624 :returns: a callable matching the path sections, or ``None``
625 :rtype: ``Controller | None``
628 ps = '/' + '/'.join(filter(None, l))
629 method_name = 'index'
631 c = controllers_path.get(ps)
633 method = getattr(c, method_name, None)
635 exposed = getattr(method, 'exposed', False)
636 if exposed == 'json':
637 _logger.debug("Dispatch json to %s %s %s", ps, c, method_name)
639 req = JsonRequest(request)
640 with RequestProxy.set_request(req):
641 return req.dispatch(method)
643 elif exposed == 'http':
644 _logger.debug("Dispatch http to %s %s %s", ps, c, method_name)
646 req = HttpRequest(request)
647 with RequestProxy.set_request(req):
648 return req.dispatch(method)
650 if method_name != "index":
651 method_name = "index"
653 ps, _slash, method_name = ps.rpartition('/')
654 if not ps and method_name:
659 openerp.wsgi.register_wsgi_handler(Root())