1 # -*- coding: utf-8 -*-
2 #----------------------------------------------------------
3 # OpenERP Web HTTP layer
4 #----------------------------------------------------------
21 import werkzeug.contrib.sessions
22 import werkzeug.datastructures
23 import werkzeug.exceptions
25 import werkzeug.wrappers
28 from . import nonliterals
30 from . import openerplib
32 __all__ = ['Root', 'jsonrequest', 'httprequest', 'Controller',
33 'WebRequest', 'JsonRequest', 'HttpRequest']
35 _logger = logging.getLogger(__name__)
37 #----------------------------------------------------------
38 # OpenERP Web RequestHandler
39 #----------------------------------------------------------
40 class WebRequest(object):
41 """ Parent class for all OpenERP Web request types, mostly deals with
42 initialization and setup of the request object (the dispatching itself has
43 to be handled by the subclasses)
45 :param request: a wrapped werkzeug Request object
46 :type request: :class:`werkzeug.wrappers.BaseRequest`
47 :param config: configuration object
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 config parameter provided to the request object
65 :class:`~collections.Mapping` of request parameters, not generally
66 useful as they're provided directly to the handler method as keyword
69 .. attribute:: session_id
71 opaque identifier for the :class:`session.OpenERPSession` instance of
74 .. attribute:: session
76 :class:`~session.OpenERPSession` instance for the current request
78 .. attribute:: context
80 :class:`~collections.Mapping` of context values for the current request
84 ``bool``, indicates whether the debug mode is active on the client
86 def __init__(self, request, config):
87 self.httprequest = request
88 self.httpresponse = None
89 self.httpsession = request.session
92 def init(self, params):
93 self.params = dict(params)
94 # OpenERP session setup
95 self.session_id = self.params.pop("session_id", None) or uuid.uuid4().hex
96 self.session = self.httpsession.get(self.session_id)
98 self.httpsession[self.session_id] = self.session = session.OpenERPSession()
99 self.session.config = self.config
100 self.context = self.params.pop('context', None)
101 self.debug = self.params.pop('debug', False) != False
103 class JsonRequest(WebRequest):
104 """ JSON-RPC2 over HTTP.
108 --> {"jsonrpc": "2.0",
110 "params": {"session_id": "SID",
115 <-- {"jsonrpc": "2.0",
116 "result": { "res1": "val1" },
119 Request producing a error::
121 --> {"jsonrpc": "2.0",
123 "params": {"session_id": "SID",
128 <-- {"jsonrpc": "2.0",
130 "message": "End user error message.",
131 "data": {"code": "codestring",
132 "debug": "traceback" } },
136 def dispatch(self, controller, method):
137 """ Calls the method asked for by the JSON-RPC2 or JSONP request
139 :param controller: the instance of the controller which received the request
140 :param method: the method which received the request
142 :returns: an utf8 encoded JSON-RPC2 or JSONP reply
144 args = self.httprequest.args
145 jsonp = args.get('jsonp')
148 request_id = args.get('id')
150 if jsonp and self.httprequest.method == 'POST':
151 # jsonp 2 steps step1 POST: save call
153 self.session.jsonp_requests[request_id] = self.httprequest.form['r']
154 headers=[('Content-Type', 'text/plain; charset=utf-8')]
155 r = werkzeug.wrappers.Response(request_id, headers=headers)
157 elif jsonp and args.get('r'):
159 request = args.get('r')
160 elif jsonp and request_id:
161 # jsonp 2 steps step2 GET: run and return result
163 request = self.session.jsonp_requests.pop(request_id, "")
166 requestf = self.httprequest.stream
168 response = {"jsonrpc": "2.0" }
171 # Read POST content or POST Form Data named "request"
173 self.jsonrequest = simplejson.load(requestf, object_hook=nonliterals.non_literal_decoder)
175 self.jsonrequest = simplejson.loads(request, object_hook=nonliterals.non_literal_decoder)
176 self.init(self.jsonrequest.get("params", {}))
177 if _logger.isEnabledFor(logging.DEBUG):
178 _logger.debug("--> %s.%s\n%s", controller.__class__.__name__, method.__name__, pprint.pformat(self.jsonrequest))
179 response['id'] = self.jsonrequest.get('id')
180 response["result"] = method(controller, self, **self.params)
181 except openerplib.AuthenticationError:
184 'message': "OpenERP Session Invalid",
186 'type': 'session_invalid',
187 'debug': traceback.format_exc()
190 except xmlrpclib.Fault, e:
193 'message': "OpenERP Server Error",
195 'type': 'server_exception',
196 'fault_code': e.faultCode,
197 'debug': "Client %s\nServer %s" % (
198 "".join(traceback.format_exception("", None, sys.exc_traceback)), e.faultString)
202 logging.getLogger(__name__ + '.JSONRequest.dispatch').exception\
203 ("An error occured while handling a json request")
206 'message': "OpenERP WebClient Error",
208 'type': 'client_exception',
209 'debug': "Client %s" % traceback.format_exc()
213 response["error"] = error
215 if _logger.isEnabledFor(logging.DEBUG):
216 _logger.debug("<--\n%s", pprint.pformat(response))
219 mime = 'application/javascript'
220 body = "%s(%s);" % (jsonp, simplejson.dumps(response, cls=nonliterals.NonLiteralEncoder),)
222 mime = 'application/json'
223 body = simplejson.dumps(response, cls=nonliterals.NonLiteralEncoder)
225 r = werkzeug.wrappers.Response(body, headers=[('Content-Type', mime), ('Content-Length', len(body))])
229 """ Decorator marking the decorated method as being a handler for a
230 JSON-RPC request (the exact request path is specified via the
231 ``$(Controller._cp_path)/$methodname`` combination.
233 If the method is called, it will be provided with a :class:`JsonRequest`
234 instance and all ``params`` sent during the JSON-RPC request, apart from
235 the ``session_id``, ``context`` and ``debug`` keys (which are stripped out
239 def json_handler(controller, request, config):
240 return JsonRequest(request, config).dispatch(controller, f)
241 json_handler.exposed = True
244 class HttpRequest(WebRequest):
245 """ Regular GET/POST request
247 def dispatch(self, controller, method):
248 params = dict(self.httprequest.args)
249 params.update(self.httprequest.form)
250 params.update(self.httprequest.files)
253 for key, value in self.httprequest.args.iteritems():
254 if isinstance(value, basestring) and len(value) < 1024:
257 akw[key] = type(value)
258 _logger.debug("%s --> %s.%s %r", self.httprequest.method, controller.__class__.__name__, method.__name__, akw)
260 r = method(controller, self, **self.params)
261 except xmlrpclib.Fault, e:
262 r = werkzeug.exceptions.InternalServerError(cgi.escape(simplejson.dumps({
264 'message': "OpenERP Server Error",
266 'type': 'server_exception',
267 'fault_code': e.faultCode,
268 'debug': "Server %s\nClient %s" % (
269 e.faultString, traceback.format_exc())
273 logging.getLogger(__name__ + '.HttpRequest.dispatch').exception(
274 "An error occurred while handling a json request")
275 r = werkzeug.exceptions.InternalServerError(cgi.escape(simplejson.dumps({
277 'message': "OpenERP WebClient Error",
279 'type': 'client_exception',
280 'debug': "Client %s" % traceback.format_exc()
284 if isinstance(r, (werkzeug.wrappers.BaseResponse, werkzeug.exceptions.HTTPException)):
285 _logger.debug('<-- %s', r)
287 _logger.debug("<-- size: %s", len(r))
290 def make_response(self, data, headers=None, cookies=None):
291 """ Helper for non-HTML responses, or HTML responses with custom
292 response headers or cookies.
294 While handlers can just return the HTML markup of a page they want to
295 send as a string if non-HTML data is returned they need to create a
296 complete response object, or the returned data will not be correctly
297 interpreted by the clients.
299 :param basestring data: response body
300 :param headers: HTTP headers to set on the response
301 :type headers: ``[(name, value)]``
302 :param collections.Mapping cookies: cookies to set on the client
304 response = werkzeug.wrappers.Response(data, headers=headers)
306 for k, v in cookies.iteritems():
307 response.set_cookie(k, v)
310 def not_found(self, description=None):
311 """ Helper for 404 response, return its result from the method
313 return werkzeug.exceptions.NotFound(description)
316 """ Decorator marking the decorated method as being a handler for a
317 normal HTTP request (the exact request path is specified via the
318 ``$(Controller._cp_path)/$methodname`` combination.
320 If the method is called, it will be provided with a :class:`HttpRequest`
321 instance and all ``params`` sent during the request (``GET`` and ``POST``
322 merged in the same dictionary), apart from the ``session_id``, ``context``
323 and ``debug`` keys (which are stripped out beforehand)
326 def http_handler(controller, request, config):
327 return HttpRequest(request, config).dispatch(controller, f)
328 http_handler.exposed = True
331 #----------------------------------------------------------
332 # OpenERP Web werkzeug Session Managment wraped using with
333 #----------------------------------------------------------
336 @contextlib.contextmanager
337 def session_context(request, storage_path, session_cookie='sessionid'):
338 session_store, session_lock = STORES.get(storage_path, (None, None))
339 if not session_store:
340 session_store = werkzeug.contrib.sessions.FilesystemSessionStore(
342 session_lock = threading.Lock()
343 STORES[storage_path] = session_store, session_lock
345 sid = request.cookies.get(session_cookie)
348 request.session = session_store.get(sid)
350 request.session = session_store.new()
353 yield request.session
355 # Remove all OpenERPSession instances with no uid, they're generated
356 # either by login process or by HTTP requests without an OpenERP
357 # session id, and are generally noise
358 removed_sessions = set()
359 for key, value in request.session.items():
360 if not isinstance(value, session.OpenERPSession):
362 if getattr(value, '_suicide', False) or (
364 and not value.jsonp_requests
365 # FIXME do not use a fixed value
366 and value._creation_time + (60*5) < time.time()):
367 _logger.debug('remove session %s', key)
368 removed_sessions.add(key)
369 del request.session[key]
373 # Re-load sessions from storage and merge non-literal
374 # contexts and domains (they're indexed by hash of the
375 # content so conflicts should auto-resolve), otherwise if
376 # two requests alter those concurrently the last to finish
377 # will overwrite the previous one, leading to loss of data
378 # (a non-literal is lost even though it was sent to the
379 # client and client errors)
381 # note that domains_store and contexts_store are append-only (we
382 # only ever add items to them), so we can just update one with the
383 # other to get the right result, if we want to merge the
384 # ``context`` dict we'll need something smarter
385 in_store = session_store.get(sid)
386 for k, v in request.session.iteritems():
387 stored = in_store.get(k)
388 if stored and isinstance(v, session.OpenERPSession):
389 v.contexts_store.update(stored.contexts_store)
390 v.domains_store.update(stored.domains_store)
391 if not hasattr(v, 'jsonp_requests'):
392 v.jsonp_requests = {}
393 v.jsonp_requests.update(getattr(
394 stored, 'jsonp_requests', {}))
397 for k, v in in_store.iteritems():
398 if k not in request.session and k not in removed_sessions:
399 request.session[k] = v
401 session_store.save(request.session)
403 #----------------------------------------------------------
404 # OpenERP Web Module/Controller Loading and URL Routing
405 #----------------------------------------------------------
408 controllers_class = []
409 controllers_object = {}
410 controllers_path = {}
412 class ControllerType(type):
413 def __init__(cls, name, bases, attrs):
414 super(ControllerType, cls).__init__(name, bases, attrs)
415 controllers_class.append(("%s.%s" % (cls.__module__, cls.__name__), cls))
417 class Controller(object):
418 __metaclass__ = ControllerType
421 """Root WSGI application for the OpenERP Web Client.
423 :param options: mandatory initialization options object, must provide
424 the following attributes:
426 ``server_host`` (``str``)
427 hostname of the OpenERP server to dispatch RPC to
428 ``server_port`` (``int``)
429 RPC port of the OpenERP server
430 ``serve_static`` (``bool | None``)
431 whether this application should serve the various
432 addons's static files
433 ``storage_path`` (``str``)
434 filesystem path where HTTP session data will be stored
435 ``dbfilter`` (``str``)
436 only used in case the list of databases is requested
437 by the server, will be filtered by this pattern
439 def __init__(self, options, openerp_addons_namespace=True):
440 self.root = '/web/webclient/home'
441 self.config = options
443 if not hasattr(self.config, 'connector'):
444 if self.config.backend == 'local':
445 self.config.connector = LocalConnector()
447 self.config.connector = openerplib.get_connector(
448 hostname=self.config.server_host, port=self.config.server_port)
450 self.session_cookie = 'sessionid'
453 static_dirs = self._load_addons(openerp_addons_namespace)
454 if options.serve_static:
455 self.dispatch = werkzeug.wsgi.SharedDataMiddleware(
456 self.dispatch, static_dirs)
458 if options.session_storage:
459 if not os.path.exists(options.session_storage):
460 os.mkdir(options.session_storage, 0700)
461 self.session_storage = options.session_storage
462 _logger.debug('HTTP sessions stored in: %s', self.session_storage)
464 def __call__(self, environ, start_response):
465 """ Handle a WSGI request
467 return self.dispatch(environ, start_response)
469 def dispatch(self, environ, start_response):
471 Performs the actual WSGI dispatching for the application, may be
472 wrapped during the initialization of the object.
474 Call the object directly.
476 request = werkzeug.wrappers.Request(environ)
477 request.parameter_storage_class = werkzeug.datastructures.ImmutableDict
480 if request.path == '/':
481 params = urllib.urlencode(request.args)
482 return werkzeug.utils.redirect(self.root + '?' + params, 301)(
483 environ, start_response)
484 elif request.path == '/mobile':
485 return werkzeug.utils.redirect(
486 '/web_mobile/static/src/web_mobile.html', 301)(environ, start_response)
488 handler = self.find_handler(*(request.path.split('/')[1:]))
491 response = werkzeug.exceptions.NotFound()
493 with session_context(request, self.session_storage, self.session_cookie) as session:
494 result = handler( request, self.config)
496 if isinstance(result, basestring):
497 headers=[('Content-Type', 'text/html; charset=utf-8'), ('Content-Length', len(result))]
498 response = werkzeug.wrappers.Response(result, headers=headers)
502 if hasattr(response, 'set_cookie'):
503 response.set_cookie(self.session_cookie, session.sid)
505 return response(environ, start_response)
507 def _load_addons(self, openerp_addons_namespace=True):
509 Loads all addons at the specified addons path, returns a mapping of
510 static URLs to the corresponding directories
513 for addons_path in self.config.addons_path:
514 for module in os.listdir(addons_path):
515 if module not in addons_module:
516 manifest_path = os.path.join(addons_path, module, '__openerp__.py')
517 path_static = os.path.join(addons_path, module, 'static')
518 if os.path.isfile(manifest_path) and os.path.isdir(path_static):
519 manifest = ast.literal_eval(open(manifest_path).read())
520 manifest['addons_path'] = addons_path
521 _logger.debug("Loading %s", module)
522 if openerp_addons_namespace:
523 m = __import__('openerp.addons.' + module)
525 m = __import__(module)
526 addons_module[module] = m
527 addons_manifest[module] = manifest
528 statics['/%s/static' % module] = path_static
529 for k, v in controllers_class:
530 if k not in controllers_object:
532 controllers_object[k] = o
533 if hasattr(o, '_cp_path'):
534 controllers_path[o._cp_path] = o
537 def find_handler(self, *l):
539 Tries to discover the controller handling the request for the path
540 specified by the provided parameters
542 :param l: path sections to a controller or controller method
543 :returns: a callable matching the path sections, or ``None``
544 :rtype: ``Controller | None``
547 ps = '/' + '/'.join(l)
550 c = controllers_path.get(ps)
553 if getattr(m, 'exposed', False):
554 _logger.debug("Dispatching to %s %s %s", ps, c, meth)
556 ps, _slash, meth = ps.rpartition('/')
559 class LibException(Exception):
560 """ Base of all client lib exceptions """
561 def __init__(self,code=None,message=None):
563 self.message = message
565 class ApplicationError(LibException):
566 """ maps to code: 1, server side: Exception or openerp.exceptions.DeferredException"""
568 class Warning(LibException):
569 """ maps to code: 2, server side: openerp.exceptions.Warning"""
571 class AccessError(LibException):
572 """ maps to code: 3, server side: openerp.exceptions.AccessError"""
574 class AccessDenied(LibException):
575 """ maps to code: 4, server side: openerp.exceptions.AccessDenied"""
578 class LocalConnector(openerplib.Connector):
580 A type of connector that uses the XMLRPC protocol.
587 def send(self, service_name, method, *args):
592 result = openerp.netsvc.dispatch_rpc(service_name, method, args)
594 # TODO change the except to raise LibException instead of their emulated xmlrpc fault
595 if isinstance(e, openerp.osv.osv.except_osv):
596 fault = xmlrpclib.Fault('warning -- ' + e.name + '\n\n' + e.value, '')
597 elif isinstance(e, openerp.exceptions.Warning):
598 fault = xmlrpclib.Fault('warning -- Warning\n\n' + str(e), '')
599 elif isinstance(e, openerp.exceptions.AccessError):
600 fault = xmlrpclib.Fault('warning -- AccessError\n\n' + str(e), '')
601 elif isinstance(e, openerp.exceptions.AccessDenied):
602 fault = xmlrpclib.Fault('AccessDenied', str(e))
603 elif isinstance(e, openerp.exceptions.DeferredException):
605 formatted_info = "".join(traceback.format_exception(*info))
606 fault = xmlrpclib.Fault(openerp.tools.ustr(e.message), formatted_info)
608 info = sys.exc_info()
609 formatted_info = "".join(traceback.format_exception(*info))
610 fault = xmlrpclib.Fault(openerp.tools.exception_to_unicode(e), formatted_info)