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