rm files, move postload hook
[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 mimetypes
11 import os
12 import pprint
13 import sys
14 import threading
15 import time
16 import traceback
17 import urllib
18 import uuid
19 import xmlrpclib
20
21 import simplejson
22 import werkzeug.contrib.sessions
23 import werkzeug.datastructures
24 import werkzeug.exceptions
25 import werkzeug.utils
26 import werkzeug.wrappers
27 import werkzeug.wsgi
28
29 from . import nonliterals
30 from . import session
31 from . import openerplib
32 import urlparse
33
34 __all__ = ['Root', 'jsonrequest', 'httprequest', 'Controller',
35            'WebRequest', 'JsonRequest', 'HttpRequest']
36
37 _logger = logging.getLogger(__name__)
38
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)
46
47     :param request: a wrapped werkzeug Request object
48     :type request: :class:`werkzeug.wrappers.BaseRequest`
49     :param config: configuration object
50
51     .. attribute:: httprequest
52
53         the original :class:`werkzeug.wrappers.Request` object provided to the
54         request
55
56     .. attribute:: httpsession
57
58         a :class:`~collections.Mapping` holding the HTTP session data for the
59         current http session
60
61     .. attribute:: config
62
63         config parameter provided to the request object
64
65     .. attribute:: params
66
67         :class:`~collections.Mapping` of request parameters, not generally
68         useful as they're provided directly to the handler method as keyword
69         arguments
70
71     .. attribute:: session_id
72
73         opaque identifier for the :class:`session.OpenERPSession` instance of
74         the current request
75
76     .. attribute:: session
77
78         :class:`~session.OpenERPSession` instance for the current request
79
80     .. attribute:: context
81
82         :class:`~collections.Mapping` of context values for the current request
83
84     .. attribute:: debug
85
86         ``bool``, indicates whether the debug mode is active on the client
87     """
88     def __init__(self, request, config):
89         self.httprequest = request
90         self.httpresponse = None
91         self.httpsession = request.session
92         self.config = config
93
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)
99         if not self.session:
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
104
105 class JsonRequest(WebRequest):
106     """ JSON-RPC2 over HTTP.
107
108     Sucessful request::
109
110       --> {"jsonrpc": "2.0",
111            "method": "call",
112            "params": {"session_id": "SID",
113                       "context": {},
114                       "arg1": "val1" },
115            "id": null}
116
117       <-- {"jsonrpc": "2.0",
118            "result": { "res1": "val1" },
119            "id": null}
120
121     Request producing a error::
122
123       --> {"jsonrpc": "2.0",
124            "method": "call",
125            "params": {"session_id": "SID",
126                       "context": {},
127                       "arg1": "val1" },
128            "id": null}
129
130       <-- {"jsonrpc": "2.0",
131            "error": {"code": 1,
132                      "message": "End user error message.",
133                      "data": {"code": "codestring",
134                               "debug": "traceback" } },
135            "id": null}
136
137     """
138     def dispatch(self, controller, method):
139         """ Calls the method asked for by the JSON-RPC2 or JSONP request
140
141         :param controller: the instance of the controller which received the request
142         :param method: the method which received the request
143
144         :returns: an utf8 encoded JSON-RPC2 or JSONP reply
145         """
146         args = self.httprequest.args
147         jsonp = args.get('jsonp')
148         requestf = None
149         request = None
150         request_id = args.get('id')
151
152         if jsonp and self.httprequest.method == 'POST':
153             # jsonp 2 steps step1 POST: save call
154             self.init(args)
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)
158             return r
159         elif jsonp and args.get('r'):
160             # jsonp method GET
161             request = args.get('r')
162         elif jsonp and request_id:
163             # jsonp 2 steps step2 GET: run and return result
164             self.init(args)
165             request = self.session.jsonp_requests.pop(request_id, "")
166         else:
167             # regular jsonrpc2
168             requestf = self.httprequest.stream
169
170         response = {"jsonrpc": "2.0" }
171         error = None
172         try:
173             # Read POST content or POST Form Data named "request"
174             if requestf:
175                 self.jsonrequest = simplejson.load(requestf, object_hook=nonliterals.non_literal_decoder)
176             else:
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:
184             error = {
185                 'code': 100,
186                 'message': "OpenERP Session Invalid",
187                 'data': {
188                     'type': 'session_invalid',
189                     'debug': traceback.format_exc()
190                 }
191             }
192         except xmlrpclib.Fault, e:
193             error = {
194                 'code': 200,
195                 'message': "OpenERP Server Error",
196                 'data': {
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)
201                 }
202             }
203         except Exception:
204             logging.getLogger(__name__ + '.JSONRequest.dispatch').exception\
205                 ("An error occured while handling a json request")
206             error = {
207                 'code': 300,
208                 'message': "OpenERP WebClient Error",
209                 'data': {
210                     'type': 'client_exception',
211                     'debug': "Client %s" % traceback.format_exc()
212                 }
213             }
214         if error:
215             response["error"] = error
216
217         if _logger.isEnabledFor(logging.DEBUG):
218             _logger.debug("<--\n%s", pprint.pformat(response))
219
220         if jsonp:
221             mime = 'application/javascript'
222             body = "%s(%s);" % (jsonp, simplejson.dumps(response, cls=nonliterals.NonLiteralEncoder),)
223         else:
224             mime = 'application/json'
225             body = simplejson.dumps(response, cls=nonliterals.NonLiteralEncoder)
226
227         r = werkzeug.wrappers.Response(body, headers=[('Content-Type', mime), ('Content-Length', len(body))])
228         return r
229
230 def jsonrequest(f):
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.
234
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
238     beforehand)
239     """
240     @functools.wraps(f)
241     def json_handler(controller, request, config):
242         return JsonRequest(request, config).dispatch(controller, f)
243     json_handler.exposed = True
244     return json_handler
245
246 class HttpRequest(WebRequest):
247     """ Regular GET/POST request
248     """
249     def dispatch(self, controller, method):
250         params = dict(self.httprequest.args)
251         params.update(self.httprequest.form)
252         params.update(self.httprequest.files)
253         self.init(params)
254         akw = {}
255         for key, value in self.httprequest.args.iteritems():
256             if isinstance(value, basestring) and len(value) < 1024:
257                 akw[key] = value
258             else:
259                 akw[key] = type(value)
260         _logger.debug("%s --> %s.%s %r", self.httprequest.method, controller.__class__.__name__, method.__name__, akw)
261         try:
262             r = method(controller, self, **self.params)
263         except xmlrpclib.Fault, e:
264             r = werkzeug.exceptions.InternalServerError(cgi.escape(simplejson.dumps({
265                 'code': 200,
266                 'message': "OpenERP Server Error",
267                 'data': {
268                     'type': 'server_exception',
269                     'fault_code': e.faultCode,
270                     'debug': "Server %s\nClient %s" % (
271                         e.faultString, traceback.format_exc())
272                 }
273             })))
274         except Exception:
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({
278                 'code': 300,
279                 'message': "OpenERP WebClient Error",
280                 'data': {
281                     'type': 'client_exception',
282                     'debug': "Client %s" % traceback.format_exc()
283                 }
284             })))
285         if self.debug or 1:
286             if isinstance(r, (werkzeug.wrappers.BaseResponse, werkzeug.exceptions.HTTPException)):
287                 _logger.debug('<-- %s', r)
288             else:
289                 _logger.debug("<-- size: %s", len(r))
290         return r
291
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.
295
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.
300
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
305         """
306         response = werkzeug.wrappers.Response(data, headers=headers)
307         if cookies:
308             for k, v in cookies.iteritems():
309                 response.set_cookie(k, v)
310         return response
311
312     def not_found(self, description=None):
313         """ Helper for 404 response, return its result from the method
314         """
315         return werkzeug.exceptions.NotFound(description)
316
317 def httprequest(f):
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.
321
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)
326     """
327     @functools.wraps(f)
328     def http_handler(controller, request, config):
329         return HttpRequest(request, config).dispatch(controller, f)
330     http_handler.exposed = True
331     return http_handler
332
333 #----------------------------------------------------------
334 # OpenERP Web Controller registration with a metaclass
335 #----------------------------------------------------------
336 addons_module = {}
337 addons_manifest = {}
338 controllers_class = []
339 controllers_object = {}
340 controllers_path = {}
341
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))
346
347 class Controller(object):
348     __metaclass__ = ControllerType
349
350 #----------------------------------------------------------
351 # OpenERP Web Session context manager
352 #----------------------------------------------------------
353 STORES = {}
354
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
362
363     sid = request.cookies.get(session_cookie)
364     with session_lock:
365         if sid:
366             request.session = session_store.get(sid)
367         else:
368             request.session = session_store.new()
369
370     try:
371         yield request.session
372     finally:
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):
379                 continue
380             if getattr(value, '_suicide', False) or (
381                         not value._uid
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]
388
389         with session_lock:
390             if sid:
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)
398                 #
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', {}))
413
414                 # add missing keys
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
418
419             session_store.save(request.session)
420
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):
430         self.app = 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
436
437             new_headers = []
438             unwanted_keys = ['Last-Modified']
439             if debug:
440                 new_headers = [('Cache-Control', 'no-cache')]
441                 unwanted_keys += ['Expires', 'Etag', 'Cache-Control']
442
443             for k, v in headers:
444                 if k not in unwanted_keys:
445                     new_headers.append((k, v))
446
447             start_response(status, new_headers)
448         return self.app(environ, start_wrapped)
449
450 class Root(object):
451     """Root WSGI application for the OpenERP Web Client.
452
453     :param options: mandatory initialization options object, must provide
454                     the following attributes:
455
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
468     """
469     def __init__(self, options):
470         self.config = options
471
472         if not hasattr(self.config, 'connector'):
473             if self.config.backend == 'local':
474                 self.config.connector = session.LocalConnector()
475             else:
476                 self.config.connector = openerplib.get_connector(
477                     hostname=self.config.server_host, port=self.config.server_port)
478
479         self.httpsession_cookie = 'httpsessionid'
480         self.addons = {}
481
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)
486
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)
492
493     def __call__(self, environ, start_response):
494         """ Handle a WSGI request
495         """
496         return self.dispatch(environ, start_response)
497
498     def dispatch(self, environ, start_response):
499         """
500         Performs the actual WSGI dispatching for the application, may be
501         wrapped during the initialization of the object.
502
503         Call the object directly.
504         """
505         request = werkzeug.wrappers.Request(environ)
506         request.parameter_storage_class = werkzeug.datastructures.ImmutableDict
507         request.app = self
508
509         handler = self.find_handler(*(request.path.split('/')[1:]))
510
511         if not handler:
512             response = werkzeug.exceptions.NotFound()
513         else:
514             with session_context(request, self.session_storage, self.httpsession_cookie) as session:
515                 result = handler( request, self.config)
516
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)
520                 else:
521                     response = result
522
523                 if hasattr(response, 'set_cookie'):
524                     response.set_cookie(self.httpsession_cookie, session.sid)
525
526         return response(environ, start_response)
527
528     def _load_addons(self):
529         """
530         Loads all addons at the specified addons path, returns a mapping of
531         static URLs to the corresponding directories
532         """
533         statics = {}
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)
545                         else:
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:
552                 o = v()
553                 controllers_object[k] = o
554                 if hasattr(o, '_cp_path'):
555                     controllers_path[o._cp_path] = o
556         return statics
557
558     def find_handler(self, *l):
559         """
560         Tries to discover the controller handling the request for the path
561         specified by the provided parameters
562
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``
566         """
567         if l:
568             ps = '/' + '/'.join(l)
569             meth = 'index'
570             while ps:
571                 c = controllers_path.get(ps)
572                 if c:
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)
576                         return m
577                 ps, _slash, meth = ps.rpartition('/')
578                 if not ps and meth:
579                     ps = '/'
580         return None
581
582 class Options(object):
583     pass
584
585 def wsgi_postload():
586     import openerp
587     import tempfile
588     import getpass
589     _logger.info("embedded mode")
590     o = Options()
591     o.dbfilter = openerp.tools.config['dbfilter']
592     o.server_wide_modules = openerp.conf.server_wide_modules or ['web']
593     try:
594         username = getpass.getuser()
595     except Exception:
596         username = "unknown"
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
600     o.backend = 'local'
601
602     app = Root(o)
603     openerp.wsgi.register_wsgi_handler(app)
604
605
606 # vim:et:ts=4:sw=4: