1 # -*- coding: utf-8 -*-
2 #----------------------------------------------------------
3 # OpenERP Web HTTP layer
4 #----------------------------------------------------------
25 import werkzeug.contrib.sessions
26 import werkzeug.datastructures
27 import werkzeug.exceptions
29 import werkzeug.wrappers
36 _logger = logging.getLogger(__name__)
38 #----------------------------------------------------------
40 #----------------------------------------------------------
41 class WebRequest(object):
42 """ Parent class for all OpenERP Web request types, mostly deals with
43 initialization and setup of the request object (the dispatching itself has
44 to be handled by the subclasses)
46 :param request: a wrapped werkzeug Request object
47 :type request: :class:`werkzeug.wrappers.BaseRequest`
49 .. attribute:: httprequest
51 the original :class:`werkzeug.wrappers.Request` object provided to the
54 .. attribute:: httpsession
56 a :class:`~collections.Mapping` holding the HTTP session data for the
61 :class:`~collections.Mapping` of request parameters, not generally
62 useful as they're provided directly to the handler method as keyword
65 .. attribute:: session_id
67 opaque identifier for the :class:`session.OpenERPSession` instance of
70 .. attribute:: session
72 :class:`~session.OpenERPSession` instance for the current request
74 .. attribute:: context
76 :class:`~collections.Mapping` of context values for the current request
80 ``bool``, indicates whether the debug mode is active on the client
82 def __init__(self, request):
83 self.httprequest = request
84 self.httpresponse = None
85 self.httpsession = request.session
87 def init(self, params):
88 self.params = dict(params)
89 # OpenERP session setup
90 self.session_id = self.params.pop("session_id", None) or uuid.uuid4().hex
91 self.session = self.httpsession.get(self.session_id)
93 self.session = session.OpenERPSession()
94 self.httpsession[self.session_id] = self.session
96 # set db/uid trackers - they're cleaned up at the WSGI
97 # dispatching phase in openerp.service.wsgi_server.application
99 threading.current_thread().dbname = self.session._db
100 if self.session._uid:
101 threading.current_thread().uid = self.session._uid
103 self.context = self.params.pop('context', {})
104 self.debug = self.params.pop('debug', False) is not False
105 # Determine self.lang
106 lang = self.params.get('lang', None)
108 lang = self.context.get('lang')
110 lang = self.httprequest.cookies.get('lang')
112 lang = self.httprequest.accept_languages.best
115 # tranform 2 letters lang like 'en' into 5 letters like 'en_US'
116 lang = babel.core.LOCALE_ALIASES.get(lang, lang)
117 # we use _ as seprator where RFC2616 uses '-'
118 self.lang = lang.replace('-', '_')
120 def reject_nonliteral(dct):
123 "Non literal contexts can not be sent to the server anymore (%r)" % (dct,))
126 class JsonRequest(WebRequest):
127 """ JSON-RPC2 over HTTP.
131 --> {"jsonrpc": "2.0",
133 "params": {"session_id": "SID",
138 <-- {"jsonrpc": "2.0",
139 "result": { "res1": "val1" },
142 Request producing a error::
144 --> {"jsonrpc": "2.0",
146 "params": {"session_id": "SID",
151 <-- {"jsonrpc": "2.0",
153 "message": "End user error message.",
154 "data": {"code": "codestring",
155 "debug": "traceback" } },
159 def dispatch(self, method):
160 """ Calls the method asked for by the JSON-RPC2 or JSONP request
162 :param method: the method which received the request
164 :returns: an utf8 encoded JSON-RPC2 or JSONP reply
166 args = self.httprequest.args
167 jsonp = args.get('jsonp')
170 request_id = args.get('id')
172 if jsonp and self.httprequest.method == 'POST':
173 # jsonp 2 steps step1 POST: save call
175 self.session.jsonp_requests[request_id] = self.httprequest.form['r']
176 headers=[('Content-Type', 'text/plain; charset=utf-8')]
177 r = werkzeug.wrappers.Response(request_id, headers=headers)
179 elif jsonp and args.get('r'):
181 request = args.get('r')
182 elif jsonp and request_id:
183 # jsonp 2 steps step2 GET: run and return result
185 request = self.session.jsonp_requests.pop(request_id, "")
188 requestf = self.httprequest.stream
190 response = {"jsonrpc": "2.0" }
193 # Read POST content or POST Form Data named "request"
195 self.jsonrequest = simplejson.load(requestf, object_hook=reject_nonliteral)
197 self.jsonrequest = simplejson.loads(request, object_hook=reject_nonliteral)
198 self.init(self.jsonrequest.get("params", {}))
199 if _logger.isEnabledFor(logging.DEBUG):
200 _logger.debug("--> %s.%s\n%s", method.im_class.__name__, method.__name__, pprint.pformat(self.jsonrequest))
201 response['id'] = self.jsonrequest.get('id')
202 response["result"] = method(self, **self.params)
203 except session.AuthenticationError:
204 se = serialize_exception(e)
207 'message': "OpenERP Session Invalid",
211 se = serialize_exception(e)
214 'message': "OpenERP Server Error",
218 response["error"] = error
220 if _logger.isEnabledFor(logging.DEBUG):
221 _logger.debug("<--\n%s", pprint.pformat(response))
224 # If we use jsonp, that's mean we are called from another host
225 # Some browser (IE and Safari) do no allow third party cookies
226 # We need then to manage http sessions manually.
227 response['httpsessionid'] = self.httpsession.sid
228 mime = 'application/javascript'
229 body = "%s(%s);" % (jsonp, simplejson.dumps(response),)
231 mime = 'application/json'
232 body = simplejson.dumps(response)
234 r = werkzeug.wrappers.Response(body, headers=[('Content-Type', mime), ('Content-Length', len(body))])
237 def serialize_exception(e):
239 "name": type(e).__module__ + "." + type(e).__name__ if type(e).__module__ else type(e).__name__,
240 "debug": traceback.format_exc(),
241 "message": u"%s" % e,
242 "arguments": to_jsonable(e.args),
244 if isinstance(e, openerp.osv.osv.except_osv):
245 tmp["exception_type"] = "except_osv"
246 elif isinstance(e, openerp.exceptions.Warning):
247 tmp["exception_type"] = "warning"
248 elif isinstance(e, openerp.exceptions.AccessError):
249 tmp["exception_type"] = "access_error"
250 elif isinstance(e, openerp.exceptions.AccessDenied):
251 tmp["exception_type"] = "access_denied"
255 if isinstance(o, str) or isinstance(o,unicode) or isinstance(o, int) or isinstance(o, long) \
256 or isinstance(o, bool) or o is None or isinstance(o, float):
258 if isinstance(o, list) or isinstance(o, tuple):
259 return [to_jsonable(x) for x in o]
260 if isinstance(o, dict):
262 for k, v in o.items():
263 tmp[u"%s" % k] = to_jsonable(v)
268 """ Decorator marking the decorated method as being a handler for a
269 JSON-RPC request (the exact request path is specified via the
270 ``$(Controller._cp_path)/$methodname`` combination.
272 If the method is called, it will be provided with a :class:`JsonRequest`
273 instance and all ``params`` sent during the JSON-RPC request, apart from
274 the ``session_id``, ``context`` and ``debug`` keys (which are stripped out
280 class HttpRequest(WebRequest):
281 """ Regular GET/POST request
283 def dispatch(self, method):
284 params = dict(self.httprequest.args)
285 params.update(self.httprequest.form)
286 params.update(self.httprequest.files)
289 for key, value in self.httprequest.args.iteritems():
290 if isinstance(value, basestring) and len(value) < 1024:
293 akw[key] = type(value)
294 _logger.debug("%s --> %s.%s %r", self.httprequest.method, method.im_class.__name__, method.__name__, akw)
296 r = method(self, **self.params)
298 _logger.exception("An exception occured during an http request")
299 se = serialize_exception(e)
302 'message': "OpenERP Server Error",
305 r = werkzeug.exceptions.InternalServerError(cgi.escape(simplejson.dumps(error)))
307 if isinstance(r, (werkzeug.wrappers.BaseResponse, werkzeug.exceptions.HTTPException)):
308 _logger.debug('<-- %s', r)
310 _logger.debug("<-- size: %s", len(r))
313 def make_response(self, data, headers=None, cookies=None):
314 """ Helper for non-HTML responses, or HTML responses with custom
315 response headers or cookies.
317 While handlers can just return the HTML markup of a page they want to
318 send as a string if non-HTML data is returned they need to create a
319 complete response object, or the returned data will not be correctly
320 interpreted by the clients.
322 :param basestring data: response body
323 :param headers: HTTP headers to set on the response
324 :type headers: ``[(name, value)]``
325 :param collections.Mapping cookies: cookies to set on the client
327 response = werkzeug.wrappers.Response(data, headers=headers)
329 for k, v in cookies.iteritems():
330 response.set_cookie(k, v)
333 def not_found(self, description=None):
334 """ Helper for 404 response, return its result from the method
336 return werkzeug.exceptions.NotFound(description)
339 """ Decorator marking the decorated method as being a handler for a
340 normal HTTP request (the exact request path is specified via the
341 ``$(Controller._cp_path)/$methodname`` combination.
343 If the method is called, it will be provided with a :class:`HttpRequest`
344 instance and all ``params`` sent during the request (``GET`` and ``POST``
345 merged in the same dictionary), apart from the ``session_id``, ``context``
346 and ``debug`` keys (which are stripped out beforehand)
351 #----------------------------------------------------------
352 # Controller registration with a metaclass
353 #----------------------------------------------------------
356 controllers_class = []
357 controllers_object = {}
358 controllers_path = {}
360 class ControllerType(type):
361 def __init__(cls, name, bases, attrs):
362 super(ControllerType, cls).__init__(name, bases, attrs)
363 controllers_class.append(("%s.%s" % (cls.__module__, cls.__name__), cls))
365 class Controller(object):
366 __metaclass__ = ControllerType
368 #----------------------------------------------------------
369 # Session context manager
370 #----------------------------------------------------------
371 @contextlib.contextmanager
372 def session_context(request, session_store, session_lock, sid):
375 request.session = session_store.get(sid)
377 request.session = session_store.new()
379 yield request.session
381 # Remove all OpenERPSession instances with no uid, they're generated
382 # either by login process or by HTTP requests without an OpenERP
383 # session id, and are generally noise
384 removed_sessions = set()
385 for key, value in request.session.items():
386 if not isinstance(value, session.OpenERPSession):
388 if getattr(value, '_suicide', False) or (
390 and not value.jsonp_requests
391 # FIXME do not use a fixed value
392 and value._creation_time + (60*5) < time.time()):
393 _logger.debug('remove session %s', key)
394 removed_sessions.add(key)
395 del request.session[key]
399 # Re-load sessions from storage and merge non-literal
400 # contexts and domains (they're indexed by hash of the
401 # content so conflicts should auto-resolve), otherwise if
402 # two requests alter those concurrently the last to finish
403 # will overwrite the previous one, leading to loss of data
404 # (a non-literal is lost even though it was sent to the
405 # client and client errors)
407 # note that domains_store and contexts_store are append-only (we
408 # only ever add items to them), so we can just update one with the
409 # other to get the right result, if we want to merge the
410 # ``context`` dict we'll need something smarter
411 in_store = session_store.get(sid)
412 for k, v in request.session.iteritems():
413 stored = in_store.get(k)
414 if stored and isinstance(v, session.OpenERPSession):
415 if hasattr(v, 'contexts_store'):
417 if hasattr(v, 'domains_store'):
419 if not hasattr(v, 'jsonp_requests'):
420 v.jsonp_requests = {}
421 v.jsonp_requests.update(getattr(
422 stored, 'jsonp_requests', {}))
425 for k, v in in_store.iteritems():
426 if k not in request.session and k not in removed_sessions:
427 request.session[k] = v
429 session_store.save(request.session)
431 def session_gc(session_store):
432 if random.random() < 0.001:
433 # we keep session one week
434 last_week = time.time() - 60*60*24*7
435 for fname in os.listdir(session_store.path):
436 path = os.path.join(session_store.path, fname)
438 if os.path.getmtime(path) < last_week:
443 #----------------------------------------------------------
445 #----------------------------------------------------------
446 # Add potentially missing (older ubuntu) font mime types
447 mimetypes.add_type('application/font-woff', '.woff')
448 mimetypes.add_type('application/vnd.ms-fontobject', '.eot')
449 mimetypes.add_type('application/x-font-ttf', '.ttf')
451 class DisableCacheMiddleware(object):
452 def __init__(self, app):
454 def __call__(self, environ, start_response):
455 def start_wrapped(status, headers):
456 referer = environ.get('HTTP_REFERER', '')
457 parsed = urlparse.urlparse(referer)
458 debug = parsed.query.count('debug') >= 1
461 unwanted_keys = ['Last-Modified']
463 new_headers = [('Cache-Control', 'no-cache')]
464 unwanted_keys += ['Expires', 'Etag', 'Cache-Control']
467 if k not in unwanted_keys:
468 new_headers.append((k, v))
470 start_response(status, new_headers)
471 return self.app(environ, start_wrapped)
475 username = getpass.getuser()
478 path = os.path.join(tempfile.gettempdir(), "oe-sessions-" + username)
479 if not os.path.exists(path):
484 """Root WSGI application for the OpenERP Web Client.
492 # Setup http sessions
493 path = session_path()
494 self.session_store = werkzeug.contrib.sessions.FilesystemSessionStore(path)
495 self.session_lock = threading.Lock()
496 _logger.debug('HTTP sessions stored in: %s', path)
498 def __call__(self, environ, start_response):
499 """ Handle a WSGI request
501 return self.dispatch(environ, start_response)
503 def dispatch(self, environ, start_response):
505 Performs the actual WSGI dispatching for the application, may be
506 wrapped during the initialization of the object.
508 Call the object directly.
510 request = werkzeug.wrappers.Request(environ)
511 request.parameter_storage_class = werkzeug.datastructures.ImmutableDict
514 handler = self.find_handler(*(request.path.split('/')[1:]))
517 response = werkzeug.exceptions.NotFound()
519 sid = request.cookies.get('sid')
521 sid = request.args.get('sid')
523 session_gc(self.session_store)
525 with session_context(request, self.session_store, self.session_lock, sid) as session:
526 result = handler(request)
528 if isinstance(result, basestring):
529 headers=[('Content-Type', 'text/html; charset=utf-8'), ('Content-Length', len(result))]
530 response = werkzeug.wrappers.Response(result, headers=headers)
534 if hasattr(response, 'set_cookie'):
535 response.set_cookie('sid', session.sid)
537 return response(environ, start_response)
539 def load_addons(self):
540 """ Load all addons from addons patch containg static files and
541 controllers and configure them. """
543 for addons_path in openerp.modules.module.ad_paths:
544 for module in sorted(os.listdir(addons_path)):
545 if module not in addons_module:
546 manifest_path = os.path.join(addons_path, module, '__openerp__.py')
547 path_static = os.path.join(addons_path, module, 'static')
548 if os.path.isfile(manifest_path) and os.path.isdir(path_static):
549 manifest = ast.literal_eval(open(manifest_path).read())
550 manifest['addons_path'] = addons_path
551 _logger.debug("Loading %s", module)
552 if 'openerp.addons' in sys.modules:
553 m = __import__('openerp.addons.' + module)
555 m = __import__(module)
556 addons_module[module] = m
557 addons_manifest[module] = manifest
558 self.statics['/%s/static' % module] = path_static
560 for k, v in controllers_class:
561 if k not in controllers_object:
563 controllers_object[k] = o
564 if hasattr(o, '_cp_path'):
565 controllers_path[o._cp_path] = o
567 app = werkzeug.wsgi.SharedDataMiddleware(self.dispatch, self.statics)
568 self.dispatch = DisableCacheMiddleware(app)
570 def find_handler(self, *l):
572 Tries to discover the controller handling the request for the path
573 specified by the provided parameters
575 :param l: path sections to a controller or controller method
576 :returns: a callable matching the path sections, or ``None``
577 :rtype: ``Controller | None``
580 ps = '/' + '/'.join(filter(None, l))
581 method_name = 'index'
583 c = controllers_path.get(ps)
585 method = getattr(c, method_name, None)
587 exposed = getattr(method, 'exposed', False)
588 if exposed == 'json':
589 _logger.debug("Dispatch json to %s %s %s", ps, c, method_name)
590 return lambda request: JsonRequest(request).dispatch(method)
591 elif exposed == 'http':
592 _logger.debug("Dispatch http to %s %s %s", ps, c, method_name)
593 return lambda request: HttpRequest(request).dispatch(method)
594 ps, _slash, method_name = ps.rpartition('/')
595 if not ps and method_name:
600 openerp.wsgi.register_wsgi_handler(Root())