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 @contextlib.contextmanager
125 def registry_cr(self):
126 dbname = self.session._db or openerp.addons.web.controllers.main.db_monodb(self)
127 registry = openerp.modules.registry.RegistryManager.get(dbname.lower())
128 with registry.cursor() as cr:
131 def reject_nonliteral(dct):
134 "Non literal contexts can not be sent to the server anymore (%r)" % (dct,))
137 class JsonRequest(WebRequest):
138 """ JSON-RPC2 over HTTP.
142 --> {"jsonrpc": "2.0",
144 "params": {"session_id": "SID",
149 <-- {"jsonrpc": "2.0",
150 "result": { "res1": "val1" },
153 Request producing a error::
155 --> {"jsonrpc": "2.0",
157 "params": {"session_id": "SID",
162 <-- {"jsonrpc": "2.0",
164 "message": "End user error message.",
165 "data": {"code": "codestring",
166 "debug": "traceback" } },
170 def dispatch(self, method):
171 """ Calls the method asked for by the JSON-RPC2 or JSONP request
173 :param method: the method which received the request
175 :returns: an utf8 encoded JSON-RPC2 or JSONP reply
177 args = self.httprequest.args
178 jsonp = args.get('jsonp')
181 request_id = args.get('id')
183 if jsonp and self.httprequest.method == 'POST':
184 # jsonp 2 steps step1 POST: save call
186 self.session.jsonp_requests[request_id] = self.httprequest.form['r']
187 headers=[('Content-Type', 'text/plain; charset=utf-8')]
188 r = werkzeug.wrappers.Response(request_id, headers=headers)
190 elif jsonp and args.get('r'):
192 request = args.get('r')
193 elif jsonp and request_id:
194 # jsonp 2 steps step2 GET: run and return result
196 request = self.session.jsonp_requests.pop(request_id, "")
199 requestf = self.httprequest.stream
201 response = {"jsonrpc": "2.0" }
204 # Read POST content or POST Form Data named "request"
206 self.jsonrequest = simplejson.load(requestf, object_hook=reject_nonliteral)
208 self.jsonrequest = simplejson.loads(request, object_hook=reject_nonliteral)
209 self.init(self.jsonrequest.get("params", {}))
210 #if _logger.isEnabledFor(logging.DEBUG):
211 # _logger.debug("--> %s.%s\n%s", method.im_class.__name__, method.__name__, pprint.pformat(self.jsonrequest))
212 response['id'] = self.jsonrequest.get('id')
213 response["result"] = method(**self.params)
214 except session.AuthenticationError, e:
215 se = serialize_exception(e)
218 'message': "OpenERP Session Invalid",
222 se = serialize_exception(e)
225 'message': "OpenERP Server Error",
229 response["error"] = error
231 if _logger.isEnabledFor(logging.DEBUG):
232 _logger.debug("<--\n%s", pprint.pformat(response))
235 # If we use jsonp, that's mean we are called from another host
236 # Some browser (IE and Safari) do no allow third party cookies
237 # We need then to manage http sessions manually.
238 response['httpsessionid'] = self.httpsession.sid
239 mime = 'application/javascript'
240 body = "%s(%s);" % (jsonp, simplejson.dumps(response),)
242 mime = 'application/json'
243 body = simplejson.dumps(response)
245 r = werkzeug.wrappers.Response(body, headers=[('Content-Type', mime), ('Content-Length', len(body))])
248 def serialize_exception(e):
250 "name": type(e).__module__ + "." + type(e).__name__ if type(e).__module__ else type(e).__name__,
251 "debug": traceback.format_exc(),
252 "message": u"%s" % e,
253 "arguments": to_jsonable(e.args),
255 if isinstance(e, openerp.osv.osv.except_osv):
256 tmp["exception_type"] = "except_osv"
257 elif isinstance(e, openerp.exceptions.Warning):
258 tmp["exception_type"] = "warning"
259 elif isinstance(e, openerp.exceptions.AccessError):
260 tmp["exception_type"] = "access_error"
261 elif isinstance(e, openerp.exceptions.AccessDenied):
262 tmp["exception_type"] = "access_denied"
266 if isinstance(o, str) or isinstance(o,unicode) or isinstance(o, int) or isinstance(o, long) \
267 or isinstance(o, bool) or o is None or isinstance(o, float):
269 if isinstance(o, list) or isinstance(o, tuple):
270 return [to_jsonable(x) for x in o]
271 if isinstance(o, dict):
273 for k, v in o.items():
274 tmp[u"%s" % k] = to_jsonable(v)
279 """ Decorator marking the decorated method as being a handler for a
280 JSON-RPC request (the exact request path is specified via the
281 ``$(Controller._cp_path)/$methodname`` combination.
283 If the method is called, it will be provided with a :class:`JsonRequest`
284 instance and all ``params`` sent during the JSON-RPC request, apart from
285 the ``session_id``, ``context`` and ``debug`` keys (which are stripped out
291 class HttpRequest(WebRequest):
292 """ Regular GET/POST request
294 def dispatch(self, method):
295 params = dict(self.httprequest.args)
296 params.update(self.httprequest.form)
297 params.update(self.httprequest.files)
300 for key, value in self.httprequest.args.iteritems():
301 if isinstance(value, basestring) and len(value) < 1024:
304 akw[key] = type(value)
305 #_logger.debug("%s --> %s.%s %r", self.httprequest.method, method.im_class.__name__, method.__name__, akw)
307 r = method(**self.params)
308 except werkzeug.exceptions.HTTPException, e:
311 _logger.exception("An exception occured during an http request")
312 se = serialize_exception(e)
315 'message': "OpenERP Server Error",
318 r = werkzeug.exceptions.InternalServerError(cgi.escape(simplejson.dumps(error)))
321 r = werkzeug.wrappers.Response(status=204) # no content
322 if isinstance(r, (werkzeug.wrappers.BaseResponse, werkzeug.exceptions.HTTPException)):
323 _logger.debug('<-- %s', r)
325 _logger.debug("<-- size: %s", len(r))
328 def make_response(self, data, headers=None, cookies=None):
329 """ Helper for non-HTML responses, or HTML responses with custom
330 response headers or cookies.
332 While handlers can just return the HTML markup of a page they want to
333 send as a string if non-HTML data is returned they need to create a
334 complete response object, or the returned data will not be correctly
335 interpreted by the clients.
337 :param basestring data: response body
338 :param headers: HTTP headers to set on the response
339 :type headers: ``[(name, value)]``
340 :param collections.Mapping cookies: cookies to set on the client
342 response = werkzeug.wrappers.Response(data, headers=headers)
344 for k, v in cookies.iteritems():
345 response.set_cookie(k, v)
348 def not_found(self, description=None):
349 """ Helper for 404 response, return its result from the method
351 return werkzeug.exceptions.NotFound(description)
354 """ Decorator marking the decorated method as being a handler for a
355 normal HTTP request (the exact request path is specified via the
356 ``$(Controller._cp_path)/$methodname`` combination.
358 If the method is called, it will be provided with a :class:`HttpRequest`
359 instance and all ``params`` sent during the request (``GET`` and ``POST``
360 merged in the same dictionary), apart from the ``session_id``, ``context``
361 and ``debug`` keys (which are stripped out beforehand)
366 #----------------------------------------------------------
367 # Local storage of requests
368 #----------------------------------------------------------
369 from werkzeug.local import LocalStack
371 _request_stack = LocalStack()
373 def set_request(request):
374 class with_obj(object):
376 _request_stack.push(request)
377 def __exit__(self, *args):
381 request = _request_stack()
383 #----------------------------------------------------------
384 # Controller registration with a metaclass
385 #----------------------------------------------------------
388 controllers_class = []
389 controllers_class_path = {}
390 controllers_object = {}
391 controllers_object_path = {}
392 controllers_path = {}
394 class ControllerType(type):
395 def __init__(cls, name, bases, attrs):
396 super(ControllerType, cls).__init__(name, bases, attrs)
398 # create wrappers for old-style methods with req as first argument
399 cls._methods_wrapper = {}
400 for k, v in attrs.items():
401 if inspect.isfunction(v):
402 spec = inspect.getargspec(v)
403 first_arg = spec.args[1] if len(spec.args) >= 2 else None
404 if first_arg in ["req", "request"]:
406 return lambda self, *args, **kwargs: nv(self, request, *args, **kwargs)
407 cls._methods_wrapper[k] = build_new(v)
409 # store the controller in the controllers list
410 name_class = ("%s.%s" % (cls.__module__, cls.__name__), cls)
411 controllers_class.append(name_class)
412 path = attrs.get('_cp_path')
413 if path not in controllers_class_path:
414 controllers_class_path[path] = name_class
416 class Controller(object):
417 __metaclass__ = ControllerType
419 def __new__(cls, *args, **kwargs):
420 subclasses = [c for c in cls.__subclasses__() if c._cp_path == cls._cp_path]
422 name = "%s (extended by %s)" % (cls.__name__, ', '.join(sub.__name__ for sub in subclasses))
423 cls = type(name, tuple(reversed(subclasses)), {})
425 return object.__new__(cls)
427 def get_wrapped_method(self, name):
428 if name in self.__class__._methods_wrapper:
429 return functools.partial(self.__class__._methods_wrapper[name], self)
431 return getattr(self, name)
433 #----------------------------------------------------------
434 # Session context manager
435 #----------------------------------------------------------
436 @contextlib.contextmanager
437 def session_context(request, session_store, session_lock, sid):
440 request.session = session_store.get(sid)
442 request.session = session_store.new()
444 yield request.session
446 # Remove all OpenERPSession instances with no uid, they're generated
447 # either by login process or by HTTP requests without an OpenERP
448 # session id, and are generally noise
449 removed_sessions = set()
450 for key, value in request.session.items():
451 if not isinstance(value, session.OpenERPSession):
453 if getattr(value, '_suicide', False) or (
455 and not value.jsonp_requests
456 # FIXME do not use a fixed value
457 and value._creation_time + (60*5) < time.time()):
458 _logger.debug('remove session %s', key)
459 removed_sessions.add(key)
460 del request.session[key]
464 # Re-load sessions from storage and merge non-literal
465 # contexts and domains (they're indexed by hash of the
466 # content so conflicts should auto-resolve), otherwise if
467 # two requests alter those concurrently the last to finish
468 # will overwrite the previous one, leading to loss of data
469 # (a non-literal is lost even though it was sent to the
470 # client and client errors)
472 # note that domains_store and contexts_store are append-only (we
473 # only ever add items to them), so we can just update one with the
474 # other to get the right result, if we want to merge the
475 # ``context`` dict we'll need something smarter
476 in_store = session_store.get(sid)
477 for k, v in request.session.iteritems():
478 stored = in_store.get(k)
479 if stored and isinstance(v, session.OpenERPSession):
480 if hasattr(v, 'contexts_store'):
482 if hasattr(v, 'domains_store'):
484 if not hasattr(v, 'jsonp_requests'):
485 v.jsonp_requests = {}
486 v.jsonp_requests.update(getattr(
487 stored, 'jsonp_requests', {}))
490 for k, v in in_store.iteritems():
491 if k not in request.session and k not in removed_sessions:
492 request.session[k] = v
494 session_store.save(request.session)
496 def session_gc(session_store):
497 if random.random() < 0.001:
498 # we keep session one week
499 last_week = time.time() - 60*60*24*7
500 for fname in os.listdir(session_store.path):
501 path = os.path.join(session_store.path, fname)
503 if os.path.getmtime(path) < last_week:
508 #----------------------------------------------------------
510 #----------------------------------------------------------
511 # Add potentially missing (older ubuntu) font mime types
512 mimetypes.add_type('application/font-woff', '.woff')
513 mimetypes.add_type('application/vnd.ms-fontobject', '.eot')
514 mimetypes.add_type('application/x-font-ttf', '.ttf')
516 class DisableCacheMiddleware(object):
517 def __init__(self, app):
519 def __call__(self, environ, start_response):
520 def start_wrapped(status, headers):
521 referer = environ.get('HTTP_REFERER', '')
522 parsed = urlparse.urlparse(referer)
523 debug = parsed.query.count('debug') >= 1
526 unwanted_keys = ['Last-Modified']
528 new_headers = [('Cache-Control', 'no-cache')]
529 unwanted_keys += ['Expires', 'Etag', 'Cache-Control']
532 if k not in unwanted_keys:
533 new_headers.append((k, v))
535 start_response(status, new_headers)
536 return self.app(environ, start_wrapped)
540 username = getpass.getuser()
543 path = os.path.join(tempfile.gettempdir(), "oe-sessions-" + username)
546 except OSError as exc:
547 if exc.errno == errno.EEXIST:
548 # directory exists: ensure it has the correct permissions
549 # this will fail if the directory is not owned by the current user
556 """Root WSGI application for the OpenERP Web Client.
564 # Setup http sessions
565 path = session_path()
566 self.session_store = werkzeug.contrib.sessions.FilesystemSessionStore(path)
567 self.session_lock = threading.Lock()
568 _logger.debug('HTTP sessions stored in: %s', path)
570 def __call__(self, environ, start_response):
571 """ Handle a WSGI request
573 return self.dispatch(environ, start_response)
575 def dispatch(self, environ, start_response):
577 Performs the actual WSGI dispatching for the application, may be
578 wrapped during the initialization of the object.
580 Call the object directly.
582 request = werkzeug.wrappers.Request(environ)
583 request.parameter_storage_class = werkzeug.datastructures.ImmutableDict
586 handler = self.find_handler(*(request.path.split('/')[1:]))
589 response = werkzeug.exceptions.NotFound()
591 sid = request.cookies.get('sid')
593 sid = request.args.get('sid')
595 session_gc(self.session_store)
597 with session_context(request, self.session_store, self.session_lock, sid) as session:
598 result = handler(request)
600 if isinstance(result, basestring):
601 headers=[('Content-Type', 'text/html; charset=utf-8'), ('Content-Length', len(result))]
602 response = werkzeug.wrappers.Response(result, headers=headers)
606 if hasattr(response, 'set_cookie'):
607 response.set_cookie('sid', session.sid)
609 return response(environ, start_response)
611 def load_addons(self):
612 """ Load all addons from addons patch containg static files and
613 controllers and configure them. """
615 for addons_path in openerp.modules.module.ad_paths:
616 for module in sorted(os.listdir(str(addons_path))):
617 if module not in addons_module:
618 manifest_path = os.path.join(addons_path, module, '__openerp__.py')
619 path_static = os.path.join(addons_path, module, 'static')
620 if os.path.isfile(manifest_path) and os.path.isdir(path_static):
621 manifest = ast.literal_eval(open(manifest_path).read())
622 manifest['addons_path'] = addons_path
623 _logger.debug("Loading %s", module)
624 if 'openerp.addons' in sys.modules:
625 m = __import__('openerp.addons.' + module)
627 m = __import__(module)
628 addons_module[module] = m
629 addons_manifest[module] = manifest
630 self.statics['/%s/static' % module] = path_static
632 for k, v in controllers_class_path.items():
633 if k not in controllers_object_path and hasattr(v[1], '_cp_path'):
635 controllers_object[v[0]] = o
636 controllers_object_path[k] = o
637 if hasattr(o, '_cp_path'):
638 controllers_path[o._cp_path] = o
640 app = werkzeug.wsgi.SharedDataMiddleware(self.dispatch, self.statics)
641 self.dispatch = DisableCacheMiddleware(app)
643 def find_handler(self, *l):
645 Tries to discover the controller handling the request for the path
646 specified by the provided parameters
648 :param l: path sections to a controller or controller method
649 :returns: a callable matching the path sections, or ``None``
650 :rtype: ``Controller | None``
653 ps = '/' + '/'.join(filter(None, l))
654 method_name = 'index'
656 c = controllers_path.get(ps)
658 method = getattr(c, method_name, None)
660 exposed = getattr(method, 'exposed', False)
661 method = c.get_wrapped_method(method_name)
662 if exposed == 'json':
663 _logger.debug("Dispatch json to %s %s %s", ps, c, method_name)
665 _req = JsonRequest(_request)
666 with set_request(_req):
667 return request.dispatch(method)
669 elif exposed == 'http':
670 _logger.debug("Dispatch http to %s %s %s", ps, c, method_name)
672 _req = HttpRequest(_request)
673 with set_request(_req):
674 return request.dispatch(method)
676 if method_name != "index":
677 method_name = "index"
679 ps, _slash, method_name = ps.rpartition('/')
680 if not ps and method_name:
685 openerp.wsgi.register_wsgi_handler(Root())