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
96 self.context = self.params.pop('context', {})
97 self.debug = self.params.pop('debug', False) is not False
99 lang = self.params.get('lang', None)
101 lang = self.context.get('lang')
103 lang = self.httprequest.cookies.get('lang')
105 lang = self.httprequest.accept_languages.best
108 # tranform 2 letters lang like 'en' into 5 letters like 'en_US'
109 lang = babel.core.LOCALE_ALIASES.get(lang, lang)
110 # we use _ as seprator where RFC2616 uses '-'
111 self.lang = lang.replace('-', '_')
113 def reject_nonliteral(dct):
116 "Non literal contexts can not be sent to the server anymore (%r)" % (dct,))
119 class JsonRequest(WebRequest):
120 """ JSON-RPC2 over HTTP.
124 --> {"jsonrpc": "2.0",
126 "params": {"session_id": "SID",
131 <-- {"jsonrpc": "2.0",
132 "result": { "res1": "val1" },
135 Request producing a error::
137 --> {"jsonrpc": "2.0",
139 "params": {"session_id": "SID",
144 <-- {"jsonrpc": "2.0",
146 "message": "End user error message.",
147 "data": {"code": "codestring",
148 "debug": "traceback" } },
152 def dispatch(self, method):
153 """ Calls the method asked for by the JSON-RPC2 or JSONP request
155 :param method: the method which received the request
157 :returns: an utf8 encoded JSON-RPC2 or JSONP reply
159 args = self.httprequest.args
160 jsonp = args.get('jsonp')
163 request_id = args.get('id')
165 if jsonp and self.httprequest.method == 'POST':
166 # jsonp 2 steps step1 POST: save call
168 self.session.jsonp_requests[request_id] = self.httprequest.form['r']
169 headers=[('Content-Type', 'text/plain; charset=utf-8')]
170 r = werkzeug.wrappers.Response(request_id, headers=headers)
172 elif jsonp and args.get('r'):
174 request = args.get('r')
175 elif jsonp and request_id:
176 # jsonp 2 steps step2 GET: run and return result
178 request = self.session.jsonp_requests.pop(request_id, "")
181 requestf = self.httprequest.stream
183 response = {"jsonrpc": "2.0" }
186 # Read POST content or POST Form Data named "request"
188 self.jsonrequest = simplejson.load(requestf, object_hook=reject_nonliteral)
190 self.jsonrequest = simplejson.loads(request, object_hook=reject_nonliteral)
191 self.init(self.jsonrequest.get("params", {}))
192 if _logger.isEnabledFor(logging.DEBUG):
193 _logger.debug("--> %s.%s\n%s", method.im_class.__name__, method.__name__, pprint.pformat(self.jsonrequest))
194 response['id'] = self.jsonrequest.get('id')
195 response["result"] = method(self, **self.params)
196 except session.AuthenticationError:
199 'message': "OpenERP Session Invalid",
201 'type': 'session_invalid',
202 'debug': traceback.format_exc()
205 except xmlrpclib.Fault, e:
208 'message': "OpenERP Server Error",
210 'type': 'server_exception',
211 'fault_code': e.faultCode,
212 'debug': "Client %s\nServer %s" % (
213 "".join(traceback.format_exception("", None, sys.exc_traceback)), e.faultString)
217 logging.getLogger(__name__ + '.JSONRequest.dispatch').exception\
218 ("An error occured while handling a json request")
221 'message': "OpenERP WebClient Error",
223 'type': 'client_exception',
224 'debug': "Client %s" % traceback.format_exc()
228 response["error"] = error
230 if _logger.isEnabledFor(logging.DEBUG):
231 _logger.debug("<--\n%s", pprint.pformat(response))
234 # If we use jsonp, that's mean we are called from another host
235 # Some browser (IE and Safari) do no allow third party cookies
236 # We need then to manage http sessions manually.
237 response['httpsessionid'] = self.httpsession.sid
238 mime = 'application/javascript'
239 body = "%s(%s);" % (jsonp, simplejson.dumps(response),)
241 mime = 'application/json'
242 body = simplejson.dumps(response)
244 r = werkzeug.wrappers.Response(body, headers=[('Content-Type', mime), ('Content-Length', len(body))])
248 """ Decorator marking the decorated method as being a handler for a
249 JSON-RPC request (the exact request path is specified via the
250 ``$(Controller._cp_path)/$methodname`` combination.
252 If the method is called, it will be provided with a :class:`JsonRequest`
253 instance and all ``params`` sent during the JSON-RPC request, apart from
254 the ``session_id``, ``context`` and ``debug`` keys (which are stripped out
260 class HttpRequest(WebRequest):
261 """ Regular GET/POST request
263 def dispatch(self, method):
264 params = dict(self.httprequest.args)
265 params.update(self.httprequest.form)
266 params.update(self.httprequest.files)
269 for key, value in self.httprequest.args.iteritems():
270 if isinstance(value, basestring) and len(value) < 1024:
273 akw[key] = type(value)
274 _logger.debug("%s --> %s.%s %r", self.httprequest.method, method.im_class.__name__, method.__name__, akw)
276 r = method(self, **self.params)
277 except xmlrpclib.Fault, e:
278 r = werkzeug.exceptions.InternalServerError(cgi.escape(simplejson.dumps({
280 'message': "OpenERP Server Error",
282 'type': 'server_exception',
283 'fault_code': e.faultCode,
284 'debug': "Server %s\nClient %s" % (
285 e.faultString, traceback.format_exc())
289 logging.getLogger(__name__ + '.HttpRequest.dispatch').exception(
290 "An error occurred while handling a json request")
291 r = werkzeug.exceptions.InternalServerError(cgi.escape(simplejson.dumps({
293 'message': "OpenERP WebClient Error",
295 'type': 'client_exception',
296 'debug': "Client %s" % traceback.format_exc()
300 if isinstance(r, (werkzeug.wrappers.BaseResponse, werkzeug.exceptions.HTTPException)):
301 _logger.debug('<-- %s', r)
303 _logger.debug("<-- size: %s", len(r))
306 def make_response(self, data, headers=None, cookies=None):
307 """ Helper for non-HTML responses, or HTML responses with custom
308 response headers or cookies.
310 While handlers can just return the HTML markup of a page they want to
311 send as a string if non-HTML data is returned they need to create a
312 complete response object, or the returned data will not be correctly
313 interpreted by the clients.
315 :param basestring data: response body
316 :param headers: HTTP headers to set on the response
317 :type headers: ``[(name, value)]``
318 :param collections.Mapping cookies: cookies to set on the client
320 response = werkzeug.wrappers.Response(data, headers=headers)
322 for k, v in cookies.iteritems():
323 response.set_cookie(k, v)
326 def not_found(self, description=None):
327 """ Helper for 404 response, return its result from the method
329 return werkzeug.exceptions.NotFound(description)
332 """ Decorator marking the decorated method as being a handler for a
333 normal HTTP request (the exact request path is specified via the
334 ``$(Controller._cp_path)/$methodname`` combination.
336 If the method is called, it will be provided with a :class:`HttpRequest`
337 instance and all ``params`` sent during the request (``GET`` and ``POST``
338 merged in the same dictionary), apart from the ``session_id``, ``context``
339 and ``debug`` keys (which are stripped out beforehand)
344 #----------------------------------------------------------
345 # Controller registration with a metaclass
346 #----------------------------------------------------------
349 controllers_class = []
350 controllers_object = {}
351 controllers_path = {}
353 class ControllerType(type):
354 def __init__(cls, name, bases, attrs):
355 super(ControllerType, cls).__init__(name, bases, attrs)
356 controllers_class.append(("%s.%s" % (cls.__module__, cls.__name__), cls))
358 class Controller(object):
359 __metaclass__ = ControllerType
361 #----------------------------------------------------------
362 # Session context manager
363 #----------------------------------------------------------
364 @contextlib.contextmanager
365 def session_context(request, session_store, session_lock, sid):
368 request.session = session_store.get(sid)
370 request.session = session_store.new()
372 yield request.session
374 # Remove all OpenERPSession instances with no uid, they're generated
375 # either by login process or by HTTP requests without an OpenERP
376 # session id, and are generally noise
377 removed_sessions = set()
378 for key, value in request.session.items():
379 if not isinstance(value, session.OpenERPSession):
381 if getattr(value, '_suicide', False) or (
383 and not value.jsonp_requests
384 # FIXME do not use a fixed value
385 and value._creation_time + (60*5) < time.time()):
386 _logger.debug('remove session %s', key)
387 removed_sessions.add(key)
388 del request.session[key]
392 # Re-load sessions from storage and merge non-literal
393 # contexts and domains (they're indexed by hash of the
394 # content so conflicts should auto-resolve), otherwise if
395 # two requests alter those concurrently the last to finish
396 # will overwrite the previous one, leading to loss of data
397 # (a non-literal is lost even though it was sent to the
398 # client and client errors)
400 # note that domains_store and contexts_store are append-only (we
401 # only ever add items to them), so we can just update one with the
402 # other to get the right result, if we want to merge the
403 # ``context`` dict we'll need something smarter
404 in_store = session_store.get(sid)
405 for k, v in request.session.iteritems():
406 stored = in_store.get(k)
407 if stored and isinstance(v, session.OpenERPSession):
408 if hasattr(v, 'contexts_store'):
410 if hasattr(v, 'domains_store'):
412 if not hasattr(v, 'jsonp_requests'):
413 v.jsonp_requests = {}
414 v.jsonp_requests.update(getattr(
415 stored, 'jsonp_requests', {}))
418 for k, v in in_store.iteritems():
419 if k not in request.session and k not in removed_sessions:
420 request.session[k] = v
422 session_store.save(request.session)
424 def session_gc(session_store):
425 if random.random() < 0.001:
426 # we keep session one week
427 last_week = time.time() - 60*60*24*7
428 for fname in os.listdir(session_store.path):
429 path = os.path.join(session_store.path, fname)
431 if os.path.getmtime(path) < last_week:
436 #----------------------------------------------------------
438 #----------------------------------------------------------
439 # Add potentially missing (older ubuntu) font mime types
440 mimetypes.add_type('application/font-woff', '.woff')
441 mimetypes.add_type('application/vnd.ms-fontobject', '.eot')
442 mimetypes.add_type('application/x-font-ttf', '.ttf')
444 class DisableCacheMiddleware(object):
445 def __init__(self, app):
447 def __call__(self, environ, start_response):
448 def start_wrapped(status, headers):
449 referer = environ.get('HTTP_REFERER', '')
450 parsed = urlparse.urlparse(referer)
451 debug = parsed.query.count('debug') >= 1
454 unwanted_keys = ['Last-Modified']
456 new_headers = [('Cache-Control', 'no-cache')]
457 unwanted_keys += ['Expires', 'Etag', 'Cache-Control']
460 if k not in unwanted_keys:
461 new_headers.append((k, v))
463 start_response(status, new_headers)
464 return self.app(environ, start_wrapped)
468 username = getpass.getuser()
471 path = os.path.join(tempfile.gettempdir(), "oe-sessions-" + username)
472 if not os.path.exists(path):
477 """Root WSGI application for the OpenERP Web Client.
485 # Setup http sessions
486 path = session_path()
487 self.session_store = werkzeug.contrib.sessions.FilesystemSessionStore(path)
488 self.session_lock = threading.Lock()
489 _logger.debug('HTTP sessions stored in: %s', path)
491 def __call__(self, environ, start_response):
492 """ Handle a WSGI request
494 return self.dispatch(environ, start_response)
496 def dispatch(self, environ, start_response):
498 Performs the actual WSGI dispatching for the application, may be
499 wrapped during the initialization of the object.
501 Call the object directly.
503 request = werkzeug.wrappers.Request(environ)
504 request.parameter_storage_class = werkzeug.datastructures.ImmutableDict
507 handler = self.find_handler(*(request.path.split('/')[1:]))
510 response = werkzeug.exceptions.NotFound()
512 sid = request.cookies.get('sid')
514 sid = request.args.get('sid')
516 session_gc(self.session_store)
518 with session_context(request, self.session_store, self.session_lock, sid) as session:
519 result = handler(request)
521 if isinstance(result, basestring):
522 headers=[('Content-Type', 'text/html; charset=utf-8'), ('Content-Length', len(result))]
523 response = werkzeug.wrappers.Response(result, headers=headers)
527 if hasattr(response, 'set_cookie'):
528 response.set_cookie('sid', session.sid)
530 return response(environ, start_response)
532 def load_addons(self):
533 """ Load all addons from addons patch containg static files and
534 controllers and configure them. """
536 for addons_path in openerp.modules.module.ad_paths:
537 for module in sorted(os.listdir(addons_path)):
538 if module not in addons_module:
539 manifest_path = os.path.join(addons_path, module, '__openerp__.py')
540 path_static = os.path.join(addons_path, module, 'static')
541 if os.path.isfile(manifest_path) and os.path.isdir(path_static):
542 manifest = ast.literal_eval(open(manifest_path).read())
543 manifest['addons_path'] = addons_path
544 _logger.debug("Loading %s", module)
545 if 'openerp.addons' in sys.modules:
546 m = __import__('openerp.addons.' + module)
548 m = __import__(module)
549 addons_module[module] = m
550 addons_manifest[module] = manifest
551 self.statics['/%s/static' % module] = path_static
553 for k, v in controllers_class:
554 if k not in controllers_object:
556 controllers_object[k] = o
557 if hasattr(o, '_cp_path'):
558 controllers_path[o._cp_path] = o
560 app = werkzeug.wsgi.SharedDataMiddleware(self.dispatch, self.statics)
561 self.dispatch = DisableCacheMiddleware(app)
563 def find_handler(self, *l):
565 Tries to discover the controller handling the request for the path
566 specified by the provided parameters
568 :param l: path sections to a controller or controller method
569 :returns: a callable matching the path sections, or ``None``
570 :rtype: ``Controller | None``
573 ps = '/' + '/'.join(filter(None, l))
574 method_name = 'index'
576 c = controllers_path.get(ps)
578 method = getattr(c, method_name, None)
580 exposed = getattr(method, 'exposed', False)
581 if exposed == 'json':
582 _logger.debug("Dispatch json to %s %s %s", ps, c, method_name)
583 return lambda request: JsonRequest(request).dispatch(method)
584 elif exposed == 'http':
585 _logger.debug("Dispatch http to %s %s %s", ps, c, method_name)
586 return lambda request: HttpRequest(request).dispatch(method)
587 ps, _slash, method_name = ps.rpartition('/')
588 if not ps and method_name:
593 openerp.wsgi.register_wsgi_handler(Root())