1 # -*- coding: utf-8 -*-
2 #----------------------------------------------------------
3 # OpenERP Web HTTP layer
4 #----------------------------------------------------------
27 import werkzeug.contrib.sessions
28 import werkzeug.datastructures
29 import werkzeug.exceptions
31 import werkzeug.wrappers
38 _logger = logging.getLogger(__name__)
40 #----------------------------------------------------------
42 #----------------------------------------------------------
43 class WebRequest(object):
44 """ Parent class for all OpenERP Web request types, mostly deals with
45 initialization and setup of the request object (the dispatching itself has
46 to be handled by the subclasses)
48 :param request: a wrapped werkzeug Request object
49 :type request: :class:`werkzeug.wrappers.BaseRequest`
51 .. attribute:: httprequest
53 the original :class:`werkzeug.wrappers.Request` object provided to the
56 .. attribute:: httpsession
58 a :class:`~collections.Mapping` holding the HTTP session data for the
63 :class:`~collections.Mapping` of request parameters, not generally
64 useful as they're provided directly to the handler method as keyword
67 .. attribute:: session_id
69 opaque identifier for the :class:`session.OpenERPSession` instance of
72 .. attribute:: session
74 :class:`~session.OpenERPSession` instance for the current request
76 .. attribute:: context
78 :class:`~collections.Mapping` of context values for the current request
82 ``bool``, indicates whether the debug mode is active on the client
84 def __init__(self, request):
85 self.httprequest = request
86 self.httpresponse = None
87 self.httpsession = request.session
89 def init(self, params):
90 self.params = dict(params)
91 # OpenERP session setup
92 self.session_id = self.params.pop("session_id", None) or uuid.uuid4().hex
93 self.session = self.httpsession.get(self.session_id)
95 self.session = session.OpenERPSession()
96 self.httpsession[self.session_id] = self.session
98 # set db/uid trackers - they're cleaned up at the WSGI
99 # dispatching phase in openerp.service.wsgi_server.application
101 threading.current_thread().dbname = self.session._db
102 if self.session._uid:
103 threading.current_thread().uid = self.session._uid
105 self.context = self.params.pop('context', {})
106 self.debug = self.params.pop('debug', False) is not False
107 # Determine self.lang
108 lang = self.params.get('lang', None)
110 lang = self.context.get('lang')
112 lang = self.httprequest.cookies.get('lang')
114 lang = self.httprequest.accept_languages.best
117 # tranform 2 letters lang like 'en' into 5 letters like 'en_US'
118 lang = babel.core.LOCALE_ALIASES.get(lang, lang)
119 # we use _ as seprator where RFC2616 uses '-'
120 self.lang = lang.replace('-', '_')
122 def reject_nonliteral(dct):
125 "Non literal contexts can not be sent to the server anymore (%r)" % (dct,))
128 class JsonRequest(WebRequest):
129 """ JSON-RPC2 over HTTP.
133 --> {"jsonrpc": "2.0",
135 "params": {"session_id": "SID",
140 <-- {"jsonrpc": "2.0",
141 "result": { "res1": "val1" },
144 Request producing a error::
146 --> {"jsonrpc": "2.0",
148 "params": {"session_id": "SID",
153 <-- {"jsonrpc": "2.0",
155 "message": "End user error message.",
156 "data": {"code": "codestring",
157 "debug": "traceback" } },
161 def dispatch(self, method):
162 """ Calls the method asked for by the JSON-RPC2 or JSONP request
164 :param method: the method which received the request
166 :returns: an utf8 encoded JSON-RPC2 or JSONP reply
168 args = self.httprequest.args
169 jsonp = args.get('jsonp')
172 request_id = args.get('id')
174 if jsonp and self.httprequest.method == 'POST':
175 # jsonp 2 steps step1 POST: save call
177 self.session.jsonp_requests[request_id] = self.httprequest.form['r']
178 headers=[('Content-Type', 'text/plain; charset=utf-8')]
179 r = werkzeug.wrappers.Response(request_id, headers=headers)
181 elif jsonp and args.get('r'):
183 request = args.get('r')
184 elif jsonp and request_id:
185 # jsonp 2 steps step2 GET: run and return result
187 request = self.session.jsonp_requests.pop(request_id, "")
190 requestf = self.httprequest.stream
192 response = {"jsonrpc": "2.0" }
195 # Read POST content or POST Form Data named "request"
197 self.jsonrequest = simplejson.load(requestf, object_hook=reject_nonliteral)
199 self.jsonrequest = simplejson.loads(request, object_hook=reject_nonliteral)
200 self.init(self.jsonrequest.get("params", {}))
201 if _logger.isEnabledFor(logging.DEBUG):
202 _logger.debug("--> %s.%s\n%s", method.im_class.__name__, method.__name__, pprint.pformat(self.jsonrequest))
203 response['id'] = self.jsonrequest.get('id')
204 response["result"] = method(self, **self.params)
205 except session.AuthenticationError:
208 'message': "OpenERP Session Invalid",
210 'type': 'session_invalid',
211 'debug': traceback.format_exc()
214 except xmlrpclib.Fault, e:
217 'message': "OpenERP Server Error",
219 'type': 'server_exception',
220 'fault_code': e.faultCode,
221 'debug': "Client %s\nServer %s" % (
222 "".join(traceback.format_exception("", None, sys.exc_traceback)), e.faultString)
226 logging.getLogger(__name__ + '.JSONRequest.dispatch').exception\
227 ("An error occured while handling a json request")
230 'message': "OpenERP WebClient Error",
232 'type': 'client_exception',
233 'debug': "Client %s" % traceback.format_exc()
237 response["error"] = error
239 if _logger.isEnabledFor(logging.DEBUG):
240 _logger.debug("<--\n%s", pprint.pformat(response))
243 # If we use jsonp, that's mean we are called from another host
244 # Some browser (IE and Safari) do no allow third party cookies
245 # We need then to manage http sessions manually.
246 response['httpsessionid'] = self.httpsession.sid
247 mime = 'application/javascript'
248 body = "%s(%s);" % (jsonp, simplejson.dumps(response),)
250 mime = 'application/json'
251 body = simplejson.dumps(response)
253 r = werkzeug.wrappers.Response(body, headers=[('Content-Type', mime), ('Content-Length', len(body))])
257 """ Decorator marking the decorated method as being a handler for a
258 JSON-RPC request (the exact request path is specified via the
259 ``$(Controller._cp_path)/$methodname`` combination.
261 If the method is called, it will be provided with a :class:`JsonRequest`
262 instance and all ``params`` sent during the JSON-RPC request, apart from
263 the ``session_id``, ``context`` and ``debug`` keys (which are stripped out
269 class HttpRequest(WebRequest):
270 """ Regular GET/POST request
272 def dispatch(self, method):
273 params = dict(self.httprequest.args)
274 params.update(self.httprequest.form)
275 params.update(self.httprequest.files)
278 for key, value in self.httprequest.args.iteritems():
279 if isinstance(value, basestring) and len(value) < 1024:
282 akw[key] = type(value)
283 _logger.debug("%s --> %s.%s %r", self.httprequest.method, method.im_class.__name__, method.__name__, akw)
285 r = method(self, **self.params)
286 except xmlrpclib.Fault, e:
287 r = werkzeug.exceptions.InternalServerError(cgi.escape(simplejson.dumps({
289 'message': "OpenERP Server Error",
291 'type': 'server_exception',
292 'fault_code': e.faultCode,
293 'debug': "Server %s\nClient %s" % (
294 e.faultString, traceback.format_exc())
298 logging.getLogger(__name__ + '.HttpRequest.dispatch').exception(
299 "An error occurred while handling a json request")
300 r = werkzeug.exceptions.InternalServerError(cgi.escape(simplejson.dumps({
302 'message': "OpenERP WebClient Error",
304 'type': 'client_exception',
305 'debug': "Client %s" % traceback.format_exc()
309 if isinstance(r, (werkzeug.wrappers.BaseResponse, werkzeug.exceptions.HTTPException)):
310 _logger.debug('<-- %s', r)
312 _logger.debug("<-- size: %s", len(r))
315 def make_response(self, data, headers=None, cookies=None):
316 """ Helper for non-HTML responses, or HTML responses with custom
317 response headers or cookies.
319 While handlers can just return the HTML markup of a page they want to
320 send as a string if non-HTML data is returned they need to create a
321 complete response object, or the returned data will not be correctly
322 interpreted by the clients.
324 :param basestring data: response body
325 :param headers: HTTP headers to set on the response
326 :type headers: ``[(name, value)]``
327 :param collections.Mapping cookies: cookies to set on the client
329 response = werkzeug.wrappers.Response(data, headers=headers)
331 for k, v in cookies.iteritems():
332 response.set_cookie(k, v)
335 def not_found(self, description=None):
336 """ Helper for 404 response, return its result from the method
338 return werkzeug.exceptions.NotFound(description)
341 """ Decorator marking the decorated method as being a handler for a
342 normal HTTP request (the exact request path is specified via the
343 ``$(Controller._cp_path)/$methodname`` combination.
345 If the method is called, it will be provided with a :class:`HttpRequest`
346 instance and all ``params`` sent during the request (``GET`` and ``POST``
347 merged in the same dictionary), apart from the ``session_id``, ``context``
348 and ``debug`` keys (which are stripped out beforehand)
353 #----------------------------------------------------------
354 # Controller registration with a metaclass
355 #----------------------------------------------------------
358 controllers_class = []
359 controllers_class_path = {}
360 controllers_object = {}
361 controllers_object_path = {}
362 controllers_path = {}
364 class ControllerType(type):
365 def __init__(cls, name, bases, attrs):
366 super(ControllerType, cls).__init__(name, bases, attrs)
367 name_class = ("%s.%s" % (cls.__module__, cls.__name__), cls)
368 controllers_class.append(name_class)
369 path = attrs.get('_cp_path')
370 if path not in controllers_class_path:
371 controllers_class_path[path] = name_class
373 class Controller(object):
374 __metaclass__ = ControllerType
376 def __new__(cls, *args, **kwargs):
377 subclasses = [c for c in cls.__subclasses__() if c._cp_path == cls._cp_path]
379 name = "%s (extended by %s)" % (cls.__name__, ', '.join(sub.__name__ for sub in subclasses))
380 cls = type(name, tuple(reversed(subclasses)), {})
382 return object.__new__(cls)
384 #----------------------------------------------------------
385 # Session context manager
386 #----------------------------------------------------------
387 @contextlib.contextmanager
388 def session_context(request, session_store, session_lock, sid):
391 request.session = session_store.get(sid)
393 request.session = session_store.new()
395 yield request.session
397 # Remove all OpenERPSession instances with no uid, they're generated
398 # either by login process or by HTTP requests without an OpenERP
399 # session id, and are generally noise
400 removed_sessions = set()
401 for key, value in request.session.items():
402 if not isinstance(value, session.OpenERPSession):
404 if getattr(value, '_suicide', False) or (
406 and not value.jsonp_requests
407 # FIXME do not use a fixed value
408 and value._creation_time + (60*5) < time.time()):
409 _logger.debug('remove session %s', key)
410 removed_sessions.add(key)
411 del request.session[key]
415 # Re-load sessions from storage and merge non-literal
416 # contexts and domains (they're indexed by hash of the
417 # content so conflicts should auto-resolve), otherwise if
418 # two requests alter those concurrently the last to finish
419 # will overwrite the previous one, leading to loss of data
420 # (a non-literal is lost even though it was sent to the
421 # client and client errors)
423 # note that domains_store and contexts_store are append-only (we
424 # only ever add items to them), so we can just update one with the
425 # other to get the right result, if we want to merge the
426 # ``context`` dict we'll need something smarter
427 in_store = session_store.get(sid)
428 for k, v in request.session.iteritems():
429 stored = in_store.get(k)
430 if stored and isinstance(v, session.OpenERPSession):
431 if hasattr(v, 'contexts_store'):
433 if hasattr(v, 'domains_store'):
435 if not hasattr(v, 'jsonp_requests'):
436 v.jsonp_requests = {}
437 v.jsonp_requests.update(getattr(
438 stored, 'jsonp_requests', {}))
441 for k, v in in_store.iteritems():
442 if k not in request.session and k not in removed_sessions:
443 request.session[k] = v
445 session_store.save(request.session)
447 def session_gc(session_store):
448 if random.random() < 0.001:
449 # we keep session one week
450 last_week = time.time() - 60*60*24*7
451 for fname in os.listdir(session_store.path):
452 path = os.path.join(session_store.path, fname)
454 if os.path.getmtime(path) < last_week:
459 #----------------------------------------------------------
461 #----------------------------------------------------------
462 # Add potentially missing (older ubuntu) font mime types
463 mimetypes.add_type('application/font-woff', '.woff')
464 mimetypes.add_type('application/vnd.ms-fontobject', '.eot')
465 mimetypes.add_type('application/x-font-ttf', '.ttf')
467 class DisableCacheMiddleware(object):
468 def __init__(self, app):
470 def __call__(self, environ, start_response):
471 def start_wrapped(status, headers):
472 referer = environ.get('HTTP_REFERER', '')
473 parsed = urlparse.urlparse(referer)
474 debug = parsed.query.count('debug') >= 1
477 unwanted_keys = ['Last-Modified']
479 new_headers = [('Cache-Control', 'no-cache')]
480 unwanted_keys += ['Expires', 'Etag', 'Cache-Control']
483 if k not in unwanted_keys:
484 new_headers.append((k, v))
486 start_response(status, new_headers)
487 return self.app(environ, start_wrapped)
492 username = pwd.getpwuid(os.geteuid()).pw_name
495 username = getpass.getuser()
498 path = os.path.join(tempfile.gettempdir(), "oe-sessions-" + username)
501 except OSError as exc:
502 if exc.errno == errno.EEXIST:
503 # directory exists: ensure it has the correct permissions
504 # this will fail if the directory is not owned by the current user
511 """Root WSGI application for the OpenERP Web Client.
519 # Setup http sessions
520 path = session_path()
521 self.session_store = werkzeug.contrib.sessions.FilesystemSessionStore(path)
522 self.session_lock = threading.Lock()
523 _logger.debug('HTTP sessions stored in: %s', path)
525 def __call__(self, environ, start_response):
526 """ Handle a WSGI request
528 return self.dispatch(environ, start_response)
530 def dispatch(self, environ, start_response):
532 Performs the actual WSGI dispatching for the application, may be
533 wrapped during the initialization of the object.
535 Call the object directly.
537 request = werkzeug.wrappers.Request(environ)
538 request.parameter_storage_class = werkzeug.datastructures.ImmutableDict
541 handler = self.find_handler(*(request.path.split('/')[1:]))
544 response = werkzeug.exceptions.NotFound()
546 sid = request.cookies.get('sid')
548 sid = request.args.get('sid')
550 session_gc(self.session_store)
552 with session_context(request, self.session_store, self.session_lock, sid) as session:
553 result = handler(request)
555 if isinstance(result, basestring):
556 headers=[('Content-Type', 'text/html; charset=utf-8'), ('Content-Length', len(result))]
557 response = werkzeug.wrappers.Response(result, headers=headers)
561 if hasattr(response, 'set_cookie'):
562 response.set_cookie('sid', session.sid)
564 return response(environ, start_response)
566 def load_addons(self):
567 """ Load all addons from addons patch containg static files and
568 controllers and configure them. """
570 for addons_path in openerp.modules.module.ad_paths:
571 for module in sorted(os.listdir(str(addons_path))):
572 if module not in addons_module:
573 manifest_path = os.path.join(addons_path, module, '__openerp__.py')
574 path_static = os.path.join(addons_path, module, 'static')
575 if os.path.isfile(manifest_path) and os.path.isdir(path_static):
576 manifest = ast.literal_eval(open(manifest_path).read())
577 manifest['addons_path'] = addons_path
578 _logger.debug("Loading %s", module)
579 if 'openerp.addons' in sys.modules:
580 m = __import__('openerp.addons.' + module)
582 m = __import__(module)
583 addons_module[module] = m
584 addons_manifest[module] = manifest
585 self.statics['/%s/static' % module] = path_static
587 for k, v in controllers_class_path.items():
588 if k not in controllers_object_path and hasattr(v[1], '_cp_path'):
590 controllers_object[v[0]] = o
591 controllers_object_path[k] = o
592 if hasattr(o, '_cp_path'):
593 controllers_path[o._cp_path] = o
595 app = werkzeug.wsgi.SharedDataMiddleware(self.dispatch, self.statics)
596 self.dispatch = DisableCacheMiddleware(app)
598 def find_handler(self, *l):
600 Tries to discover the controller handling the request for the path
601 specified by the provided parameters
603 :param l: path sections to a controller or controller method
604 :returns: a callable matching the path sections, or ``None``
605 :rtype: ``Controller | None``
608 ps = '/' + '/'.join(filter(None, l))
609 method_name = 'index'
611 c = controllers_path.get(ps)
613 method = getattr(c, method_name, None)
615 exposed = getattr(method, 'exposed', False)
616 if exposed == 'json':
617 _logger.debug("Dispatch json to %s %s %s", ps, c, method_name)
618 return lambda request: JsonRequest(request).dispatch(method)
619 elif exposed == 'http':
620 _logger.debug("Dispatch http to %s %s %s", ps, c, method_name)
621 return lambda request: HttpRequest(request).dispatch(method)
622 ps, _slash, method_name = ps.rpartition('/')
623 if not ps and method_name:
628 openerp.wsgi.register_wsgi_handler(Root())