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:
207 'message': "OpenERP Session Invalid",
209 'type': 'session_invalid',
210 'debug': traceback.format_exc()
213 except xmlrpclib.Fault, e:
216 'message': "OpenERP Server Error",
218 'type': 'server_exception',
219 'fault_code': e.faultCode,
220 'debug': "Client %s\nServer %s" % (
221 "".join(traceback.format_exception("", None, sys.exc_traceback)), e.faultString)
225 logging.getLogger(__name__ + '.JSONRequest.dispatch').exception\
226 ("An error occured while handling a json request")
229 'message': "OpenERP WebClient Error",
231 'type': 'client_exception',
232 'debug': "Client %s" % traceback.format_exc()
236 response["error"] = error
238 if _logger.isEnabledFor(logging.DEBUG):
239 _logger.debug("<--\n%s", pprint.pformat(response))
242 # If we use jsonp, that's mean we are called from another host
243 # Some browser (IE and Safari) do no allow third party cookies
244 # We need then to manage http sessions manually.
245 response['httpsessionid'] = self.httpsession.sid
246 mime = 'application/javascript'
247 body = "%s(%s);" % (jsonp, simplejson.dumps(response),)
249 mime = 'application/json'
250 body = simplejson.dumps(response)
252 r = werkzeug.wrappers.Response(body, headers=[('Content-Type', mime), ('Content-Length', len(body))])
256 """ Decorator marking the decorated method as being a handler for a
257 JSON-RPC request (the exact request path is specified via the
258 ``$(Controller._cp_path)/$methodname`` combination.
260 If the method is called, it will be provided with a :class:`JsonRequest`
261 instance and all ``params`` sent during the JSON-RPC request, apart from
262 the ``session_id``, ``context`` and ``debug`` keys (which are stripped out
268 class HttpRequest(WebRequest):
269 """ Regular GET/POST request
271 def dispatch(self, method):
272 params = dict(self.httprequest.args)
273 params.update(self.httprequest.form)
274 params.update(self.httprequest.files)
277 for key, value in self.httprequest.args.iteritems():
278 if isinstance(value, basestring) and len(value) < 1024:
281 akw[key] = type(value)
282 _logger.debug("%s --> %s.%s %r", self.httprequest.method, method.im_class.__name__, method.__name__, akw)
284 r = method(self, **self.params)
285 except xmlrpclib.Fault, e:
286 r = werkzeug.exceptions.InternalServerError(cgi.escape(simplejson.dumps({
288 'message': "OpenERP Server Error",
290 'type': 'server_exception',
291 'fault_code': e.faultCode,
292 'debug': "Server %s\nClient %s" % (
293 e.faultString, traceback.format_exc())
297 logging.getLogger(__name__ + '.HttpRequest.dispatch').exception(
298 "An error occurred while handling a json request")
299 r = werkzeug.exceptions.InternalServerError(cgi.escape(simplejson.dumps({
301 'message': "OpenERP WebClient Error",
303 'type': 'client_exception',
304 'debug': "Client %s" % traceback.format_exc()
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_object = {}
359 controllers_path = {}
361 class ControllerType(type):
362 def __init__(cls, name, bases, attrs):
363 super(ControllerType, cls).__init__(name, bases, attrs)
364 controllers_class.append(("%s.%s" % (cls.__module__, cls.__name__), cls))
366 class Controller(object):
367 __metaclass__ = ControllerType
369 #----------------------------------------------------------
370 # Session context manager
371 #----------------------------------------------------------
372 @contextlib.contextmanager
373 def session_context(request, session_store, session_lock, sid):
376 request.session = session_store.get(sid)
378 request.session = session_store.new()
380 yield request.session
382 # Remove all OpenERPSession instances with no uid, they're generated
383 # either by login process or by HTTP requests without an OpenERP
384 # session id, and are generally noise
385 removed_sessions = set()
386 for key, value in request.session.items():
387 if not isinstance(value, session.OpenERPSession):
389 if getattr(value, '_suicide', False) or (
391 and not value.jsonp_requests
392 # FIXME do not use a fixed value
393 and value._creation_time + (60*5) < time.time()):
394 _logger.debug('remove session %s', key)
395 removed_sessions.add(key)
396 del request.session[key]
400 # Re-load sessions from storage and merge non-literal
401 # contexts and domains (they're indexed by hash of the
402 # content so conflicts should auto-resolve), otherwise if
403 # two requests alter those concurrently the last to finish
404 # will overwrite the previous one, leading to loss of data
405 # (a non-literal is lost even though it was sent to the
406 # client and client errors)
408 # note that domains_store and contexts_store are append-only (we
409 # only ever add items to them), so we can just update one with the
410 # other to get the right result, if we want to merge the
411 # ``context`` dict we'll need something smarter
412 in_store = session_store.get(sid)
413 for k, v in request.session.iteritems():
414 stored = in_store.get(k)
415 if stored and isinstance(v, session.OpenERPSession):
416 if hasattr(v, 'contexts_store'):
418 if hasattr(v, 'domains_store'):
420 if not hasattr(v, 'jsonp_requests'):
421 v.jsonp_requests = {}
422 v.jsonp_requests.update(getattr(
423 stored, 'jsonp_requests', {}))
426 for k, v in in_store.iteritems():
427 if k not in request.session and k not in removed_sessions:
428 request.session[k] = v
430 session_store.save(request.session)
432 def session_gc(session_store):
433 if random.random() < 0.001:
434 # we keep session one week
435 last_week = time.time() - 60*60*24*7
436 for fname in os.listdir(session_store.path):
437 path = os.path.join(session_store.path, fname)
439 if os.path.getmtime(path) < last_week:
444 #----------------------------------------------------------
446 #----------------------------------------------------------
447 # Add potentially missing (older ubuntu) font mime types
448 mimetypes.add_type('application/font-woff', '.woff')
449 mimetypes.add_type('application/vnd.ms-fontobject', '.eot')
450 mimetypes.add_type('application/x-font-ttf', '.ttf')
452 class DisableCacheMiddleware(object):
453 def __init__(self, app):
455 def __call__(self, environ, start_response):
456 def start_wrapped(status, headers):
457 referer = environ.get('HTTP_REFERER', '')
458 parsed = urlparse.urlparse(referer)
459 debug = parsed.query.count('debug') >= 1
462 unwanted_keys = ['Last-Modified']
464 new_headers = [('Cache-Control', 'no-cache')]
465 unwanted_keys += ['Expires', 'Etag', 'Cache-Control']
468 if k not in unwanted_keys:
469 new_headers.append((k, v))
471 start_response(status, new_headers)
472 return self.app(environ, start_wrapped)
476 username = getpass.getuser()
479 path = os.path.join(tempfile.gettempdir(), "oe-sessions-" + username)
480 if not os.path.exists(path):
485 """Root WSGI application for the OpenERP Web Client.
493 # Setup http sessions
494 path = session_path()
495 self.session_store = werkzeug.contrib.sessions.FilesystemSessionStore(path)
496 self.session_lock = threading.Lock()
497 _logger.debug('HTTP sessions stored in: %s', path)
499 def __call__(self, environ, start_response):
500 """ Handle a WSGI request
502 return self.dispatch(environ, start_response)
504 def dispatch(self, environ, start_response):
506 Performs the actual WSGI dispatching for the application, may be
507 wrapped during the initialization of the object.
509 Call the object directly.
511 request = werkzeug.wrappers.Request(environ)
512 request.parameter_storage_class = werkzeug.datastructures.ImmutableDict
515 handler = self.find_handler(*(request.path.split('/')[1:]))
518 response = werkzeug.exceptions.NotFound()
520 sid = request.cookies.get('sid')
522 sid = request.args.get('sid')
524 session_gc(self.session_store)
526 with session_context(request, self.session_store, self.session_lock, sid) as session:
527 result = handler(request)
529 if isinstance(result, basestring):
530 headers=[('Content-Type', 'text/html; charset=utf-8'), ('Content-Length', len(result))]
531 response = werkzeug.wrappers.Response(result, headers=headers)
535 if hasattr(response, 'set_cookie'):
536 response.set_cookie('sid', session.sid)
538 return response(environ, start_response)
540 def load_addons(self):
541 """ Load all addons from addons patch containg static files and
542 controllers and configure them. """
544 for addons_path in openerp.modules.module.ad_paths:
545 for module in sorted(os.listdir(addons_path)):
546 if module not in addons_module:
547 manifest_path = os.path.join(addons_path, module, '__openerp__.py')
548 path_static = os.path.join(addons_path, module, 'static')
549 if os.path.isfile(manifest_path) and os.path.isdir(path_static):
550 manifest = ast.literal_eval(open(manifest_path).read())
551 manifest['addons_path'] = addons_path
552 _logger.debug("Loading %s", module)
553 if 'openerp.addons' in sys.modules:
554 m = __import__('openerp.addons.' + module)
556 m = __import__(module)
557 addons_module[module] = m
558 addons_manifest[module] = manifest
559 self.statics['/%s/static' % module] = path_static
561 for k, v in controllers_class:
562 if k not in controllers_object:
564 controllers_object[k] = o
565 if hasattr(o, '_cp_path'):
566 controllers_path[o._cp_path] = o
568 app = werkzeug.wsgi.SharedDataMiddleware(self.dispatch, self.statics)
569 self.dispatch = DisableCacheMiddleware(app)
571 def find_handler(self, *l):
573 Tries to discover the controller handling the request for the path
574 specified by the provided parameters
576 :param l: path sections to a controller or controller method
577 :returns: a callable matching the path sections, or ``None``
578 :rtype: ``Controller | None``
581 ps = '/' + '/'.join(filter(None, l))
582 method_name = 'index'
584 c = controllers_path.get(ps)
586 method = getattr(c, method_name, None)
588 exposed = getattr(method, 'exposed', False)
589 if exposed == 'json':
590 _logger.debug("Dispatch json to %s %s %s", ps, c, method_name)
591 return lambda request: JsonRequest(request).dispatch(method)
592 elif exposed == 'http':
593 _logger.debug("Dispatch http to %s %s %s", ps, c, method_name)
594 return lambda request: HttpRequest(request).dispatch(method)
595 ps, _slash, method_name = ps.rpartition('/')
596 if not ps and method_name:
601 openerp.wsgi.register_wsgi_handler(Root())