[IMP] account: removed duplicated entry for journal items to reconcile
[odoo/odoo.git] / addons / web / 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 getpass
10 import logging
11 import mimetypes
12 import os
13 import pprint
14 import random
15 import sys
16 import tempfile
17 import threading
18 import time
19 import traceback
20 import urllib
21 import urlparse
22 import uuid
23 import xmlrpclib
24
25 import babel.core
26 import simplejson
27 import werkzeug.contrib.sessions
28 import werkzeug.datastructures
29 import werkzeug.exceptions
30 import werkzeug.utils
31 import werkzeug.wrappers
32 import werkzeug.wsgi
33
34 import openerp
35
36 import nonliterals
37 import session
38
39 _logger = logging.getLogger(__name__)
40
41 #----------------------------------------------------------
42 # RequestHandler
43 #----------------------------------------------------------
44 class WebRequest(object):
45     """ Parent class for all OpenERP Web request types, mostly deals with
46     initialization and setup of the request object (the dispatching itself has
47     to be handled by the subclasses)
48
49     :param request: a wrapped werkzeug Request object
50     :type request: :class:`werkzeug.wrappers.BaseRequest`
51
52     .. attribute:: httprequest
53
54         the original :class:`werkzeug.wrappers.Request` object provided to the
55         request
56
57     .. attribute:: httpsession
58
59         a :class:`~collections.Mapping` holding the HTTP session data for the
60         current http session
61
62     .. attribute:: params
63
64         :class:`~collections.Mapping` of request parameters, not generally
65         useful as they're provided directly to the handler method as keyword
66         arguments
67
68     .. attribute:: session_id
69
70         opaque identifier for the :class:`session.OpenERPSession` instance of
71         the current request
72
73     .. attribute:: session
74
75         :class:`~session.OpenERPSession` instance for the current request
76
77     .. attribute:: context
78
79         :class:`~collections.Mapping` of context values for the current request
80
81     .. attribute:: debug
82
83         ``bool``, indicates whether the debug mode is active on the client
84     """
85     def __init__(self, request):
86         self.httprequest = request
87         self.httpresponse = None
88         self.httpsession = request.session
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.get(self.session_id)
95         if not self.session:
96             self.session = session.OpenERPSession()
97             self.httpsession[self.session_id] = self.session
98         self.context = self.params.pop('context', None)
99         self.debug = self.params.pop('debug', False) is not False
100         # Determine self.lang
101         lang = self.params.get('lang', None)
102         if lang is None:
103             lang = self.session.eval_context(self.context).get('lang')
104         if lang is None:
105             lang = self.httprequest.cookies.get('lang')
106         if lang is None:
107             lang = self.httprequest.accept_languages.best
108         if lang is None:
109             lang = 'en_US'
110         # tranform 2 letters lang like 'en' into 5 letters like 'en_US'
111         lang = babel.core.LOCALE_ALIASES.get(lang, lang)
112         # we use _ as seprator where RFC2616 uses '-'
113         self.lang = lang.replace('-', '_')
114
115
116 class JsonRequest(WebRequest):
117     """ JSON-RPC2 over HTTP.
118
119     Sucessful request::
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            "result": { "res1": "val1" },
130            "id": null}
131
132     Request producing a error::
133
134       --> {"jsonrpc": "2.0",
135            "method": "call",
136            "params": {"session_id": "SID",
137                       "context": {},
138                       "arg1": "val1" },
139            "id": null}
140
141       <-- {"jsonrpc": "2.0",
142            "error": {"code": 1,
143                      "message": "End user error message.",
144                      "data": {"code": "codestring",
145                               "debug": "traceback" } },
146            "id": null}
147
148     """
149     def dispatch(self, method):
150         """ Calls the method asked for by the JSON-RPC2 or JSONP request
151
152         :param method: the method which received the request
153
154         :returns: an utf8 encoded JSON-RPC2 or JSONP reply
155         """
156         args = self.httprequest.args
157         jsonp = args.get('jsonp')
158         requestf = None
159         request = None
160         request_id = args.get('id')
161
162         if jsonp and self.httprequest.method == 'POST':
163             # jsonp 2 steps step1 POST: save call
164             self.init(args)
165             self.session.jsonp_requests[request_id] = self.httprequest.form['r']
166             headers=[('Content-Type', 'text/plain; charset=utf-8')]
167             r = werkzeug.wrappers.Response(request_id, headers=headers)
168             return r
169         elif jsonp and args.get('r'):
170             # jsonp method GET
171             request = args.get('r')
172         elif jsonp and request_id:
173             # jsonp 2 steps step2 GET: run and return result
174             self.init(args)
175             request = self.session.jsonp_requests.pop(request_id, "")
176         else:
177             # regular jsonrpc2
178             requestf = self.httprequest.stream
179
180         response = {"jsonrpc": "2.0" }
181         error = None
182         try:
183             # Read POST content or POST Form Data named "request"
184             if requestf:
185                 self.jsonrequest = simplejson.load(requestf, object_hook=nonliterals.non_literal_decoder)
186             else:
187                 self.jsonrequest = simplejson.loads(request, object_hook=nonliterals.non_literal_decoder)
188             self.init(self.jsonrequest.get("params", {}))
189             if _logger.isEnabledFor(logging.DEBUG):
190                 _logger.debug("--> %s.%s\n%s", method.im_class.__name__, method.__name__, pprint.pformat(self.jsonrequest))
191             response['id'] = self.jsonrequest.get('id')
192             response["result"] = method(self, **self.params)
193         except session.AuthenticationError:
194             error = {
195                 'code': 100,
196                 'message': "OpenERP Session Invalid",
197                 'data': {
198                     'type': 'session_invalid',
199                     'debug': traceback.format_exc()
200                 }
201             }
202         except xmlrpclib.Fault, e:
203             error = {
204                 'code': 200,
205                 'message': "OpenERP Server Error",
206                 'data': {
207                     'type': 'server_exception',
208                     'fault_code': e.faultCode,
209                     'debug': "Client %s\nServer %s" % (
210                     "".join(traceback.format_exception("", None, sys.exc_traceback)), e.faultString)
211                 }
212             }
213         except Exception:
214             logging.getLogger(__name__ + '.JSONRequest.dispatch').exception\
215                 ("An error occured while handling a json request")
216             error = {
217                 'code': 300,
218                 'message': "OpenERP WebClient Error",
219                 'data': {
220                     'type': 'client_exception',
221                     'debug': "Client %s" % traceback.format_exc()
222                 }
223             }
224         if error:
225             response["error"] = error
226
227         if _logger.isEnabledFor(logging.DEBUG):
228             _logger.debug("<--\n%s", pprint.pformat(response))
229
230         if jsonp:
231             # If we use jsonp, that's mean we are called from another host
232             # Some browser (IE and Safari) do no allow third party cookies
233             # We need then to manage http sessions manually.
234             response['httpsessionid'] = self.httpsession.sid
235             mime = 'application/javascript'
236             body = "%s(%s);" % (jsonp, simplejson.dumps(response, cls=nonliterals.NonLiteralEncoder),)
237         else:
238             mime = 'application/json'
239             body = simplejson.dumps(response, cls=nonliterals.NonLiteralEncoder)
240
241         r = werkzeug.wrappers.Response(body, headers=[('Content-Type', mime), ('Content-Length', len(body))])
242         return r
243
244 def jsonrequest(f):
245     """ Decorator marking the decorated method as being a handler for a
246     JSON-RPC request (the exact request path is specified via the
247     ``$(Controller._cp_path)/$methodname`` combination.
248
249     If the method is called, it will be provided with a :class:`JsonRequest`
250     instance and all ``params`` sent during the JSON-RPC request, apart from
251     the ``session_id``, ``context`` and ``debug`` keys (which are stripped out
252     beforehand)
253     """
254     f.exposed = 'json'
255     return f
256
257 class HttpRequest(WebRequest):
258     """ Regular GET/POST request
259     """
260     def dispatch(self, method):
261         params = dict(self.httprequest.args)
262         params.update(self.httprequest.form)
263         params.update(self.httprequest.files)
264         self.init(params)
265         akw = {}
266         for key, value in self.httprequest.args.iteritems():
267             if isinstance(value, basestring) and len(value) < 1024:
268                 akw[key] = value
269             else:
270                 akw[key] = type(value)
271         _logger.debug("%s --> %s.%s %r", self.httprequest.method, method.im_class.__name__, method.__name__, akw)
272         try:
273             r = method(self, **self.params)
274         except xmlrpclib.Fault, e:
275             r = werkzeug.exceptions.InternalServerError(cgi.escape(simplejson.dumps({
276                 'code': 200,
277                 'message': "OpenERP Server Error",
278                 'data': {
279                     'type': 'server_exception',
280                     'fault_code': e.faultCode,
281                     'debug': "Server %s\nClient %s" % (
282                         e.faultString, traceback.format_exc())
283                 }
284             })))
285         except Exception:
286             logging.getLogger(__name__ + '.HttpRequest.dispatch').exception(
287                     "An error occurred while handling a json request")
288             r = werkzeug.exceptions.InternalServerError(cgi.escape(simplejson.dumps({
289                 'code': 300,
290                 'message': "OpenERP WebClient Error",
291                 'data': {
292                     'type': 'client_exception',
293                     'debug': "Client %s" % traceback.format_exc()
294                 }
295             })))
296         if self.debug or 1:
297             if isinstance(r, (werkzeug.wrappers.BaseResponse, werkzeug.exceptions.HTTPException)):
298                 _logger.debug('<-- %s', r)
299             else:
300                 _logger.debug("<-- size: %s", len(r))
301         return r
302
303     def make_response(self, data, headers=None, cookies=None):
304         """ Helper for non-HTML responses, or HTML responses with custom
305         response headers or cookies.
306
307         While handlers can just return the HTML markup of a page they want to
308         send as a string if non-HTML data is returned they need to create a
309         complete response object, or the returned data will not be correctly
310         interpreted by the clients.
311
312         :param basestring data: response body
313         :param headers: HTTP headers to set on the response
314         :type headers: ``[(name, value)]``
315         :param collections.Mapping cookies: cookies to set on the client
316         """
317         response = werkzeug.wrappers.Response(data, headers=headers)
318         if cookies:
319             for k, v in cookies.iteritems():
320                 response.set_cookie(k, v)
321         return response
322
323     def not_found(self, description=None):
324         """ Helper for 404 response, return its result from the method
325         """
326         return werkzeug.exceptions.NotFound(description)
327
328 def httprequest(f):
329     """ Decorator marking the decorated method as being a handler for a
330     normal HTTP request (the exact request path is specified via the
331     ``$(Controller._cp_path)/$methodname`` combination.
332
333     If the method is called, it will be provided with a :class:`HttpRequest`
334     instance and all ``params`` sent during the request (``GET`` and ``POST``
335     merged in the same dictionary), apart from the ``session_id``, ``context``
336     and ``debug`` keys (which are stripped out beforehand)
337     """
338     f.exposed = 'http'
339     return f
340
341 #----------------------------------------------------------
342 # Controller registration with a metaclass
343 #----------------------------------------------------------
344 addons_module = {}
345 addons_manifest = {}
346 controllers_class = []
347 controllers_object = {}
348 controllers_path = {}
349
350 class ControllerType(type):
351     def __init__(cls, name, bases, attrs):
352         super(ControllerType, cls).__init__(name, bases, attrs)
353         controllers_class.append(("%s.%s" % (cls.__module__, cls.__name__), cls))
354
355 class Controller(object):
356     __metaclass__ = ControllerType
357
358 #----------------------------------------------------------
359 # Session context manager
360 #----------------------------------------------------------
361 @contextlib.contextmanager
362 def session_context(request, session_store, session_lock, sid):
363     with session_lock:
364         if sid:
365             request.session = session_store.get(sid)
366         else:
367             request.session = session_store.new()
368     try:
369         yield request.session
370     finally:
371         # Remove all OpenERPSession instances with no uid, they're generated
372         # either by login process or by HTTP requests without an OpenERP
373         # session id, and are generally noise
374         removed_sessions = set()
375         for key, value in request.session.items():
376             if not isinstance(value, session.OpenERPSession):
377                 continue
378             if getattr(value, '_suicide', False) or (
379                         not value._uid
380                     and not value.jsonp_requests
381                     # FIXME do not use a fixed value
382                     and value._creation_time + (60*5) < time.time()):
383                 _logger.debug('remove session %s', key)
384                 removed_sessions.add(key)
385                 del request.session[key]
386
387         with session_lock:
388             if sid:
389                 # Re-load sessions from storage and merge non-literal
390                 # contexts and domains (they're indexed by hash of the
391                 # content so conflicts should auto-resolve), otherwise if
392                 # two requests alter those concurrently the last to finish
393                 # will overwrite the previous one, leading to loss of data
394                 # (a non-literal is lost even though it was sent to the
395                 # client and client errors)
396                 #
397                 # note that domains_store and contexts_store are append-only (we
398                 # only ever add items to them), so we can just update one with the
399                 # other to get the right result, if we want to merge the
400                 # ``context`` dict we'll need something smarter
401                 in_store = session_store.get(sid)
402                 for k, v in request.session.iteritems():
403                     stored = in_store.get(k)
404                     if stored and isinstance(v, session.OpenERPSession):
405                         v.contexts_store.update(stored.contexts_store)
406                         v.domains_store.update(stored.domains_store)
407                         if not hasattr(v, 'jsonp_requests'):
408                             v.jsonp_requests = {}
409                         v.jsonp_requests.update(getattr(
410                             stored, 'jsonp_requests', {}))
411
412                 # add missing keys
413                 for k, v in in_store.iteritems():
414                     if k not in request.session and k not in removed_sessions:
415                         request.session[k] = v
416
417             session_store.save(request.session)
418
419 def session_gc(session_store):
420     if random.random() < 0.001:
421         # we keep session one week
422         last_week = time.time() - 60*60*24*7
423         for fname in os.listdir(session_store.path):
424             path = os.path.join(session_store.path, fname)
425             try:
426                 if os.path.getmtime(path) < last_week:
427                     os.unlink(path)
428             except OSError:
429                 pass
430
431 #----------------------------------------------------------
432 # WSGI Application
433 #----------------------------------------------------------
434 # Add potentially missing (older ubuntu) font mime types
435 mimetypes.add_type('application/font-woff', '.woff')
436 mimetypes.add_type('application/vnd.ms-fontobject', '.eot')
437 mimetypes.add_type('application/x-font-ttf', '.ttf')
438
439 class DisableCacheMiddleware(object):
440     def __init__(self, app):
441         self.app = app
442     def __call__(self, environ, start_response):
443         def start_wrapped(status, headers):
444             referer = environ.get('HTTP_REFERER', '')
445             parsed = urlparse.urlparse(referer)
446             debug = parsed.query.count('debug') >= 1
447
448             new_headers = []
449             unwanted_keys = ['Last-Modified']
450             if debug:
451                 new_headers = [('Cache-Control', 'no-cache')]
452                 unwanted_keys += ['Expires', 'Etag', 'Cache-Control']
453
454             for k, v in headers:
455                 if k not in unwanted_keys:
456                     new_headers.append((k, v))
457
458             start_response(status, new_headers)
459         return self.app(environ, start_wrapped)
460
461 def session_path():
462     try:
463         username = getpass.getuser()
464     except Exception:
465         username = "unknown"
466     path = os.path.join(tempfile.gettempdir(), "oe-sessions-" + username)
467     if not os.path.exists(path):
468         os.mkdir(path, 0700)
469     return path
470
471 class Root(object):
472     """Root WSGI application for the OpenERP Web Client.
473     """
474     def __init__(self):
475         self.addons = {}
476
477         static_dirs = self._load_addons()
478         app = werkzeug.wsgi.SharedDataMiddleware( self.dispatch, static_dirs)
479         self.dispatch = DisableCacheMiddleware(app)
480
481         # Setup http sessions
482         path = session_path()
483         self.session_store = werkzeug.contrib.sessions.FilesystemSessionStore(path)
484         self.session_lock = threading.Lock()
485         _logger.debug('HTTP sessions stored in: %s', path)
486
487     def __call__(self, environ, start_response):
488         """ Handle a WSGI request
489         """
490         return self.dispatch(environ, start_response)
491
492     def dispatch(self, environ, start_response):
493         """
494         Performs the actual WSGI dispatching for the application, may be
495         wrapped during the initialization of the object.
496
497         Call the object directly.
498         """
499         request = werkzeug.wrappers.Request(environ)
500         request.parameter_storage_class = werkzeug.datastructures.ImmutableDict
501         request.app = self
502
503         handler = self.find_handler(*(request.path.split('/')[1:]))
504
505         if not handler:
506             response = werkzeug.exceptions.NotFound()
507         else:
508             sid = request.cookies.get('sid')
509             if not sid:
510                 sid = request.args.get('sid')
511
512             session_gc(self.session_store)
513
514             with session_context(request, self.session_store, self.session_lock, sid) as session:
515                 result = handler(request)
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('sid', 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 openerp.modules.module.ad_paths:
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(filter(None, l))
569             method_name = 'index'
570             while ps:
571                 c = controllers_path.get(ps)
572                 if c:
573                     method = getattr(c, method_name, None)
574                     if method:
575                         exposed = getattr(method, 'exposed', False)
576                         if exposed == 'json':
577                             _logger.debug("Dispatch json to %s %s %s", ps, c, method_name)
578                             return lambda request: JsonRequest(request).dispatch(method)
579                         elif exposed == 'http':
580                             _logger.debug("Dispatch http to %s %s %s", ps, c, method_name)
581                             return lambda request: HttpRequest(request).dispatch(method)
582                 ps, _slash, method_name = ps.rpartition('/')
583                 if not ps and method_name:
584                     ps = '/'
585         return None
586
587 def wsgi_postload():
588     openerp.wsgi.register_wsgi_handler(Root())
589
590 # vim:et:ts=4:sw=4: