1 # -*- coding: utf-8 -*-
2 #----------------------------------------------------------
3 # OpenERP Web HTTP layer
4 #----------------------------------------------------------
22 import werkzeug.contrib.sessions
23 import werkzeug.datastructures
24 import werkzeug.exceptions
26 import werkzeug.wrappers
29 from . import nonliterals
31 from . import openerplib
34 __all__ = ['Root', 'jsonrequest', 'httprequest', 'Controller',
35 'WebRequest', 'JsonRequest', 'HttpRequest']
37 _logger = logging.getLogger(__name__)
39 #----------------------------------------------------------
40 # OpenERP Web RequestHandler
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`
49 :param config: configuration object
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 config parameter provided to the request object
67 :class:`~collections.Mapping` of request parameters, not generally
68 useful as they're provided directly to the handler method as keyword
71 .. attribute:: session_id
73 opaque identifier for the :class:`session.OpenERPSession` instance of
76 .. attribute:: session
78 :class:`~session.OpenERPSession` instance for the current request
80 .. attribute:: context
82 :class:`~collections.Mapping` of context values for the current request
86 ``bool``, indicates whether the debug mode is active on the client
88 def __init__(self, request, config):
89 self.httprequest = request
90 self.httpresponse = None
91 self.httpsession = request.session
94 def init(self, params):
95 self.params = dict(params)
96 # OpenERP session setup
97 self.session_id = self.params.pop("session_id", None) or uuid.uuid4().hex
98 self.session = self.httpsession.get(self.session_id)
100 self.httpsession[self.session_id] = self.session = session.OpenERPSession()
101 self.session.config = self.config
102 self.context = self.params.pop('context', None)
103 self.debug = self.params.pop('debug', False) != False
105 class JsonRequest(WebRequest):
106 """ JSON-RPC2 over HTTP.
110 --> {"jsonrpc": "2.0",
112 "params": {"session_id": "SID",
117 <-- {"jsonrpc": "2.0",
118 "result": { "res1": "val1" },
121 Request producing a error::
123 --> {"jsonrpc": "2.0",
125 "params": {"session_id": "SID",
130 <-- {"jsonrpc": "2.0",
132 "message": "End user error message.",
133 "data": {"code": "codestring",
134 "debug": "traceback" } },
138 def dispatch(self, controller, method):
139 """ Calls the method asked for by the JSON-RPC2 or JSONP request
141 :param controller: the instance of the controller which received the request
142 :param method: the method which received the request
144 :returns: an utf8 encoded JSON-RPC2 or JSONP reply
146 args = self.httprequest.args
147 jsonp = args.get('jsonp')
150 request_id = args.get('id')
152 if jsonp and self.httprequest.method == 'POST':
153 # jsonp 2 steps step1 POST: save call
155 self.session.jsonp_requests[request_id] = self.httprequest.form['r']
156 headers=[('Content-Type', 'text/plain; charset=utf-8')]
157 r = werkzeug.wrappers.Response(request_id, headers=headers)
159 elif jsonp and args.get('r'):
161 request = args.get('r')
162 elif jsonp and request_id:
163 # jsonp 2 steps step2 GET: run and return result
165 request = self.session.jsonp_requests.pop(request_id, "")
168 requestf = self.httprequest.stream
170 response = {"jsonrpc": "2.0" }
173 # Read POST content or POST Form Data named "request"
175 self.jsonrequest = simplejson.load(requestf, object_hook=nonliterals.non_literal_decoder)
177 self.jsonrequest = simplejson.loads(request, object_hook=nonliterals.non_literal_decoder)
178 self.init(self.jsonrequest.get("params", {}))
179 if _logger.isEnabledFor(logging.DEBUG):
180 _logger.debug("--> %s.%s\n%s", controller.__class__.__name__, method.__name__, pprint.pformat(self.jsonrequest))
181 response['id'] = self.jsonrequest.get('id')
182 response["result"] = method(controller, self, **self.params)
183 except openerplib.AuthenticationError:
186 'message': "OpenERP Session Invalid",
188 'type': 'session_invalid',
189 'debug': traceback.format_exc()
192 except xmlrpclib.Fault, e:
195 'message': "OpenERP Server Error",
197 'type': 'server_exception',
198 'fault_code': e.faultCode,
199 'debug': "Client %s\nServer %s" % (
200 "".join(traceback.format_exception("", None, sys.exc_traceback)), e.faultString)
204 logging.getLogger(__name__ + '.JSONRequest.dispatch').exception\
205 ("An error occured while handling a json request")
208 'message': "OpenERP WebClient Error",
210 'type': 'client_exception',
211 'debug': "Client %s" % traceback.format_exc()
215 response["error"] = error
217 if _logger.isEnabledFor(logging.DEBUG):
218 _logger.debug("<--\n%s", pprint.pformat(response))
221 mime = 'application/javascript'
222 body = "%s(%s);" % (jsonp, simplejson.dumps(response, cls=nonliterals.NonLiteralEncoder),)
224 mime = 'application/json'
225 body = simplejson.dumps(response, cls=nonliterals.NonLiteralEncoder)
227 r = werkzeug.wrappers.Response(body, headers=[('Content-Type', mime), ('Content-Length', len(body))])
231 """ Decorator marking the decorated method as being a handler for a
232 JSON-RPC request (the exact request path is specified via the
233 ``$(Controller._cp_path)/$methodname`` combination.
235 If the method is called, it will be provided with a :class:`JsonRequest`
236 instance and all ``params`` sent during the JSON-RPC request, apart from
237 the ``session_id``, ``context`` and ``debug`` keys (which are stripped out
241 def json_handler(controller, request, config):
242 return JsonRequest(request, config).dispatch(controller, f)
243 json_handler.exposed = True
246 class HttpRequest(WebRequest):
247 """ Regular GET/POST request
249 def dispatch(self, controller, method):
250 params = dict(self.httprequest.args)
251 params.update(self.httprequest.form)
252 params.update(self.httprequest.files)
255 for key, value in self.httprequest.args.iteritems():
256 if isinstance(value, basestring) and len(value) < 1024:
259 akw[key] = type(value)
260 _logger.debug("%s --> %s.%s %r", self.httprequest.method, controller.__class__.__name__, method.__name__, akw)
262 r = method(controller, self, **self.params)
263 except xmlrpclib.Fault, e:
264 r = werkzeug.exceptions.InternalServerError(cgi.escape(simplejson.dumps({
266 'message': "OpenERP Server Error",
268 'type': 'server_exception',
269 'fault_code': e.faultCode,
270 'debug': "Server %s\nClient %s" % (
271 e.faultString, traceback.format_exc())
275 logging.getLogger(__name__ + '.HttpRequest.dispatch').exception(
276 "An error occurred while handling a json request")
277 r = werkzeug.exceptions.InternalServerError(cgi.escape(simplejson.dumps({
279 'message': "OpenERP WebClient Error",
281 'type': 'client_exception',
282 'debug': "Client %s" % traceback.format_exc()
286 if isinstance(r, (werkzeug.wrappers.BaseResponse, werkzeug.exceptions.HTTPException)):
287 _logger.debug('<-- %s', r)
289 _logger.debug("<-- size: %s", len(r))
292 def make_response(self, data, headers=None, cookies=None):
293 """ Helper for non-HTML responses, or HTML responses with custom
294 response headers or cookies.
296 While handlers can just return the HTML markup of a page they want to
297 send as a string if non-HTML data is returned they need to create a
298 complete response object, or the returned data will not be correctly
299 interpreted by the clients.
301 :param basestring data: response body
302 :param headers: HTTP headers to set on the response
303 :type headers: ``[(name, value)]``
304 :param collections.Mapping cookies: cookies to set on the client
306 response = werkzeug.wrappers.Response(data, headers=headers)
308 for k, v in cookies.iteritems():
309 response.set_cookie(k, v)
312 def not_found(self, description=None):
313 """ Helper for 404 response, return its result from the method
315 return werkzeug.exceptions.NotFound(description)
318 """ Decorator marking the decorated method as being a handler for a
319 normal HTTP request (the exact request path is specified via the
320 ``$(Controller._cp_path)/$methodname`` combination.
322 If the method is called, it will be provided with a :class:`HttpRequest`
323 instance and all ``params`` sent during the request (``GET`` and ``POST``
324 merged in the same dictionary), apart from the ``session_id``, ``context``
325 and ``debug`` keys (which are stripped out beforehand)
328 def http_handler(controller, request, config):
329 return HttpRequest(request, config).dispatch(controller, f)
330 http_handler.exposed = True
333 #----------------------------------------------------------
334 # OpenERP Web Controller registration with a metaclass
335 #----------------------------------------------------------
338 controllers_class = []
339 controllers_object = {}
340 controllers_path = {}
342 class ControllerType(type):
343 def __init__(cls, name, bases, attrs):
344 super(ControllerType, cls).__init__(name, bases, attrs)
345 controllers_class.append(("%s.%s" % (cls.__module__, cls.__name__), cls))
347 class Controller(object):
348 __metaclass__ = ControllerType
350 #----------------------------------------------------------
351 # OpenERP Web Session context manager
352 #----------------------------------------------------------
355 @contextlib.contextmanager
356 def session_context(request, storage_path, session_cookie='httpsessionid'):
357 session_store, session_lock = STORES.get(storage_path, (None, None))
358 if not session_store:
359 session_store = werkzeug.contrib.sessions.FilesystemSessionStore( storage_path)
360 session_lock = threading.Lock()
361 STORES[storage_path] = session_store, session_lock
363 sid = request.cookies.get(session_cookie)
366 request.session = session_store.get(sid)
368 request.session = session_store.new()
371 yield request.session
373 # Remove all OpenERPSession instances with no uid, they're generated
374 # either by login process or by HTTP requests without an OpenERP
375 # session id, and are generally noise
376 removed_sessions = set()
377 for key, value in request.session.items():
378 if not isinstance(value, session.OpenERPSession):
380 if getattr(value, '_suicide', False) or (
382 and not value.jsonp_requests
383 # FIXME do not use a fixed value
384 and value._creation_time + (60*5) < time.time()):
385 _logger.debug('remove session %s', key)
386 removed_sessions.add(key)
387 del request.session[key]
391 # Re-load sessions from storage and merge non-literal
392 # contexts and domains (they're indexed by hash of the
393 # content so conflicts should auto-resolve), otherwise if
394 # two requests alter those concurrently the last to finish
395 # will overwrite the previous one, leading to loss of data
396 # (a non-literal is lost even though it was sent to the
397 # client and client errors)
399 # note that domains_store and contexts_store are append-only (we
400 # only ever add items to them), so we can just update one with the
401 # other to get the right result, if we want to merge the
402 # ``context`` dict we'll need something smarter
403 in_store = session_store.get(sid)
404 for k, v in request.session.iteritems():
405 stored = in_store.get(k)
406 if stored and isinstance(v, session.OpenERPSession):
407 v.contexts_store.update(stored.contexts_store)
408 v.domains_store.update(stored.domains_store)
409 if not hasattr(v, 'jsonp_requests'):
410 v.jsonp_requests = {}
411 v.jsonp_requests.update(getattr(
412 stored, 'jsonp_requests', {}))
415 for k, v in in_store.iteritems():
416 if k not in request.session and k not in removed_sessions:
417 request.session[k] = v
419 session_store.save(request.session)
421 #----------------------------------------------------------
422 # OpenERP Web WSGI Application
423 #----------------------------------------------------------
424 # Add potentially missing (older ubuntu) font mime types
425 mimetypes.add_type('application/font-woff', '.woff')
426 mimetypes.add_type('application/vnd.ms-fontobject', '.eot')
427 mimetypes.add_type('application/x-font-ttf', '.ttf')
428 class DisableCacheMiddleware(object):
429 def __init__(self, app):
431 def __call__(self, environ, start_response):
432 def start_wrapped(status, headers):
433 referer = environ.get('HTTP_REFERER', '')
434 parsed = urlparse.urlparse(referer)
435 debug = parsed.query.count('debug') >= 1
438 unwanted_keys = ['Last-Modified']
440 new_headers = [('Cache-Control', 'no-cache')]
441 unwanted_keys += ['Expires', 'Etag', 'Cache-Control']
444 if k not in unwanted_keys:
445 new_headers.append((k, v))
447 start_response(status, new_headers)
448 return self.app(environ, start_wrapped)
451 """Root WSGI application for the OpenERP Web Client.
453 :param options: mandatory initialization options object, must provide
454 the following attributes:
456 ``server_host`` (``str``)
457 hostname of the OpenERP server to dispatch RPC to
458 ``server_port`` (``int``)
459 RPC port of the OpenERP server
460 ``serve_static`` (``bool | None``)
461 whether this application should serve the various
462 addons's static files
463 ``storage_path`` (``str``)
464 filesystem path where HTTP session data will be stored
465 ``dbfilter`` (``str``)
466 only used in case the list of databases is requested
467 by the server, will be filtered by this pattern
469 def __init__(self, options):
470 self.config = options
472 if not hasattr(self.config, 'connector'):
473 if self.config.backend == 'local':
474 self.config.connector = session.LocalConnector()
476 self.config.connector = openerplib.get_connector(
477 hostname=self.config.server_host, port=self.config.server_port)
479 self.httpsession_cookie = 'httpsessionid'
482 static_dirs = self._load_addons()
483 if options.serve_static:
484 app = werkzeug.wsgi.SharedDataMiddleware( self.dispatch, static_dirs)
485 self.dispatch = DisableCacheMiddleware(app)
487 if options.session_storage:
488 if not os.path.exists(options.session_storage):
489 os.mkdir(options.session_storage, 0700)
490 self.session_storage = options.session_storage
491 _logger.debug('HTTP sessions stored in: %s', self.session_storage)
493 def __call__(self, environ, start_response):
494 """ Handle a WSGI request
496 return self.dispatch(environ, start_response)
498 def dispatch(self, environ, start_response):
500 Performs the actual WSGI dispatching for the application, may be
501 wrapped during the initialization of the object.
503 Call the object directly.
505 request = werkzeug.wrappers.Request(environ)
506 request.parameter_storage_class = werkzeug.datastructures.ImmutableDict
509 handler = self.find_handler(*(request.path.split('/')[1:]))
512 response = werkzeug.exceptions.NotFound()
514 with session_context(request, self.session_storage, self.httpsession_cookie) as session:
515 result = handler( request, self.config)
517 if isinstance(result, basestring):
518 headers=[('Content-Type', 'text/html; charset=utf-8'), ('Content-Length', len(result))]
519 response = werkzeug.wrappers.Response(result, headers=headers)
523 if hasattr(response, 'set_cookie'):
524 response.set_cookie(self.httpsession_cookie, session.sid)
526 return response(environ, start_response)
528 def _load_addons(self):
530 Loads all addons at the specified addons path, returns a mapping of
531 static URLs to the corresponding directories
534 for addons_path in self.config.addons_path:
535 for module in os.listdir(addons_path):
536 if module not in addons_module:
537 manifest_path = os.path.join(addons_path, module, '__openerp__.py')
538 path_static = os.path.join(addons_path, module, 'static')
539 if os.path.isfile(manifest_path) and os.path.isdir(path_static):
540 manifest = ast.literal_eval(open(manifest_path).read())
541 manifest['addons_path'] = addons_path
542 _logger.debug("Loading %s", module)
543 if 'openerp.addons' in sys.modules:
544 m = __import__('openerp.addons.' + module)
546 m = __import__(module)
547 addons_module[module] = m
548 addons_manifest[module] = manifest
549 statics['/%s/static' % module] = path_static
550 for k, v in controllers_class:
551 if k not in controllers_object:
553 controllers_object[k] = o
554 if hasattr(o, '_cp_path'):
555 controllers_path[o._cp_path] = o
558 def find_handler(self, *l):
560 Tries to discover the controller handling the request for the path
561 specified by the provided parameters
563 :param l: path sections to a controller or controller method
564 :returns: a callable matching the path sections, or ``None``
565 :rtype: ``Controller | None``
568 ps = '/' + '/'.join(l)
571 c = controllers_path.get(ps)
573 m = getattr(c, meth, None)
574 if m and getattr(m, 'exposed', False):
575 _logger.debug("Dispatching to %s %s %s", ps, c, meth)
577 ps, _slash, meth = ps.rpartition('/')
582 class Options(object):
589 _logger.info("embedded mode")
591 o.dbfilter = openerp.tools.config['dbfilter']
592 o.server_wide_modules = openerp.conf.server_wide_modules or ['web']
594 username = getpass.getuser()
597 o.session_storage = os.path.join(tempfile.gettempdir(), "oe-sessions-" + username)
598 o.addons_path = openerp.modules.module.ad_paths
599 o.serve_static = True
603 openerp.wsgi.register_wsgi_handler(app)