[FIX] if a connector was setup in the configuration object, don't re-set one instead
[odoo/odoo.git] / addons / web / common / http.py
1 # -*- coding: utf-8 -*-
2 #----------------------------------------------------------
3 # OpenERP Web HTTP layer
4 #----------------------------------------------------------
5 import ast
6 import cgi
7 import contextlib
8 import functools
9 import logging
10 import os
11 import pprint
12 import sys
13 import threading
14 import time
15 import traceback
16 import urllib
17 import uuid
18 import xmlrpclib
19
20 import simplejson
21 import werkzeug.contrib.sessions
22 import werkzeug.datastructures
23 import werkzeug.exceptions
24 import werkzeug.utils
25 import werkzeug.wrappers
26 import werkzeug.wsgi
27
28 from . import nonliterals
29 from . import session
30 from . import openerplib
31
32 __all__ = ['Root', 'jsonrequest', 'httprequest', 'Controller',
33            'WebRequest', 'JsonRequest', 'HttpRequest']
34
35 _logger = logging.getLogger(__name__)
36
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)
44
45     :param request: a wrapped werkzeug Request object
46     :type request: :class:`werkzeug.wrappers.BaseRequest`
47     :param config: configuration object
48
49     .. attribute:: httprequest
50
51         the original :class:`werkzeug.wrappers.Request` object provided to the
52         request
53
54     .. attribute:: httpsession
55
56         a :class:`~collections.Mapping` holding the HTTP session data for the
57         current http session
58
59     .. attribute:: config
60
61         config parameter provided to the request object
62
63     .. attribute:: params
64
65         :class:`~collections.Mapping` of request parameters, not generally
66         useful as they're provided directly to the handler method as keyword
67         arguments
68
69     .. attribute:: session_id
70
71         opaque identifier for the :class:`session.OpenERPSession` instance of
72         the current request
73
74     .. attribute:: session
75
76         :class:`~session.OpenERPSession` instance for the current request
77
78     .. attribute:: context
79
80         :class:`~collections.Mapping` of context values for the current request
81
82     .. attribute:: debug
83
84         ``bool``, indicates whether the debug mode is active on the client
85     """
86     def __init__(self, request, config):
87         self.httprequest = request
88         self.httpresponse = None
89         self.httpsession = request.session
90         self.config = config
91
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)
97         if not self.session:
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
102
103 class JsonRequest(WebRequest):
104     """ JSON-RPC2 over HTTP.
105
106     Sucessful request::
107
108       --> {"jsonrpc": "2.0",
109            "method": "call",
110            "params": {"session_id": "SID",
111                       "context": {},
112                       "arg1": "val1" },
113            "id": null}
114
115       <-- {"jsonrpc": "2.0",
116            "result": { "res1": "val1" },
117            "id": null}
118
119     Request producing a error::
120
121       --> {"jsonrpc": "2.0",
122            "method": "call",
123            "params": {"session_id": "SID",
124                       "context": {},
125                       "arg1": "val1" },
126            "id": null}
127
128       <-- {"jsonrpc": "2.0",
129            "error": {"code": 1,
130                      "message": "End user error message.",
131                      "data": {"code": "codestring",
132                               "debug": "traceback" } },
133            "id": null}
134
135     """
136     def dispatch(self, controller, method):
137         """ Calls the method asked for by the JSON-RPC2 or JSONP request
138
139         :param controller: the instance of the controller which received the request
140         :param method: the method which received the request
141
142         :returns: an utf8 encoded JSON-RPC2 or JSONP reply
143         """
144         args = self.httprequest.args
145         jsonp = args.get('jsonp')
146         requestf = None
147         request = None
148         request_id = args.get('id')
149
150         if jsonp and self.httprequest.method == 'POST':
151             # jsonp 2 steps step1 POST: save call
152             self.init(args)
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)
156             return r
157         elif jsonp and args.get('r'):
158             # jsonp method GET
159             request = args.get('r')
160         elif jsonp and request_id:
161             # jsonp 2 steps step2 GET: run and return result
162             self.init(args)
163             request = self.session.jsonp_requests.pop(request_id, "")
164         else:
165             # regular jsonrpc2
166             requestf = self.httprequest.stream
167
168         response = {"jsonrpc": "2.0" }
169         error = None
170         try:
171             # Read POST content or POST Form Data named "request"
172             if requestf:
173                 self.jsonrequest = simplejson.load(requestf, object_hook=nonliterals.non_literal_decoder)
174             else:
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:
182             error = {
183                 'code': 100,
184                 'message': "OpenERP Session Invalid",
185                 'data': {
186                     'type': 'session_invalid',
187                     'debug': traceback.format_exc()
188                 }
189             }
190         except xmlrpclib.Fault, e:
191             error = {
192                 'code': 200,
193                 'message': "OpenERP Server Error",
194                 'data': {
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)
199                 }
200             }
201         except Exception:
202             logging.getLogger(__name__ + '.JSONRequest.dispatch').exception\
203                 ("An error occured while handling a json request")
204             error = {
205                 'code': 300,
206                 'message': "OpenERP WebClient Error",
207                 'data': {
208                     'type': 'client_exception',
209                     'debug': "Client %s" % traceback.format_exc()
210                 }
211             }
212         if error:
213             response["error"] = error
214
215         if _logger.isEnabledFor(logging.DEBUG):
216             _logger.debug("<--\n%s", pprint.pformat(response))
217
218         if jsonp:
219             mime = 'application/javascript'
220             body = "%s(%s);" % (jsonp, simplejson.dumps(response, cls=nonliterals.NonLiteralEncoder),)
221         else:
222             mime = 'application/json'
223             body = simplejson.dumps(response, cls=nonliterals.NonLiteralEncoder)
224
225         r = werkzeug.wrappers.Response(body, headers=[('Content-Type', mime), ('Content-Length', len(body))])
226         return r
227
228 def jsonrequest(f):
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.
232
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
236     beforehand)
237     """
238     @functools.wraps(f)
239     def json_handler(controller, request, config):
240         return JsonRequest(request, config).dispatch(controller, f)
241     json_handler.exposed = True
242     return json_handler
243
244 class HttpRequest(WebRequest):
245     """ Regular GET/POST request
246     """
247     def dispatch(self, controller, method):
248         params = dict(self.httprequest.args)
249         params.update(self.httprequest.form)
250         params.update(self.httprequest.files)
251         self.init(params)
252         akw = {}
253         for key, value in self.httprequest.args.iteritems():
254             if isinstance(value, basestring) and len(value) < 1024:
255                 akw[key] = value
256             else:
257                 akw[key] = type(value)
258         _logger.debug("%s --> %s.%s %r", self.httprequest.method, controller.__class__.__name__, method.__name__, akw)
259         try:
260             r = method(controller, self, **self.params)
261         except xmlrpclib.Fault, e:
262             r = werkzeug.exceptions.InternalServerError(cgi.escape(simplejson.dumps({
263                 'code': 200,
264                 'message': "OpenERP Server Error",
265                 'data': {
266                     'type': 'server_exception',
267                     'fault_code': e.faultCode,
268                     'debug': "Server %s\nClient %s" % (
269                         e.faultString, traceback.format_exc())
270                 }
271             })))
272         except Exception:
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({
276                 'code': 300,
277                 'message': "OpenERP WebClient Error",
278                 'data': {
279                     'type': 'client_exception',
280                     'debug': "Client %s" % traceback.format_exc()
281                 }
282             })))
283         if self.debug or 1:
284             if isinstance(r, (werkzeug.wrappers.BaseResponse, werkzeug.exceptions.HTTPException)):
285                 _logger.debug('<-- %s', r)
286             else:
287                 _logger.debug("<-- size: %s", len(r))
288         return r
289
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.
293
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.
298
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
303         """
304         response = werkzeug.wrappers.Response(data, headers=headers)
305         if cookies:
306             for k, v in cookies.iteritems():
307                 response.set_cookie(k, v)
308         return response
309
310     def not_found(self, description=None):
311         """ Helper for 404 response, return its result from the method
312         """
313         return werkzeug.exceptions.NotFound(description)
314
315 def httprequest(f):
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.
319
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)
324     """
325     @functools.wraps(f)
326     def http_handler(controller, request, config):
327         return HttpRequest(request, config).dispatch(controller, f)
328     http_handler.exposed = True
329     return http_handler
330
331 #----------------------------------------------------------
332 # OpenERP Web werkzeug Session Managment wraped using with
333 #----------------------------------------------------------
334 STORES = {}
335
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(
341             storage_path)
342         session_lock = threading.Lock()
343         STORES[storage_path] = session_store, session_lock
344
345     sid = request.cookies.get(session_cookie)
346     with session_lock:
347         if sid:
348             request.session = session_store.get(sid)
349         else:
350             request.session = session_store.new()
351
352     try:
353         yield request.session
354     finally:
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):
361                 continue
362             if getattr(value, '_suicide', False) or (
363                         not value._uid
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]
370
371         with session_lock:
372             if sid:
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)
380                 #
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', {}))
395
396                 # add missing keys
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
400
401             session_store.save(request.session)
402
403 #----------------------------------------------------------
404 # OpenERP Web Module/Controller Loading and URL Routing
405 #----------------------------------------------------------
406 addons_module = {}
407 addons_manifest = {}
408 controllers_class = []
409 controllers_object = {}
410 controllers_path = {}
411
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))
416
417 class Controller(object):
418     __metaclass__ = ControllerType
419
420 class Root(object):
421     """Root WSGI application for the OpenERP Web Client.
422
423     :param options: mandatory initialization options object, must provide
424                     the following attributes:
425
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
438     """
439     def __init__(self, options, openerp_addons_namespace=True):
440         self.root = '/web/webclient/home'
441         self.config = options
442
443         if not hasattr(self.config, 'connector'):
444             if self.config.backend == 'local':
445                 self.config.connector = LocalConnector()
446             else:
447                 self.config.connector = openerplib.get_connector(
448                     hostname=self.config.server_host, port=self.config.server_port)
449
450         self.session_cookie = 'sessionid'
451         self.addons = {}
452
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)
457
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)
463
464     def __call__(self, environ, start_response):
465         """ Handle a WSGI request
466         """
467         return self.dispatch(environ, start_response)
468
469     def dispatch(self, environ, start_response):
470         """
471         Performs the actual WSGI dispatching for the application, may be
472         wrapped during the initialization of the object.
473
474         Call the object directly.
475         """
476         request = werkzeug.wrappers.Request(environ)
477         request.parameter_storage_class = werkzeug.datastructures.ImmutableDict
478         request.app = self
479
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)
487
488         handler = self.find_handler(*(request.path.split('/')[1:]))
489
490         if not handler:
491             response = werkzeug.exceptions.NotFound()
492         else:
493             with session_context(request, self.session_storage, self.session_cookie) as session:
494                 result = handler( request, self.config)
495
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)
499                 else:
500                     response = result
501
502                 if hasattr(response, 'set_cookie'):
503                     response.set_cookie(self.session_cookie, session.sid)
504
505         return response(environ, start_response)
506
507     def _load_addons(self, openerp_addons_namespace=True):
508         """
509         Loads all addons at the specified addons path, returns a mapping of
510         static URLs to the corresponding directories
511         """
512         statics = {}
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)
524                         else:
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:
531                 o = v()
532                 controllers_object[k] = o
533                 if hasattr(o, '_cp_path'):
534                     controllers_path[o._cp_path] = o
535         return statics
536
537     def find_handler(self, *l):
538         """
539         Tries to discover the controller handling the request for the path
540         specified by the provided parameters
541
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``
545         """
546         if l:
547             ps = '/' + '/'.join(l)
548             meth = 'index'
549             while ps:
550                 c = controllers_path.get(ps)
551                 if c:
552                     m = getattr(c, meth)
553                     if getattr(m, 'exposed', False):
554                         _logger.debug("Dispatching to %s %s %s", ps, c, meth)
555                         return m
556                 ps, _slash, meth = ps.rpartition('/')
557         return None
558
559 class LibException(Exception):
560     """ Base of all client lib exceptions """
561     def __init__(self,code=None,message=None):
562         self.code = code
563         self.message = message
564
565 class ApplicationError(LibException):
566     """ maps to code: 1, server side: Exception or openerp.exceptions.DeferredException"""
567
568 class Warning(LibException):
569     """ maps to code: 2, server side: openerp.exceptions.Warning"""
570
571 class AccessError(LibException):
572     """ maps to code: 3, server side:  openerp.exceptions.AccessError"""
573
574 class AccessDenied(LibException):
575     """ maps to code: 4, server side: openerp.exceptions.AccessDenied"""
576
577
578 class LocalConnector(openerplib.Connector):
579     """
580     A type of connector that uses the XMLRPC protocol.
581     """
582     PROTOCOL = 'local'
583
584     def __init__(self):
585         pass
586
587     def send(self, service_name, method, *args):
588         import openerp
589         import traceback
590         import xmlrpclib
591         try:
592             result = openerp.netsvc.dispatch_rpc(service_name, method, args)
593         except Exception,e:
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):
604                 info = e.traceback
605                 formatted_info = "".join(traceback.format_exception(*info))
606                 fault = xmlrpclib.Fault(openerp.tools.ustr(e.message), formatted_info)
607             else:
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)
611             raise fault
612         return result
613