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 # Controller registration with a metaclass
354 #----------------------------------------------------------
357 controllers_class = []
358 controllers_class_path = {}
359 controllers_object = {}
360 controllers_object_path = {}
361 controllers_path = {}
363 class ControllerType(type):
364 def __init__(cls, name, bases, attrs):
365 super(ControllerType, cls).__init__(name, bases, attrs)
366 name_class = ("%s.%s" % (cls.__module__, cls.__name__), cls)
367 controllers_class.append(name_class)
368 path = attrs.get('_cp_path')
369 if path not in controllers_class_path:
370 controllers_class_path[path] = name_class
372 class Controller(object):
373 __metaclass__ = ControllerType
375 def __new__(cls, *args, **kwargs):
376 subclasses = [c for c in cls.__subclasses__() if c._cp_path == cls._cp_path]
378 name = "%s (extended by %s)" % (cls.__name__, ', '.join(sub.__name__ for sub in subclasses))
379 cls = type(name, tuple(reversed(subclasses)), {})
381 return object.__new__(cls)
383 #----------------------------------------------------------
384 # Session context manager
385 #----------------------------------------------------------
386 @contextlib.contextmanager
387 def session_context(request, session_store, session_lock, sid):
390 request.session = session_store.get(sid)
392 request.session = session_store.new()
394 yield request.session
396 # Remove all OpenERPSession instances with no uid, they're generated
397 # either by login process or by HTTP requests without an OpenERP
398 # session id, and are generally noise
399 removed_sessions = set()
400 for key, value in request.session.items():
401 if not isinstance(value, session.OpenERPSession):
403 if getattr(value, '_suicide', False) or (
405 and not value.jsonp_requests
406 # FIXME do not use a fixed value
407 and value._creation_time + (60*5) < time.time()):
408 _logger.debug('remove session %s', key)
409 removed_sessions.add(key)
410 del request.session[key]
414 # Re-load sessions from storage and merge non-literal
415 # contexts and domains (they're indexed by hash of the
416 # content so conflicts should auto-resolve), otherwise if
417 # two requests alter those concurrently the last to finish
418 # will overwrite the previous one, leading to loss of data
419 # (a non-literal is lost even though it was sent to the
420 # client and client errors)
422 # note that domains_store and contexts_store are append-only (we
423 # only ever add items to them), so we can just update one with the
424 # other to get the right result, if we want to merge the
425 # ``context`` dict we'll need something smarter
426 in_store = session_store.get(sid)
427 for k, v in request.session.iteritems():
428 stored = in_store.get(k)
429 if stored and isinstance(v, session.OpenERPSession):
430 if hasattr(v, 'contexts_store'):
432 if hasattr(v, 'domains_store'):
434 if not hasattr(v, 'jsonp_requests'):
435 v.jsonp_requests = {}
436 v.jsonp_requests.update(getattr(
437 stored, 'jsonp_requests', {}))
440 for k, v in in_store.iteritems():
441 if k not in request.session and k not in removed_sessions:
442 request.session[k] = v
444 session_store.save(request.session)
446 def session_gc(session_store):
447 if random.random() < 0.001:
448 # we keep session one week
449 last_week = time.time() - 60*60*24*7
450 for fname in os.listdir(session_store.path):
451 path = os.path.join(session_store.path, fname)
453 if os.path.getmtime(path) < last_week:
458 #----------------------------------------------------------
460 #----------------------------------------------------------
461 # Add potentially missing (older ubuntu) font mime types
462 mimetypes.add_type('application/font-woff', '.woff')
463 mimetypes.add_type('application/vnd.ms-fontobject', '.eot')
464 mimetypes.add_type('application/x-font-ttf', '.ttf')
466 class DisableCacheMiddleware(object):
467 def __init__(self, app):
469 def __call__(self, environ, start_response):
470 def start_wrapped(status, headers):
471 referer = environ.get('HTTP_REFERER', '')
472 parsed = urlparse.urlparse(referer)
473 debug = parsed.query.count('debug') >= 1
476 unwanted_keys = ['Last-Modified']
478 new_headers = [('Cache-Control', 'no-cache')]
479 unwanted_keys += ['Expires', 'Etag', 'Cache-Control']
482 if k not in unwanted_keys:
483 new_headers.append((k, v))
485 start_response(status, new_headers)
486 return self.app(environ, start_wrapped)
490 username = getpass.getuser()
493 path = os.path.join(tempfile.gettempdir(), "oe-sessions-" + username)
496 except OSError as exc:
497 if exc.errno == errno.EEXIST:
498 # directory exists: ensure it has the correct permissions
499 # this will fail if the directory is not owned by the current user
506 """Root WSGI application for the OpenERP Web Client.
514 # Setup http sessions
515 path = session_path()
516 self.session_store = werkzeug.contrib.sessions.FilesystemSessionStore(path)
517 self.session_lock = threading.Lock()
518 _logger.debug('HTTP sessions stored in: %s', path)
520 def __call__(self, environ, start_response):
521 """ Handle a WSGI request
523 return self.dispatch(environ, start_response)
525 def dispatch(self, environ, start_response):
527 Performs the actual WSGI dispatching for the application, may be
528 wrapped during the initialization of the object.
530 Call the object directly.
532 request = werkzeug.wrappers.Request(environ)
533 request.parameter_storage_class = werkzeug.datastructures.ImmutableDict
536 handler = self.find_handler(*(request.path.split('/')[1:]))
539 response = werkzeug.exceptions.NotFound()
541 sid = request.cookies.get('sid')
543 sid = request.args.get('sid')
545 session_gc(self.session_store)
547 with session_context(request, self.session_store, self.session_lock, sid) as session:
548 result = handler(request)
550 if isinstance(result, basestring):
551 headers=[('Content-Type', 'text/html; charset=utf-8'), ('Content-Length', len(result))]
552 response = werkzeug.wrappers.Response(result, headers=headers)
556 if hasattr(response, 'set_cookie'):
557 response.set_cookie('sid', session.sid)
559 return response(environ, start_response)
561 def load_addons(self):
562 """ Load all addons from addons patch containg static files and
563 controllers and configure them. """
565 for addons_path in openerp.modules.module.ad_paths:
566 for module in sorted(os.listdir(addons_path)):
567 if module not in addons_module:
568 manifest_path = os.path.join(addons_path, module, '__openerp__.py')
569 path_static = os.path.join(addons_path, module, 'static')
570 if os.path.isfile(manifest_path) and os.path.isdir(path_static):
571 manifest = ast.literal_eval(open(manifest_path).read())
572 manifest['addons_path'] = addons_path
573 _logger.debug("Loading %s", module)
574 if 'openerp.addons' in sys.modules:
575 m = __import__('openerp.addons.' + module)
577 m = __import__(module)
578 addons_module[module] = m
579 addons_manifest[module] = manifest
580 self.statics['/%s/static' % module] = path_static
582 for k, v in controllers_class_path.items():
583 if k not in controllers_object_path and hasattr(v[1], '_cp_path'):
585 controllers_object[v[0]] = o
586 controllers_object_path[k] = o
587 if hasattr(o, '_cp_path'):
588 controllers_path[o._cp_path] = o
590 app = werkzeug.wsgi.SharedDataMiddleware(self.dispatch, self.statics)
591 self.dispatch = DisableCacheMiddleware(app)
593 def find_handler(self, *l):
595 Tries to discover the controller handling the request for the path
596 specified by the provided parameters
598 :param l: path sections to a controller or controller method
599 :returns: a callable matching the path sections, or ``None``
600 :rtype: ``Controller | None``
603 ps = '/' + '/'.join(filter(None, l))
604 method_name = 'index'
606 c = controllers_path.get(ps)
608 method = getattr(c, method_name, None)
610 exposed = getattr(method, 'exposed', False)
611 if exposed == 'json':
612 _logger.debug("Dispatch json to %s %s %s", ps, c, method_name)
613 return lambda request: JsonRequest(request).dispatch(method)
614 elif exposed == 'http':
615 _logger.debug("Dispatch http to %s %s %s", ps, c, method_name)
616 return lambda request: HttpRequest(request).dispatch(method)
617 elif method_name != "index":
618 method_name = "index"
620 ps, _slash, method_name = ps.rpartition('/')
621 if not ps and method_name:
626 openerp.wsgi.register_wsgi_handler(Root())