[IMP] Fix the issue of warning
[odoo/odoo.git] / addons / web / common / http.py
1 # -*- coding: utf-8 -*-
2 #----------------------------------------------------------
3 # OpenERP Web HTTP layer
4 #----------------------------------------------------------
5 import ast
6 import contextlib
7 import functools
8 import logging
9 import os
10 import pprint
11 import sys
12 import threading
13 import traceback
14 import urllib
15 import uuid
16 import xmlrpclib
17
18 import simplejson
19 import werkzeug.contrib.sessions
20 import werkzeug.datastructures
21 import werkzeug.exceptions
22 import werkzeug.utils
23 import werkzeug.wrappers
24 import werkzeug.wsgi
25
26 import nonliterals
27 import session
28 import openerplib
29
30 __all__ = ['Root', 'jsonrequest', 'httprequest', 'Controller',
31            'WebRequest', 'JsonRequest', 'HttpRequest']
32
33 _logger = logging.getLogger(__name__)
34
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)
42
43     :param request: a wrapped werkzeug Request object
44     :type request: :class:`werkzeug.wrappers.BaseRequest`
45     :param config: configuration object
46
47     .. attribute:: httprequest
48
49         the original :class:`werkzeug.wrappers.Request` object provided to the
50         request
51
52     .. attribute:: httpsession
53
54         a :class:`~collections.Mapping` holding the HTTP session data for the
55         current http session
56
57     .. attribute:: config
58
59         config parameter provided to the request object
60
61     .. attribute:: params
62
63         :class:`~collections.Mapping` of request parameters, not generally
64         useful as they're provided directly to the handler method as keyword
65         arguments
66
67     .. attribute:: session_id
68
69         opaque identifier for the :class:`session.OpenERPSession` instance of
70         the current request
71
72     .. attribute:: session
73
74         :class:`~session.OpenERPSession` instance for the current request
75
76     .. attribute:: context
77
78         :class:`~collections.Mapping` of context values for the current request
79
80     .. attribute:: debug
81
82         ``bool``, indicates whether the debug mode is active on the client
83     """
84     def __init__(self, request, config):
85         self.httprequest = request
86         self.httpresponse = None
87         self.httpsession = request.session
88         self.config = config
89
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
98
99 class JsonRequest(WebRequest):
100     """ JSON-RPC2 over HTTP.
101
102     Sucessful request::
103
104       --> {"jsonrpc": "2.0",
105            "method": "call",
106            "params": {"session_id": "SID",
107                       "context": {},
108                       "arg1": "val1" },
109            "id": null}
110
111       <-- {"jsonrpc": "2.0",
112            "result": { "res1": "val1" },
113            "id": null}
114
115     Request producing a error::
116
117       --> {"jsonrpc": "2.0",
118            "method": "call",
119            "params": {"session_id": "SID",
120                       "context": {},
121                       "arg1": "val1" },
122            "id": null}
123
124       <-- {"jsonrpc": "2.0",
125            "error": {"code": 1,
126                      "message": "End user error message.",
127                      "data": {"code": "codestring",
128                               "debug": "traceback" } },
129            "id": null}
130
131     """
132     def dispatch(self, controller, method):
133         """ Calls the method asked for by the JSON-RPC2 or JSONP request
134
135         :param controller: the instance of the controller which received the request
136         :param method: the method which received the request
137
138         :returns: an utf8 encoded JSON-RPC2 or JSONP reply
139         """
140         args = self.httprequest.args
141         jsonp = args.get('jsonp')
142         requestf = None
143         request = None
144
145         if jsonp and self.httprequest.method == 'POST':
146             # jsonp 2 steps step1 POST: save call
147             self.init(args)
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)
151             return r
152         elif jsonp and args.get('r'):
153             # jsonp method GET
154             request = args.get('r')
155         elif jsonp and args.get('id'):
156             # jsonp 2 steps step2 GET: run and return result
157             self.init(args)
158             request = self.session.jsonp_requests.pop(args.get(id), "")
159         else:
160             # regular jsonrpc2
161             requestf = self.httprequest.stream
162
163         response = {"jsonrpc": "2.0" }
164         error = None
165         try:
166             # Read POST content or POST Form Data named "request"
167             if requestf:
168                 self.jsonrequest = simplejson.load(requestf, object_hook=nonliterals.non_literal_decoder)
169             else:
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:
177             error = {
178                 'code': 100,
179                 'message': "OpenERP Session Invalid",
180                 'data': {
181                     'type': 'session_invalid',
182                     'debug': traceback.format_exc()
183                 }
184             }
185         except xmlrpclib.Fault, e:
186             error = {
187                 'code': 200,
188                 'message': "OpenERP Server Error",
189                 'data': {
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)
194                 }
195             }
196         except Exception:
197             logging.getLogger(__name__ + '.JSONRequest.dispatch').exception\
198                 ("An error occured while handling a json request")
199             error = {
200                 'code': 300,
201                 'message': "OpenERP WebClient Error",
202                 'data': {
203                     'type': 'client_exception',
204                     'debug': "Client %s" % traceback.format_exc()
205                 }
206             }
207         if error:
208             response["error"] = error
209
210         if _logger.isEnabledFor(logging.DEBUG):
211             _logger.debug("<--\n%s", pprint.pformat(response))
212
213         if jsonp:
214             mime = 'application/javascript'
215             body = "%s(%s);" % (jsonp, simplejson.dumps(response, cls=nonliterals.NonLiteralEncoder),)
216         else:
217             mime = 'application/json'
218             body = simplejson.dumps(response, cls=nonliterals.NonLiteralEncoder)
219
220         r = werkzeug.wrappers.Response(body, headers=[('Content-Type', mime), ('Content-Length', len(body))])
221         return r
222
223 def jsonrequest(f):
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.
227
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
231     beforehand)
232     """
233     @functools.wraps(f)
234     def json_handler(controller, request, config):
235         return JsonRequest(request, config).dispatch(controller, f)
236     json_handler.exposed = True
237     return json_handler
238
239 class HttpRequest(WebRequest):
240     """ Regular GET/POST request
241     """
242     def dispatch(self, controller, method):
243         params = dict(self.httprequest.args)
244         params.update(self.httprequest.form)
245         params.update(self.httprequest.files)
246         self.init(params)
247         akw = {}
248         for key, value in self.httprequest.args.iteritems():
249             if isinstance(value, basestring) and len(value) < 1024:
250                 akw[key] = value
251             else:
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)
255         if self.debug or 1:
256             if isinstance(r, (werkzeug.wrappers.BaseResponse, werkzeug.exceptions.HTTPException)):
257                 _logger.debug('<-- %s', r)
258             else:
259                 _logger.debug("<-- size: %s", len(r))
260         return r
261
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.
265
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.
270
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
275         """
276         response = werkzeug.wrappers.Response(data, headers=headers)
277         if cookies:
278             for k, v in cookies.iteritems():
279                 response.set_cookie(k, v)
280         return response
281
282     def not_found(self, description=None):
283         """ Helper for 404 response, return its result from the method
284         """
285         return werkzeug.exceptions.NotFound(description)
286
287 def httprequest(f):
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.
291
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)
296     """
297     @functools.wraps(f)
298     def http_handler(controller, request, config):
299         return HttpRequest(request, config).dispatch(controller, f)
300     http_handler.exposed = True
301     return http_handler
302
303 #----------------------------------------------------------
304 # OpenERP Web werkzeug Session Managment wraped using with
305 #----------------------------------------------------------
306 STORES = {}
307
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(
313             storage_path)
314         session_lock = threading.Lock()
315         STORES[storage_path] = session_store, session_lock
316
317     sid = request.cookies.get(session_cookie)
318     with session_lock:
319         if sid:
320             request.session = session_store.get(sid)
321         else:
322             request.session = session_store.new()
323
324     try:
325         yield request.session
326     finally:
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) 
332                 and not value._uid
333                 and not value.jsonp_requests
334             ):
335                 _logger.info('remove session %s: %r', key, value.jsonp_requests)
336                 del request.session[key]
337
338         with session_lock:
339             if sid:
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)
347                 #
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', {}))
362
363                 # add missing keys
364                 for k, v in in_store.iteritems():
365                     if k not in request.session:
366                         request.session[k] = v
367
368             session_store.save(request.session)
369
370 #----------------------------------------------------------
371 # OpenERP Web Module/Controller Loading and URL Routing
372 #----------------------------------------------------------
373 addons_module = {}
374 addons_manifest = {}
375 controllers_class = {}
376 controllers_object = {}
377 controllers_path = {}
378
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
383
384 class Controller(object):
385     __metaclass__ = ControllerType
386
387 class Root(object):
388     """Root WSGI application for the OpenERP Web Client.
389
390     :param options: mandatory initialization options object, must provide
391                     the following attributes:
392
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
405     """
406     def __init__(self, options):
407         self.root = '/web/webclient/home'
408         self.config = options
409
410         if self.config.backend == 'local':
411             conn = LocalConnector()
412         else:
413             conn = openerplib.get_connector(hostname=self.config.server_host,
414                    port=self.config.server_port)
415         self.config.connector = conn
416
417         self.session_cookie = 'sessionid'
418         self.addons = {}
419
420         static_dirs = self._load_addons()
421         if options.serve_static:
422             self.dispatch = werkzeug.wsgi.SharedDataMiddleware(
423                 self.dispatch, static_dirs)
424
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)
430
431     def __call__(self, environ, start_response):
432         """ Handle a WSGI request
433         """
434         return self.dispatch(environ, start_response)
435
436     def dispatch(self, environ, start_response):
437         """
438         Performs the actual WSGI dispatching for the application, may be
439         wrapped during the initialization of the object.
440
441         Call the object directly.
442         """
443         request = werkzeug.wrappers.Request(environ)
444         request.parameter_storage_class = werkzeug.datastructures.ImmutableDict
445         request.app = self
446
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)
454
455         handler = self.find_handler(*(request.path.split('/')[1:]))
456
457         if not handler:
458             response = werkzeug.exceptions.NotFound()
459         else:
460             with session_context(request, self.session_storage, self.session_cookie) as session:
461                 result = handler( request, self.config)
462
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)
466                 else:
467                     response = result
468
469                 if hasattr(response, 'set_cookie'):
470                     response.set_cookie(self.session_cookie, session.sid)
471
472         return response(environ, start_response)
473
474     def _load_addons(self):
475         """
476         Loads all addons at the specified addons path, returns a mapping of
477         static URLs to the corresponding directories
478         """
479         statics = {}
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:
497                 o = v()
498                 controllers_object[k] = o
499                 if hasattr(o, '_cp_path'):
500                     controllers_path[o._cp_path] = o
501         return statics
502
503     def find_handler(self, *l):
504         """
505         Tries to discover the controller handling the request for the path
506         specified by the provided parameters
507
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``
511         """
512         if len(l):
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']
518                     meth = rest[0]
519                     m = getattr(c, meth)
520                     if getattr(m, 'exposed', False):
521                         _logger.debug("Dispatching to %s %s %s", ps, c, meth)
522                         return m
523         return None
524
525 class LibException(Exception):
526     """ Base of all client lib exceptions """
527     def __init__(self,code=None,message=None):
528         self.code = code
529         self.message = message
530
531 class ApplicationError(LibException):
532     """ maps to code: 1, server side: Exception or openerp.exceptions.DeferredException"""
533
534 class Warning(LibException):
535     """ maps to code: 2, server side: openerp.exceptions.Warning"""
536
537 class AccessError(LibException):
538     """ maps to code: 3, server side:  openerp.exceptions.AccessError"""
539
540 class AccessDenied(LibException):
541     """ maps to code: 4, server side: openerp.exceptions.AccessDenied"""
542
543
544 class LocalConnector(openerplib.Connector):
545     """
546     A type of connector that uses the XMLRPC protocol.
547     """
548     PROTOCOL = 'local'
549
550     def __init__(self):
551         pass
552
553     def send(self, service_name, method, *args):
554         import openerp
555         import traceback
556         import xmlrpclib
557         try:
558             result = openerp.netsvc.dispatch_rpc(service_name, method, args)
559         except Exception,e:
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):
570                 info = e.traceback
571                 formatted_info = "".join(traceback.format_exception(*info))
572                 fault = xmlrpclib.Fault(openerp.tools.ustr(e.message), formatted_info)
573             else:
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)
577             raise fault
578         return result
579