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 @contextlib.contextmanager
122 def registry_cr(self):
123 dbname = self.session._db or openerp.addons.web.controllers.main.db_monodb(self)
124 registry = openerp.modules.registry.RegistryManager.get(dbname.lower())
125 with registry.cursor() as cr:
128 def reject_nonliteral(dct):
131 "Non literal contexts can not be sent to the server anymore (%r)" % (dct,))
134 class JsonRequest(WebRequest):
135 """ JSON-RPC2 over HTTP.
139 --> {"jsonrpc": "2.0",
141 "params": {"session_id": "SID",
146 <-- {"jsonrpc": "2.0",
147 "result": { "res1": "val1" },
150 Request producing a error::
152 --> {"jsonrpc": "2.0",
154 "params": {"session_id": "SID",
159 <-- {"jsonrpc": "2.0",
161 "message": "End user error message.",
162 "data": {"code": "codestring",
163 "debug": "traceback" } },
167 def dispatch(self, method):
168 """ Calls the method asked for by the JSON-RPC2 or JSONP request
170 :param method: the method which received the request
172 :returns: an utf8 encoded JSON-RPC2 or JSONP reply
174 args = self.httprequest.args
175 jsonp = args.get('jsonp')
178 request_id = args.get('id')
180 if jsonp and self.httprequest.method == 'POST':
181 # jsonp 2 steps step1 POST: save call
183 self.session.jsonp_requests[request_id] = self.httprequest.form['r']
184 headers=[('Content-Type', 'text/plain; charset=utf-8')]
185 r = werkzeug.wrappers.Response(request_id, headers=headers)
187 elif jsonp and args.get('r'):
189 request = args.get('r')
190 elif jsonp and request_id:
191 # jsonp 2 steps step2 GET: run and return result
193 request = self.session.jsonp_requests.pop(request_id, "")
196 requestf = self.httprequest.stream
198 response = {"jsonrpc": "2.0" }
201 # Read POST content or POST Form Data named "request"
203 self.jsonrequest = simplejson.load(requestf, object_hook=reject_nonliteral)
205 self.jsonrequest = simplejson.loads(request, object_hook=reject_nonliteral)
206 self.init(self.jsonrequest.get("params", {}))
207 if _logger.isEnabledFor(logging.DEBUG):
208 _logger.debug("--> %s.%s\n%s", method.im_class.__name__, method.__name__, pprint.pformat(self.jsonrequest))
209 response['id'] = self.jsonrequest.get('id')
210 response["result"] = method(self, **self.params)
211 except session.AuthenticationError, e:
212 se = serialize_exception(e)
215 'message': "OpenERP Session Invalid",
219 se = serialize_exception(e)
222 'message': "OpenERP Server Error",
226 response["error"] = error
228 if _logger.isEnabledFor(logging.DEBUG):
229 _logger.debug("<--\n%s", pprint.pformat(response))
232 # If we use jsonp, that's mean we are called from another host
233 # Some browser (IE and Safari) do no allow third party cookies
234 # We need then to manage http sessions manually.
235 response['httpsessionid'] = self.httpsession.sid
236 mime = 'application/javascript'
237 body = "%s(%s);" % (jsonp, simplejson.dumps(response),)
239 mime = 'application/json'
240 body = simplejson.dumps(response)
242 r = werkzeug.wrappers.Response(body, headers=[('Content-Type', mime), ('Content-Length', len(body))])
245 def serialize_exception(e):
247 "name": type(e).__module__ + "." + type(e).__name__ if type(e).__module__ else type(e).__name__,
248 "debug": traceback.format_exc(),
249 "message": u"%s" % e,
250 "arguments": to_jsonable(e.args),
252 if isinstance(e, openerp.osv.osv.except_osv):
253 tmp["exception_type"] = "except_osv"
254 elif isinstance(e, openerp.exceptions.Warning):
255 tmp["exception_type"] = "warning"
256 elif isinstance(e, openerp.exceptions.AccessError):
257 tmp["exception_type"] = "access_error"
258 elif isinstance(e, openerp.exceptions.AccessDenied):
259 tmp["exception_type"] = "access_denied"
263 if isinstance(o, str) or isinstance(o,unicode) or isinstance(o, int) or isinstance(o, long) \
264 or isinstance(o, bool) or o is None or isinstance(o, float):
266 if isinstance(o, list) or isinstance(o, tuple):
267 return [to_jsonable(x) for x in o]
268 if isinstance(o, dict):
270 for k, v in o.items():
271 tmp[u"%s" % k] = to_jsonable(v)
276 """ Decorator marking the decorated method as being a handler for a
277 JSON-RPC request (the exact request path is specified via the
278 ``$(Controller._cp_path)/$methodname`` combination.
280 If the method is called, it will be provided with a :class:`JsonRequest`
281 instance and all ``params`` sent during the JSON-RPC request, apart from
282 the ``session_id``, ``context`` and ``debug`` keys (which are stripped out
288 class HttpRequest(WebRequest):
289 """ Regular GET/POST request
291 def dispatch(self, method):
292 params = dict(self.httprequest.args)
293 params.update(self.httprequest.form)
294 params.update(self.httprequest.files)
297 for key, value in self.httprequest.args.iteritems():
298 if isinstance(value, basestring) and len(value) < 1024:
301 akw[key] = type(value)
302 _logger.debug("%s --> %s.%s %r", self.httprequest.method, method.im_class.__name__, method.__name__, akw)
304 r = method(self, **self.params)
305 except werkzeug.exceptions.HTTPException, e:
308 _logger.exception("An exception occured during an http request")
309 se = serialize_exception(e)
312 'message': "OpenERP Server Error",
315 r = werkzeug.exceptions.InternalServerError(cgi.escape(simplejson.dumps(error)))
318 r = werkzeug.wrappers.Response(status=204) # no content
319 if isinstance(r, (werkzeug.wrappers.BaseResponse, werkzeug.exceptions.HTTPException)):
320 _logger.debug('<-- %s', r)
322 _logger.debug("<-- size: %s", len(r))
325 def make_response(self, data, headers=None, cookies=None):
326 """ Helper for non-HTML responses, or HTML responses with custom
327 response headers or cookies.
329 While handlers can just return the HTML markup of a page they want to
330 send as a string if non-HTML data is returned they need to create a
331 complete response object, or the returned data will not be correctly
332 interpreted by the clients.
334 :param basestring data: response body
335 :param headers: HTTP headers to set on the response
336 :type headers: ``[(name, value)]``
337 :param collections.Mapping cookies: cookies to set on the client
339 response = werkzeug.wrappers.Response(data, headers=headers)
341 for k, v in cookies.iteritems():
342 response.set_cookie(k, v)
345 def not_found(self, description=None):
346 """ Helper for 404 response, return its result from the method
348 return werkzeug.exceptions.NotFound(description)
351 """ Decorator marking the decorated method as being a handler for a
352 normal HTTP request (the exact request path is specified via the
353 ``$(Controller._cp_path)/$methodname`` combination.
355 If the method is called, it will be provided with a :class:`HttpRequest`
356 instance and all ``params`` sent during the request (``GET`` and ``POST``
357 merged in the same dictionary), apart from the ``session_id``, ``context``
358 and ``debug`` keys (which are stripped out beforehand)
363 #----------------------------------------------------------
364 # Controller registration with a metaclass
365 #----------------------------------------------------------
368 controllers_class = []
369 controllers_class_path = {}
370 controllers_object = {}
371 controllers_object_path = {}
372 controllers_path = {}
374 class ControllerType(type):
375 def __init__(cls, name, bases, attrs):
376 super(ControllerType, cls).__init__(name, bases, attrs)
377 name_class = ("%s.%s" % (cls.__module__, cls.__name__), cls)
378 controllers_class.append(name_class)
379 path = attrs.get('_cp_path')
380 if path not in controllers_class_path:
381 controllers_class_path[path] = name_class
383 class Controller(object):
384 __metaclass__ = ControllerType
386 def __new__(cls, *args, **kwargs):
387 subclasses = [c for c in cls.__subclasses__() if c._cp_path == cls._cp_path]
389 name = "%s (extended by %s)" % (cls.__name__, ', '.join(sub.__name__ for sub in subclasses))
390 cls = type(name, tuple(reversed(subclasses)), {})
392 return object.__new__(cls)
394 #----------------------------------------------------------
395 # Session context manager
396 #----------------------------------------------------------
397 @contextlib.contextmanager
398 def session_context(request, session_store, session_lock, sid):
401 request.session = session_store.get(sid)
403 request.session = session_store.new()
405 yield request.session
407 # Remove all OpenERPSession instances with no uid, they're generated
408 # either by login process or by HTTP requests without an OpenERP
409 # session id, and are generally noise
410 removed_sessions = set()
411 for key, value in request.session.items():
412 if not isinstance(value, session.OpenERPSession):
414 if getattr(value, '_suicide', False) or (
416 and not value.jsonp_requests
417 # FIXME do not use a fixed value
418 and value._creation_time + (60*5) < time.time()):
419 _logger.debug('remove session %s', key)
420 removed_sessions.add(key)
421 del request.session[key]
425 # Re-load sessions from storage and merge non-literal
426 # contexts and domains (they're indexed by hash of the
427 # content so conflicts should auto-resolve), otherwise if
428 # two requests alter those concurrently the last to finish
429 # will overwrite the previous one, leading to loss of data
430 # (a non-literal is lost even though it was sent to the
431 # client and client errors)
433 # note that domains_store and contexts_store are append-only (we
434 # only ever add items to them), so we can just update one with the
435 # other to get the right result, if we want to merge the
436 # ``context`` dict we'll need something smarter
437 in_store = session_store.get(sid)
438 for k, v in request.session.iteritems():
439 stored = in_store.get(k)
440 if stored and isinstance(v, session.OpenERPSession):
441 if hasattr(v, 'contexts_store'):
443 if hasattr(v, 'domains_store'):
445 if not hasattr(v, 'jsonp_requests'):
446 v.jsonp_requests = {}
447 v.jsonp_requests.update(getattr(
448 stored, 'jsonp_requests', {}))
451 for k, v in in_store.iteritems():
452 if k not in request.session and k not in removed_sessions:
453 request.session[k] = v
455 session_store.save(request.session)
457 def session_gc(session_store):
458 if random.random() < 0.001:
459 # we keep session one week
460 last_week = time.time() - 60*60*24*7
461 for fname in os.listdir(session_store.path):
462 path = os.path.join(session_store.path, fname)
464 if os.path.getmtime(path) < last_week:
469 #----------------------------------------------------------
471 #----------------------------------------------------------
472 # Add potentially missing (older ubuntu) font mime types
473 mimetypes.add_type('application/font-woff', '.woff')
474 mimetypes.add_type('application/vnd.ms-fontobject', '.eot')
475 mimetypes.add_type('application/x-font-ttf', '.ttf')
477 class DisableCacheMiddleware(object):
478 def __init__(self, app):
480 def __call__(self, environ, start_response):
481 def start_wrapped(status, headers):
482 referer = environ.get('HTTP_REFERER', '')
483 parsed = urlparse.urlparse(referer)
484 debug = parsed.query.count('debug') >= 1
487 unwanted_keys = ['Last-Modified']
489 new_headers = [('Cache-Control', 'no-cache')]
490 unwanted_keys += ['Expires', 'Etag', 'Cache-Control']
493 if k not in unwanted_keys:
494 new_headers.append((k, v))
496 start_response(status, new_headers)
497 return self.app(environ, start_wrapped)
502 username = pwd.getpwuid(os.geteuid()).pw_name
505 username = getpass.getuser()
508 path = os.path.join(tempfile.gettempdir(), "oe-sessions-" + username)
511 except OSError as exc:
512 if exc.errno == errno.EEXIST:
513 # directory exists: ensure it has the correct permissions
514 # this will fail if the directory is not owned by the current user
521 """Root WSGI application for the OpenERP Web Client.
529 # Setup http sessions
530 path = session_path()
531 self.session_store = werkzeug.contrib.sessions.FilesystemSessionStore(path)
532 self.session_lock = threading.Lock()
533 _logger.debug('HTTP sessions stored in: %s', path)
535 def __call__(self, environ, start_response):
536 """ Handle a WSGI request
538 return self.dispatch(environ, start_response)
540 def dispatch(self, environ, start_response):
542 Performs the actual WSGI dispatching for the application, may be
543 wrapped during the initialization of the object.
545 Call the object directly.
547 request = werkzeug.wrappers.Request(environ)
548 request.parameter_storage_class = werkzeug.datastructures.ImmutableDict
551 handler = self.find_handler(*(request.path.split('/')[1:]))
554 response = werkzeug.exceptions.NotFound()
556 sid = request.cookies.get('sid')
558 sid = request.args.get('sid')
560 session_gc(self.session_store)
562 with session_context(request, self.session_store, self.session_lock, sid) as session:
563 result = handler(request)
565 if isinstance(result, basestring):
566 headers=[('Content-Type', 'text/html; charset=utf-8'), ('Content-Length', len(result))]
567 response = werkzeug.wrappers.Response(result, headers=headers)
571 if hasattr(response, 'set_cookie'):
572 response.set_cookie('sid', session.sid)
574 return response(environ, start_response)
576 def load_addons(self):
577 """ Load all addons from addons patch containg static files and
578 controllers and configure them. """
580 for addons_path in openerp.modules.module.ad_paths:
581 for module in sorted(os.listdir(str(addons_path))):
582 if module not in addons_module:
583 manifest_path = os.path.join(addons_path, module, '__openerp__.py')
584 path_static = os.path.join(addons_path, module, 'static')
585 if os.path.isfile(manifest_path) and os.path.isdir(path_static):
586 manifest = ast.literal_eval(open(manifest_path).read())
587 manifest['addons_path'] = addons_path
588 _logger.debug("Loading %s", module)
589 if 'openerp.addons' in sys.modules:
590 m = __import__('openerp.addons.' + module)
592 m = __import__(module)
593 addons_module[module] = m
594 addons_manifest[module] = manifest
595 self.statics['/%s/static' % module] = path_static
597 for k, v in controllers_class_path.items():
598 if k not in controllers_object_path and hasattr(v[1], '_cp_path'):
600 controllers_object[v[0]] = o
601 controllers_object_path[k] = o
602 if hasattr(o, '_cp_path'):
603 controllers_path[o._cp_path] = o
605 app = werkzeug.wsgi.SharedDataMiddleware(self.dispatch, self.statics)
606 self.dispatch = DisableCacheMiddleware(app)
608 def find_handler(self, *l):
610 Tries to discover the controller handling the request for the path
611 specified by the provided parameters
613 :param l: path sections to a controller or controller method
614 :returns: a callable matching the path sections, or ``None``
615 :rtype: ``Controller | None``
618 ps = '/' + '/'.join(filter(None, l))
619 method_name = 'index'
621 c = controllers_path.get(ps)
623 method = getattr(c, method_name, None)
625 exposed = getattr(method, 'exposed', False)
626 if exposed == 'json':
627 _logger.debug("Dispatch json to %s %s %s", ps, c, method_name)
628 return lambda request: JsonRequest(request).dispatch(method)
629 elif exposed == 'http':
630 _logger.debug("Dispatch http to %s %s %s", ps, c, method_name)
631 return lambda request: HttpRequest(request).dispatch(method)
632 if method_name != "index":
633 method_name = "index"
635 ps, _slash, method_name = ps.rpartition('/')
636 if not ps and method_name:
641 openerp.wsgi.register_wsgi_handler(Root())