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