1 # -*- coding: utf-8 -*-
2 #----------------------------------------------------------
3 # OpenERP Web HTTP layer
4 #----------------------------------------------------------
19 import werkzeug.contrib.sessions
20 import werkzeug.datastructures
21 import werkzeug.exceptions
23 import werkzeug.wrappers
30 __all__ = ['Root', 'jsonrequest', 'httprequest', 'Controller',
31 'WebRequest', 'JsonRequest', 'HttpRequest']
33 _logger = logging.getLogger(__name__)
35 #----------------------------------------------------------
36 # OpenERP Web RequestHandler
37 #----------------------------------------------------------
38 class WebRequest(object):
39 """ Parent class for all OpenERP Web request types, mostly deals with
40 initialization and setup of the request object (the dispatching itself has
41 to be handled by the subclasses)
43 :param request: a wrapped werkzeug Request object
44 :type request: :class:`werkzeug.wrappers.BaseRequest`
45 :param config: configuration object
47 .. attribute:: httprequest
49 the original :class:`werkzeug.wrappers.Request` object provided to the
52 .. attribute:: httpsession
54 a :class:`~collections.Mapping` holding the HTTP session data for the
59 config parameter provided to the request object
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, config):
85 self.httprequest = request
86 self.httpresponse = None
87 self.httpsession = request.session
90 def init(self, params):
91 self.params = dict(params)
92 # OpenERP session setup
93 self.session_id = self.params.pop("session_id", None) or uuid.uuid4().hex
94 self.session = self.httpsession.setdefault(self.session_id, session.OpenERPSession())
95 self.session.config = self.config
96 self.context = self.params.pop('context', None)
97 self.debug = self.params.pop('debug', False) != False
99 class JsonRequest(WebRequest):
100 """ JSON-RPC2 over HTTP.
104 --> {"jsonrpc": "2.0",
106 "params": {"session_id": "SID",
111 <-- {"jsonrpc": "2.0",
112 "result": { "res1": "val1" },
115 Request producing a error::
117 --> {"jsonrpc": "2.0",
119 "params": {"session_id": "SID",
124 <-- {"jsonrpc": "2.0",
126 "message": "End user error message.",
127 "data": {"code": "codestring",
128 "debug": "traceback" } },
132 def dispatch(self, controller, method):
133 """ Calls the method asked for by the JSON-RPC2 or JSONP request
135 :param controller: the instance of the controller which received the request
136 :param method: the method which received the request
138 :returns: an utf8 encoded JSON-RPC2 or JSONP reply
140 args = self.httprequest.args
141 jsonp = args.get('jsonp')
145 if jsonp and self.httprequest.method == 'POST':
146 # jsonp 2 steps step1 POST: save call
148 req.session.jsonp_requests[args.get('id')] = self.httprequest.form['r']
149 headers=[('Content-Type', 'text/plain; charset=utf-8')]
150 r = werkzeug.wrappers.Response(request_id, headers=headers)
152 elif jsonp and args.get('r'):
154 request = args.get('r')
155 elif jsonp and args.get('id'):
156 # jsonp 2 steps step2 GET: run and return result
158 request = self.session.jsonp_requests.pop(args.get(id), "")
161 requestf = self.httprequest.stream
163 response = {"jsonrpc": "2.0" }
166 # Read POST content or POST Form Data named "request"
168 self.jsonrequest = simplejson.load(requestf, object_hook=nonliterals.non_literal_decoder)
170 self.jsonrequest = simplejson.loads(request, object_hook=nonliterals.non_literal_decoder)
171 self.init(self.jsonrequest.get("params", {}))
172 if _logger.isEnabledFor(logging.DEBUG):
173 _logger.debug("--> %s.%s\n%s", controller.__class__.__name__, method.__name__, pprint.pformat(self.jsonrequest))
174 response['id'] = self.jsonrequest.get('id')
175 response["result"] = method(controller, self, **self.params)
176 except openerplib.AuthenticationError:
179 'message': "OpenERP Session Invalid",
181 'type': 'session_invalid',
182 'debug': traceback.format_exc()
185 except xmlrpclib.Fault, e:
188 'message': "OpenERP Server Error",
190 'type': 'server_exception',
191 'fault_code': e.faultCode,
192 'debug': "Client %s\nServer %s" % (
193 "".join(traceback.format_exception("", None, sys.exc_traceback)), e.faultString)
197 logging.getLogger(__name__ + '.JSONRequest.dispatch').exception\
198 ("An error occured while handling a json request")
201 'message': "OpenERP WebClient Error",
203 'type': 'client_exception',
204 'debug': "Client %s" % traceback.format_exc()
208 response["error"] = error
210 if _logger.isEnabledFor(logging.DEBUG):
211 _logger.debug("<--\n%s", pprint.pformat(response))
214 mime = 'application/javascript'
215 body = "%s(%s);" % (jsonp, simplejson.dumps(response, cls=nonliterals.NonLiteralEncoder),)
217 mime = 'application/json'
218 body = simplejson.dumps(response, cls=nonliterals.NonLiteralEncoder)
220 r = werkzeug.wrappers.Response(body, headers=[('Content-Type', mime), ('Content-Length', len(body))])
224 """ Decorator marking the decorated method as being a handler for a
225 JSON-RPC request (the exact request path is specified via the
226 ``$(Controller._cp_path)/$methodname`` combination.
228 If the method is called, it will be provided with a :class:`JsonRequest`
229 instance and all ``params`` sent during the JSON-RPC request, apart from
230 the ``session_id``, ``context`` and ``debug`` keys (which are stripped out
234 def json_handler(controller, request, config):
235 return JsonRequest(request, config).dispatch(controller, f)
236 json_handler.exposed = True
239 class HttpRequest(WebRequest):
240 """ Regular GET/POST request
242 def dispatch(self, controller, method):
243 params = dict(self.httprequest.args)
244 params.update(self.httprequest.form)
245 params.update(self.httprequest.files)
248 for key, value in self.httprequest.args.iteritems():
249 if isinstance(value, basestring) and len(value) < 1024:
252 akw[key] = type(value)
253 _logger.debug("%s --> %s.%s %r", self.httprequest.method, controller.__class__.__name__, method.__name__, akw)
254 r = method(controller, self, **self.params)
256 if isinstance(r, (werkzeug.wrappers.BaseResponse, werkzeug.exceptions.HTTPException)):
257 _logger.debug('<-- %s', r)
259 _logger.debug("<-- size: %s", len(r))
262 def make_response(self, data, headers=None, cookies=None):
263 """ Helper for non-HTML responses, or HTML responses with custom
264 response headers or cookies.
266 While handlers can just return the HTML markup of a page they want to
267 send as a string if non-HTML data is returned they need to create a
268 complete response object, or the returned data will not be correctly
269 interpreted by the clients.
271 :param basestring data: response body
272 :param headers: HTTP headers to set on the response
273 :type headers: ``[(name, value)]``
274 :param collections.Mapping cookies: cookies to set on the client
276 response = werkzeug.wrappers.Response(data, headers=headers)
278 for k, v in cookies.iteritems():
279 response.set_cookie(k, v)
282 def not_found(self, description=None):
283 """ Helper for 404 response, return its result from the method
285 return werkzeug.exceptions.NotFound(description)
288 """ Decorator marking the decorated method as being a handler for a
289 normal HTTP request (the exact request path is specified via the
290 ``$(Controller._cp_path)/$methodname`` combination.
292 If the method is called, it will be provided with a :class:`HttpRequest`
293 instance and all ``params`` sent during the request (``GET`` and ``POST``
294 merged in the same dictionary), apart from the ``session_id``, ``context``
295 and ``debug`` keys (which are stripped out beforehand)
298 def http_handler(controller, request, config):
299 return HttpRequest(request, config).dispatch(controller, f)
300 http_handler.exposed = True
303 #----------------------------------------------------------
304 # OpenERP Web werkzeug Session Managment wraped using with
305 #----------------------------------------------------------
308 @contextlib.contextmanager
309 def session_context(request, storage_path, session_cookie='sessionid'):
310 session_store, session_lock = STORES.get(storage_path, (None, None))
311 if not session_store:
312 session_store = werkzeug.contrib.sessions.FilesystemSessionStore(
314 session_lock = threading.Lock()
315 STORES[storage_path] = session_store, session_lock
317 sid = request.cookies.get(session_cookie)
320 request.session = session_store.get(sid)
322 request.session = session_store.new()
325 yield request.session
327 # Remove all OpenERPSession instances with no uid, they're generated
328 # either by login process or by HTTP requests without an OpenERP
329 # session id, and are generally noise
330 for key, value in request.session.items():
331 if (isinstance(value, session.OpenERPSession)
333 and not value.jsonp_requests
335 _logger.info('remove session %s: %r', key, value.jsonp_requests)
336 del request.session[key]
340 # Re-load sessions from storage and merge non-literal
341 # contexts and domains (they're indexed by hash of the
342 # content so conflicts should auto-resolve), otherwise if
343 # two requests alter those concurrently the last to finish
344 # will overwrite the previous one, leading to loss of data
345 # (a non-literal is lost even though it was sent to the
346 # client and client errors)
348 # note that domains_store and contexts_store are append-only (we
349 # only ever add items to them), so we can just update one with the
350 # other to get the right result, if we want to merge the
351 # ``context`` dict we'll need something smarter
352 in_store = session_store.get(sid)
353 for k, v in request.session.iteritems():
354 stored = in_store.get(k)
355 if stored and isinstance(v, session.OpenERPSession):
356 v.contexts_store.update(stored.contexts_store)
357 v.domains_store.update(stored.domains_store)
358 if not hasattr(v, 'jsonp_requests'):
359 v.jsonp_requests = {}
360 v.jsonp_requests.update(getattr(
361 stored, 'jsonp_requests', {}))
364 for k, v in in_store.iteritems():
365 if k not in request.session:
366 request.session[k] = v
368 session_store.save(request.session)
370 #----------------------------------------------------------
371 # OpenERP Web Module/Controller Loading and URL Routing
372 #----------------------------------------------------------
375 controllers_class = {}
376 controllers_object = {}
377 controllers_path = {}
379 class ControllerType(type):
380 def __init__(cls, name, bases, attrs):
381 super(ControllerType, cls).__init__(name, bases, attrs)
382 controllers_class["%s.%s" % (cls.__module__, cls.__name__)] = cls
384 class Controller(object):
385 __metaclass__ = ControllerType
388 """Root WSGI application for the OpenERP Web Client.
390 :param options: mandatory initialization options object, must provide
391 the following attributes:
393 ``server_host`` (``str``)
394 hostname of the OpenERP server to dispatch RPC to
395 ``server_port`` (``int``)
396 RPC port of the OpenERP server
397 ``serve_static`` (``bool | None``)
398 whether this application should serve the various
399 addons's static files
400 ``storage_path`` (``str``)
401 filesystem path where HTTP session data will be stored
402 ``dbfilter`` (``str``)
403 only used in case the list of databases is requested
404 by the server, will be filtered by this pattern
406 def __init__(self, options):
407 self.root = '/web/webclient/home'
408 self.config = options
410 if self.config.backend == 'local':
411 conn = LocalConnector()
413 conn = openerplib.get_connector(hostname=self.config.server_host,
414 port=self.config.server_port)
415 self.config.connector = conn
417 self.session_cookie = 'sessionid'
420 static_dirs = self._load_addons()
421 if options.serve_static:
422 self.dispatch = werkzeug.wsgi.SharedDataMiddleware(
423 self.dispatch, static_dirs)
425 if options.session_storage:
426 if not os.path.exists(options.session_storage):
427 os.mkdir(options.session_storage, 0700)
428 self.session_storage = options.session_storage
429 _logger.debug('HTTP sessions stored in: %s', self.session_storage)
431 def __call__(self, environ, start_response):
432 """ Handle a WSGI request
434 return self.dispatch(environ, start_response)
436 def dispatch(self, environ, start_response):
438 Performs the actual WSGI dispatching for the application, may be
439 wrapped during the initialization of the object.
441 Call the object directly.
443 request = werkzeug.wrappers.Request(environ)
444 request.parameter_storage_class = werkzeug.datastructures.ImmutableDict
447 if request.path == '/':
448 params = urllib.urlencode(request.args)
449 return werkzeug.utils.redirect(self.root + '?' + params, 301)(
450 environ, start_response)
451 elif request.path == '/mobile':
452 return werkzeug.utils.redirect(
453 '/web_mobile/static/src/web_mobile.html', 301)(environ, start_response)
455 handler = self.find_handler(*(request.path.split('/')[1:]))
458 response = werkzeug.exceptions.NotFound()
460 with session_context(request, self.session_storage, self.session_cookie) as session:
461 result = handler( request, self.config)
463 if isinstance(result, basestring):
464 headers=[('Content-Type', 'text/html; charset=utf-8'), ('Content-Length', len(result))]
465 response = werkzeug.wrappers.Response(result, headers=headers)
469 if hasattr(response, 'set_cookie'):
470 response.set_cookie(self.session_cookie, session.sid)
472 return response(environ, start_response)
474 def _load_addons(self):
476 Loads all addons at the specified addons path, returns a mapping of
477 static URLs to the corresponding directories
480 for addons_path in self.config.addons_path:
481 if addons_path not in sys.path:
482 sys.path.insert(0, addons_path)
483 for module in os.listdir(addons_path):
484 if module not in addons_module:
485 manifest_path = os.path.join(addons_path, module, '__openerp__.py')
486 path_static = os.path.join(addons_path, module, 'static')
487 if os.path.isfile(manifest_path) and os.path.isdir(path_static):
488 manifest = ast.literal_eval(open(manifest_path).read())
489 manifest['addons_path'] = addons_path
490 _logger.info("Loading %s", module)
491 m = __import__(module)
492 addons_module[module] = m
493 addons_manifest[module] = manifest
494 statics['/%s/static' % module] = path_static
495 for k, v in controllers_class.items():
496 if k not in controllers_object:
498 controllers_object[k] = o
499 if hasattr(o, '_cp_path'):
500 controllers_path[o._cp_path] = o
503 def find_handler(self, *l):
505 Tries to discover the controller handling the request for the path
506 specified by the provided parameters
508 :param l: path sections to a controller or controller method
509 :returns: a callable matching the path sections, or ``None``
510 :rtype: ``Controller | None``
513 for i in range(len(l), 0, -1):
514 ps = "/" + "/".join(l[0:i])
515 if ps in controllers_path:
516 c = controllers_path[ps]
517 rest = l[i:] or ['index']
520 if getattr(m, 'exposed', False):
521 _logger.debug("Dispatching to %s %s %s", ps, c, meth)
525 class LibException(Exception):
526 """ Base of all client lib exceptions """
527 def __init__(self,code=None,message=None):
529 self.message = message
531 class ApplicationError(LibException):
532 """ maps to code: 1, server side: Exception or openerp.exceptions.DeferredException"""
534 class Warning(LibException):
535 """ maps to code: 2, server side: openerp.exceptions.Warning"""
537 class AccessError(LibException):
538 """ maps to code: 3, server side: openerp.exceptions.AccessError"""
540 class AccessDenied(LibException):
541 """ maps to code: 4, server side: openerp.exceptions.AccessDenied"""
544 class LocalConnector(openerplib.Connector):
546 A type of connector that uses the XMLRPC protocol.
553 def send(self, service_name, method, *args):
558 result = openerp.netsvc.dispatch_rpc(service_name, method, args)
560 # TODO change the except to raise LibException instead of their emulated xmlrpc fault
561 if isinstance(e, openerp.osv.osv.except_osv):
562 fault = xmlrpclib.Fault('warning -- ' + e.name + '\n\n' + e.value, '')
563 elif isinstance(e, openerp.exceptions.Warning):
564 fault = xmlrpclib.Fault('warning -- Warning\n\n' + str(e), '')
565 elif isinstance(e, openerp.exceptions.AccessError):
566 fault = xmlrpclib.Fault('warning -- AccessError\n\n' + str(e), '')
567 elif isinstance(e, openerp.exceptions.AccessDenied):
568 fault = xmlrpclib.Fault('AccessDenied', str(e))
569 elif isinstance(e, openerp.exceptions.DeferredException):
571 formatted_info = "".join(traceback.format_exception(*info))
572 fault = xmlrpclib.Fault(openerp.tools.ustr(e.message), formatted_info)
574 info = sys.exc_info()
575 formatted_info = "".join(traceback.format_exception(*info))
576 fault = xmlrpclib.Fault(openerp.tools.exception_to_unicode(e), formatted_info)