[MERGE] lp:912793 (account/demo: fix last day of February period)
[odoo/odoo.git] / openerp / wsgi.py
1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #    Copyright (C) 2011 OpenERP s.a. (<http://openerp.com>).
6 #
7 #    This program is free software: you can redistribute it and/or modify
8 #    it under the terms of the GNU Affero General Public License as
9 #    published by the Free Software Foundation, either version 3 of the
10 #    License, or (at your option) any later version.
11 #
12 #    This program is distributed in the hope that it will be useful,
13 #    but WITHOUT ANY WARRANTY; without even the implied warranty of
14 #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 #    GNU Affero General Public License for more details.
16 #
17 #    You should have received a copy of the GNU Affero General Public License
18 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
19 #
20 ##############################################################################
21
22 """ WSGI stuffs (proof of concept for now)
23
24 This module offers a WSGI interface to OpenERP.
25
26 """
27
28 import httplib
29 import urllib
30 import xmlrpclib
31 import StringIO
32
33 import errno
34 import logging
35 import os
36 import signal
37 import sys
38 import threading
39 import traceback
40
41 import openerp
42 import openerp.modules
43 import openerp.tools.config as config
44 import service.websrv_lib as websrv_lib
45
46 # XML-RPC fault codes. Some care must be taken when changing these: the
47 # constants are also defined client-side and must remain in sync.
48 # User code must use the exceptions defined in ``openerp.exceptions`` (not
49 # create directly ``xmlrpclib.Fault`` objects).
50 RPC_FAULT_CODE_CLIENT_ERROR = 1 # indistinguishable from app. error.
51 RPC_FAULT_CODE_APPLICATION_ERROR = 1
52 RPC_FAULT_CODE_WARNING = 2
53 RPC_FAULT_CODE_ACCESS_DENIED = 3
54 RPC_FAULT_CODE_ACCESS_ERROR = 4
55
56 # The new (6.1) versioned RPC paths.
57 XML_RPC_PATH = '/openerp/xmlrpc'
58 XML_RPC_PATH_1 = '/openerp/xmlrpc/1'
59 JSON_RPC_PATH = '/openerp/jsonrpc'
60 JSON_RPC_PATH_1 = '/openerp/jsonrpc/1'
61
62 def xmlrpc_return(start_response, service, method, params, legacy_exceptions=False):
63     """
64     Helper to call a service's method with some params, using a wsgi-supplied
65     ``start_response`` callback.
66
67     This is the place to look at to see the mapping between core exceptions
68     and XML-RPC fault codes.
69     """
70     # Map OpenERP core exceptions to XML-RPC fault codes. Specific exceptions
71     # defined in ``openerp.exceptions`` are mapped to specific fault codes;
72     # all the other exceptions are mapped to the generic
73     # RPC_FAULT_CODE_APPLICATION_ERROR value.
74     # This also mimics SimpleXMLRPCDispatcher._marshaled_dispatch() for
75     # exception handling.
76     try:
77         result = openerp.netsvc.dispatch_rpc(service, method, params)
78         response = xmlrpclib.dumps((result,), methodresponse=1, allow_none=False, encoding=None)
79     except Exception, e:
80         if legacy_exceptions:
81             response = xmlrpc_handle_exception_legacy(e)
82         else:
83             response = xmlrpc_handle_exception(e)
84     start_response("200 OK", [('Content-Type','text/xml'), ('Content-Length', str(len(response)))])
85     return [response]
86
87 def xmlrpc_handle_exception(e):
88     if isinstance(e, openerp.osv.osv.except_osv): # legacy
89         fault = xmlrpclib.Fault(RPC_FAULT_CODE_WARNING, openerp.tools.ustr(e.value))
90         response = xmlrpclib.dumps(fault, allow_none=False, encoding=None)
91     elif isinstance(e, openerp.exceptions.Warning):
92         fault = xmlrpclib.Fault(RPC_FAULT_CODE_WARNING, str(e))
93         response = xmlrpclib.dumps(fault, allow_none=False, encoding=None)
94     elif isinstance (e, openerp.exceptions.AccessError):
95         fault = xmlrpclib.Fault(RPC_FAULT_CODE_ACCESS_ERROR, str(e))
96         response = xmlrpclib.dumps(fault, allow_none=False, encoding=None)
97     elif isinstance(e, openerp.exceptions.AccessDenied):
98         fault = xmlrpclib.Fault(RPC_FAULT_CODE_ACCESS_DENIED, str(e))
99         response = xmlrpclib.dumps(fault, allow_none=False, encoding=None)
100     elif isinstance(e, openerp.exceptions.DeferredException):
101         info = e.traceback
102         # Which one is the best ?
103         formatted_info = "".join(traceback.format_exception(*info))
104         #formatted_info = openerp.tools.exception_to_unicode(e) + '\n' + info
105         fault = xmlrpclib.Fault(RPC_FAULT_CODE_APPLICATION_ERROR, formatted_info)
106         response = xmlrpclib.dumps(fault, allow_none=False, encoding=None)
107     else:
108         if hasattr(e, 'message') and e.message == 'AccessDenied': # legacy
109             fault = xmlrpclib.Fault(RPC_FAULT_CODE_ACCESS_DENIED, str(e))
110             response = xmlrpclib.dumps(fault, allow_none=False, encoding=None)
111         else:
112             info = sys.exc_info()
113             # Which one is the best ?
114             formatted_info = "".join(traceback.format_exception(*info))
115             #formatted_info = openerp.tools.exception_to_unicode(e) + '\n' + info
116             fault = xmlrpclib.Fault(RPC_FAULT_CODE_APPLICATION_ERROR, formatted_info)
117             response = xmlrpclib.dumps(fault, allow_none=None, encoding=None)
118     return response
119
120 def xmlrpc_handle_exception_legacy(e):
121     if isinstance(e, openerp.osv.osv.except_osv):
122         fault = xmlrpclib.Fault('warning -- ' + e.name + '\n\n' + e.value, '')
123         response = xmlrpclib.dumps(fault, allow_none=False, encoding=None)
124     elif isinstance(e, openerp.exceptions.Warning):
125         fault = xmlrpclib.Fault('warning -- Warning\n\n' + str(e), '')
126         response = xmlrpclib.dumps(fault, allow_none=False, encoding=None)
127     elif isinstance(e, openerp.exceptions.AccessError):
128         fault = xmlrpclib.Fault('warning -- AccessError\n\n' + str(e), '')
129         response = xmlrpclib.dumps(fault, allow_none=False, encoding=None)
130     elif isinstance(e, openerp.exceptions.AccessDenied):
131         fault = xmlrpclib.Fault('AccessDenied', str(e))
132         response = xmlrpclib.dumps(fault, allow_none=False, encoding=None)
133     elif isinstance(e, openerp.exceptions.DeferredException):
134         info = e.traceback
135         formatted_info = "".join(traceback.format_exception(*info))
136         fault = xmlrpclib.Fault(openerp.tools.ustr(e.message), formatted_info)
137         response = xmlrpclib.dumps(fault, allow_none=False, encoding=None)
138     else:
139         info = sys.exc_info()
140         formatted_info = "".join(traceback.format_exception(*info))
141         fault = xmlrpclib.Fault(openerp.tools.exception_to_unicode(e), formatted_info)
142         response = xmlrpclib.dumps(fault, allow_none=None, encoding=None)
143     return response
144
145 def wsgi_xmlrpc_1(environ, start_response):
146     """ The main OpenERP WSGI handler."""
147     if environ['REQUEST_METHOD'] == 'POST' and environ['PATH_INFO'].startswith(XML_RPC_PATH_1):
148         length = int(environ['CONTENT_LENGTH'])
149         data = environ['wsgi.input'].read(length)
150
151         params, method = xmlrpclib.loads(data)
152
153         path = environ['PATH_INFO'][len(XML_RPC_PATH_1):]
154         if path.startswith('/'): path = path[1:]
155         if path.endswith('/'): path = path[:-1]
156         path = path.split('/')
157
158         # All routes are hard-coded.
159
160         # No need for a db segment.
161         if len(path) == 1:
162             service = path[0]
163
164             if service == 'common':
165                 if method in ('server_version',):
166                     service = 'db'
167             return xmlrpc_return(start_response, service, method, params)
168
169         # A db segment must be given.
170         elif len(path) == 2:
171             service, db_name = path
172             params = (db_name,) + params
173
174             return xmlrpc_return(start_response, service, method, params)
175
176         # A db segment and a model segment must be given.
177         elif len(path) == 3 and path[0] == 'model':
178             service, db_name, model_name = path
179             params = (db_name,) + params[:2] + (model_name,) + params[2:]
180             service = 'object'
181             return xmlrpc_return(start_response, service, method, params)
182
183         # The body has been read, need to raise an exception (not return None).
184         fault = xmlrpclib.Fault(RPC_FAULT_CODE_CLIENT_ERROR, '')
185         response = xmlrpclib.dumps(fault, allow_none=None, encoding=None)
186         start_response("200 OK", [('Content-Type','text/xml'), ('Content-Length', str(len(response)))])
187         return [response]
188
189 def wsgi_xmlrpc(environ, start_response):
190     """ WSGI handler to return the versions."""
191     if environ['REQUEST_METHOD'] == 'POST' and environ['PATH_INFO'].startswith(XML_RPC_PATH):
192         length = int(environ['CONTENT_LENGTH'])
193         data = environ['wsgi.input'].read(length)
194
195         params, method = xmlrpclib.loads(data)
196
197         path = environ['PATH_INFO'][len(XML_RPC_PATH):]
198         if path.startswith('/'): path = path[1:]
199         if path.endswith('/'): path = path[:-1]
200         path = path.split('/')
201
202         # All routes are hard-coded.
203
204         if len(path) == 1 and path[0] == '' and method in ('version',):
205             return xmlrpc_return(start_response, 'common', method, ())
206
207         # The body has been read, need to raise an exception (not return None).
208         fault = xmlrpclib.Fault(RPC_FAULT_CODE_CLIENT_ERROR, '')
209         response = xmlrpclib.dumps(fault, allow_none=None, encoding=None)
210         start_response("200 OK", [('Content-Type','text/xml'), ('Content-Length', str(len(response)))])
211         return [response]
212
213 def wsgi_xmlrpc_legacy(environ, start_response):
214     if environ['REQUEST_METHOD'] == 'POST' and environ['PATH_INFO'].startswith('/xmlrpc/'):
215         length = int(environ['CONTENT_LENGTH'])
216         data = environ['wsgi.input'].read(length)
217         path = environ['PATH_INFO'][len('/xmlrpc/'):] # expected to be one of db, object, ...
218
219         params, method = xmlrpclib.loads(data)
220         return xmlrpc_return(start_response, path, method, params, True)
221
222 def wsgi_jsonrpc(environ, start_response):
223     pass
224
225 def wsgi_webdav(environ, start_response):
226     pi = environ['PATH_INFO']
227     if environ['REQUEST_METHOD'] == 'OPTIONS' and pi in ['*','/']:
228         return return_options(environ, start_response)
229     elif pi.startswith('/webdav'):
230         http_dir = websrv_lib.find_http_service(pi)
231         if http_dir:
232             path = pi[len(http_dir.path):]
233             if path.startswith('/'):
234                 environ['PATH_INFO'] = path
235             else:
236                 environ['PATH_INFO'] = '/' + path
237             return http_to_wsgi(http_dir)(environ, start_response)
238
239 def return_options(environ, start_response):
240     # Microsoft specific header, see
241     # http://www.ibm.com/developerworks/rational/library/2089.html
242     if 'Microsoft' in environ.get('User-Agent', ''):
243         options = [('MS-Author-Via', 'DAV')]
244     else:
245         options = []
246     options += [('DAV', '1 2'), ('Allow', 'GET HEAD PROPFIND OPTIONS REPORT')]
247     start_response("200 OK", [('Content-Length', str(0))] + options)
248     return []
249
250 def http_to_wsgi(http_dir):
251     """
252     Turn a BaseHTTPRequestHandler into a WSGI entry point.
253
254     Actually the argument is not a bare BaseHTTPRequestHandler but is wrapped
255     (as a class, so it needs to be instanciated) in a HTTPDir.
256
257     This code is adapted from wbsrv_lib.MultiHTTPHandler._handle_one_foreign().
258     It is a temporary solution: the HTTP sub-handlers (in particular the
259     document_webdav addon) have to be WSGIfied.
260     """
261     def wsgi_handler(environ, start_response):
262
263         headers = {}
264         for key, value in environ.items():
265             if key.startswith('HTTP_'):
266                 key = key[5:].replace('_', '-').title()
267                 headers[key] = value
268             if key == 'CONTENT_LENGTH':
269                 key = key.replace('_', '-').title()
270                 headers[key] = value
271         if environ.get('Content-Type'):
272             headers['Content-Type'] = environ['Content-Type']
273
274         path = urllib.quote(environ.get('PATH_INFO', ''))
275         if environ.get('QUERY_STRING'):
276             path += '?' + environ['QUERY_STRING']
277
278         request_version = 'HTTP/1.1' # TODO
279         request_line = "%s %s %s\n" % (environ['REQUEST_METHOD'], path, request_version)
280
281         class Dummy(object):
282             pass
283
284         # Let's pretend we have a server to hand to the handler.
285         server = Dummy()
286         server.server_name = environ['SERVER_NAME']
287         server.server_port = int(environ['SERVER_PORT'])
288
289         # Initialize the underlying handler and associated auth. provider.
290         con = openerp.service.websrv_lib.noconnection(environ['wsgi.input'])
291         handler = http_dir.instanciate_handler(con, environ['REMOTE_ADDR'], server)
292
293         # Populate the handler as if it is called by a regular HTTP server
294         # and the request is already parsed.
295         handler.wfile = StringIO.StringIO()
296         handler.rfile = environ['wsgi.input']
297         handler.headers = headers
298         handler.command = environ['REQUEST_METHOD']
299         handler.path = path
300         handler.request_version = request_version
301         handler.close_connection = 1
302         handler.raw_requestline = request_line
303         handler.requestline = request_line
304
305         # Handle authentication if there is an auth. provider associated to
306         # the handler.
307         if hasattr(handler, 'auth_provider'):
308             try:
309                 handler.auth_provider.checkRequest(handler, path)
310             except websrv_lib.AuthRequiredExc, ae:
311                 # Darwin 9.x.x webdav clients will report "HTTP/1.0" to us, while they support (and need) the
312                 # authorisation features of HTTP/1.1 
313                 if request_version != 'HTTP/1.1' and ('Darwin/9.' not in handler.headers.get('User-Agent', '')):
314                     start_response("403 Forbidden", [])
315                     return []
316                 start_response("401 Authorization required", [
317                     ('WWW-Authenticate', '%s realm="%s"' % (ae.atype,ae.realm)),
318                     # ('Connection', 'keep-alive'),
319                     ('Content-Type', 'text/html'),
320                     ('Content-Length', 4), # len(self.auth_required_msg)
321                     ])
322                 return ['Blah'] # self.auth_required_msg
323             except websrv_lib.AuthRejectedExc,e:
324                 start_response("403 %s" % (e.args[0],), [])
325                 return []
326
327         method_name = 'do_' + handler.command
328
329         # Support the OPTIONS method even when not provided directly by the
330         # handler. TODO I would prefer to remove it and fix the handler if
331         # needed.
332         if not hasattr(handler, method_name):
333             if handler.command == 'OPTIONS':
334                 return return_options(environ, start_response)
335             start_response("501 Unsupported method (%r)" % handler.command, [])
336             return []
337
338         # Finally, call the handler's method.
339         try:
340             method = getattr(handler, method_name)
341             method()
342             # The DAV handler buffers its output and provides a _flush()
343             # method.
344             getattr(handler, '_flush', lambda: None)()
345             response = parse_http_response(handler.wfile.getvalue())
346             response_headers = response.getheaders()
347             body = response.read()
348             start_response(str(response.status) + ' ' + response.reason, response_headers)
349             return [body]
350         except (websrv_lib.AuthRejectedExc, websrv_lib.AuthRequiredExc):
351             raise
352         except Exception, e:
353             start_response("500 Internal error", [])
354             return []
355
356     return wsgi_handler
357
358 def parse_http_response(s):
359     """ Turn a HTTP response string into a httplib.HTTPResponse object."""
360     class DummySocket(StringIO.StringIO):
361         """
362         This is used to provide a StringIO to httplib.HTTPResponse
363         which, instead of taking a file object, expects a socket and
364         uses its makefile() method.
365         """
366         def makefile(self, *args, **kw):
367             return self
368     response = httplib.HTTPResponse(DummySocket(s))
369     response.begin()
370     return response
371
372 # WSGI handlers registered through the register_wsgi_handler() function below.
373 module_handlers = []
374
375 def register_wsgi_handler(handler):
376     """ Register a WSGI handler.
377
378     Handlers are tried in the order they are added. We might provide a way to
379     register a handler for specific routes later.
380     """
381     module_handlers.append(handler)
382
383 def application(environ, start_response):
384     """ WSGI entry point."""
385
386     # Try all handlers until one returns some result (i.e. not None).
387     wsgi_handlers = [
388         wsgi_xmlrpc_1,
389         wsgi_xmlrpc,
390         wsgi_jsonrpc,
391         wsgi_xmlrpc_legacy,
392         wsgi_webdav
393         ] + module_handlers
394     for handler in wsgi_handlers:
395         result = handler(environ, start_response)
396         if result is None:
397             continue
398         return result
399
400     # We never returned from the loop.
401     response = 'No handler found.\n'
402     start_response('404 Not Found', [('Content-Type', 'text/plain'), ('Content-Length', str(len(response)))])
403     return [response]
404
405 # The WSGI server, started by start_server(), stopped by stop_server().
406 httpd = None
407
408 def serve():
409     """ Serve HTTP requests via werkzeug development server.
410
411     If werkzeug can not be imported, we fall back to wsgiref's simple_server.
412
413     Calling this function is blocking, you might want to call it in its own
414     thread.
415     """
416
417     global httpd
418
419     # TODO Change the xmlrpc_* options to http_*
420     interface = config['xmlrpc_interface'] or '0.0.0.0'
421     port = config['xmlrpc_port']
422     try:
423         import werkzeug.serving
424         httpd = werkzeug.serving.make_server(interface, port, application, threaded=True)
425         logging.getLogger('wsgi').info('HTTP service (werkzeug) running on %s:%s', interface, port)
426     except ImportError:
427         import wsgiref.simple_server
428         logging.getLogger('wsgi').warn('Werkzeug module unavailable, falling back to wsgiref.')
429         httpd = wsgiref.simple_server.make_server(interface, port, application)
430         logging.getLogger('wsgi').info('HTTP service (wsgiref) running on %s:%s', interface, port)
431
432     httpd.serve_forever()
433
434 def start_server():
435     """ Call serve() in its own thread.
436
437     The WSGI server can be shutdown with stop_server() below.
438     """
439     threading.Thread(target=openerp.wsgi.serve).start()
440
441 def stop_server():
442     """ Initiate the shutdown of the WSGI server.
443
444     The server is supposed to have been started by start_server() above.
445     """
446     if httpd:
447         httpd.shutdown()
448
449 # Master process id, can be used for signaling.
450 arbiter_pid = None
451
452 # Application setup before we can spawn any worker process.
453 # This is suitable for e.g. gunicorn's on_starting hook.
454 def on_starting(server):
455     global arbiter_pid
456     arbiter_pid = os.getpid() # TODO check if this is true even after replacing the executable
457     #openerp.tools.cache = kill_workers_cache
458     openerp.netsvc.init_logger()
459     openerp.osv.osv.start_object_proxy()
460     openerp.service.web_services.start_web_services()
461     openerp.modules.module.initialize_sys_path()
462     openerp.modules.loading.open_openerp_namespace()
463     for m in openerp.conf.server_wide_modules:
464         try:
465             __import__(m)
466             # Call any post_load hook.
467             info = openerp.modules.module.load_information_from_description_file(m)
468             if info['post_load']:
469                 getattr(sys.modules[m], info['post_load'])()
470         except Exception:
471             msg = ''
472             if m == 'web':
473                 msg = """
474 The `web` module is provided by the addons found in the `openerp-web` project.
475 Maybe you forgot to add those addons in your addons_path configuration."""
476             logging.exception('Failed to load server-wide module `%s`.%s', m, msg)
477
478 # Install our own signal handler on the master process.
479 def when_ready(server):
480     # Hijack gunicorn's SIGWINCH handling; we can choose another one.
481     signal.signal(signal.SIGWINCH, make_winch_handler(server))
482
483 # Install limits on virtual memory and CPU time consumption.
484 def pre_request(worker, req):
485     import os
486     import psutil
487     import resource
488     import signal
489     # VMS and RLIMIT_AS are the same thing: virtual memory, a.k.a. address space
490     rss, vms = psutil.Process(os.getpid()).get_memory_info()
491     soft, hard = resource.getrlimit(resource.RLIMIT_AS)
492     resource.setrlimit(resource.RLIMIT_AS, (config['virtual_memory_limit'], hard))
493
494     r = resource.getrusage(resource.RUSAGE_SELF)
495     cpu_time = r.ru_utime + r.ru_stime
496     signal.signal(signal.SIGXCPU, time_expired)
497     soft, hard = resource.getrlimit(resource.RLIMIT_CPU)
498     resource.setrlimit(resource.RLIMIT_CPU, (cpu_time + config['cpu_time_limit'], hard))
499
500 # Reset the worker if it consumes too much memory (e.g. caused by a memory leak).
501 def post_request(worker, req, environ):
502     import os
503     import psutil
504     rss, vms = psutil.Process(os.getpid()).get_memory_info()
505     if vms > config['virtual_memory_reset']:
506         logging.getLogger('wsgi.worker').info('Virtual memory consumption '
507             'too high, rebooting the worker.')
508         worker.alive = False # Commit suicide after the request.
509
510 # Our signal handler will signal a SGIQUIT to all workers.
511 def make_winch_handler(server):
512     def handle_winch(sig, fram):
513         server.kill_workers(signal.SIGQUIT) # This is gunicorn specific.
514     return handle_winch
515
516 # SIGXCPU (exceeded CPU time) signal handler will raise an exception.
517 def time_expired(n, stack):
518     logging.getLogger('wsgi.worker').info('CPU time limit exceeded.')
519     raise Exception('CPU time limit exceeded.') # TODO one of openerp.exception
520
521 # Kill gracefuly the workers (e.g. because we want to clear their cache).
522 # This is done by signaling a SIGWINCH to the master process, so it can be
523 # called by the workers themselves.
524 def kill_workers():
525     try:
526         os.kill(arbiter_pid, signal.SIGWINCH)
527     except OSError, e:
528         if e.errno == errno.ESRCH: # no such pid
529             return
530         raise
531
532 class kill_workers_cache(openerp.tools.ormcache):
533     def clear(self, dbname, *args, **kwargs):
534         kill_workers()
535
536 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: