[BOOM] deactivate all addons, remove links to addons stuff from base, do not belong...
[odoo/odoo.git] / openerpweb / openerpweb.py
1 #!/usr/bin/python
2 import datetime
3 import dateutil.relativedelta
4 import functools
5 import optparse
6 import os
7 import sys
8 import tempfile
9 import time
10 import traceback
11 import uuid
12 import xmlrpclib
13 import pytz
14
15 import cherrypy
16 import cherrypy.lib.static
17 import simplejson
18
19 import nonliterals
20 import xmlrpctimeout
21 import logging
22
23 #-----------------------------------------------------------
24 # Globals
25 #-----------------------------------------------------------
26
27 path_root = os.path.dirname(os.path.dirname(os.path.normpath(__file__)))
28 path_addons = os.path.join(path_root, 'addons')
29 cherrypy_root = None
30
31 #-----------------------------------------------------------
32 # Per Database Globals (might move into a pool if needed)
33 #-----------------------------------------------------------
34
35 applicationsession = {}
36 addons_module = {}
37 addons_manifest = {}
38 controllers_class = {}
39 controllers_object = {}
40 controllers_path = {}
41
42 #----------------------------------------------------------
43 # OpenERP Client Library
44 #----------------------------------------------------------
45 class OpenERPUnboundException(Exception):
46     pass
47
48 class OpenERPConnector(object):
49     pass
50
51 class OpenERPAuth(object):
52     pass
53
54 class OpenERPModel(object):
55     def __init__(self, session, model):
56         self._session = session
57         self._model = model
58
59     def __getattr__(self, name):
60         return lambda *l:self._session.execute(self._model, name, *l)
61
62 class OpenERPSession(object):
63     """
64     An OpenERP RPC session, a given user can own multiple such sessions
65     in a web session.
66
67     .. attribute:: context
68     
69         The session context, a ``dict``. Can be reloaded by calling
70         :meth:`openerpweb.openerpweb.OpenERPSession.get_context`
71
72     .. attribute:: domains_store
73
74         A ``dict`` matching domain keys to evaluable (but non-literal) domains.
75
76         Used to store references to non-literal domains which need to be
77         round-tripped to the client browser.
78     """
79     def __init__(self, server='127.0.0.1', port=8069,
80                  model_factory=OpenERPModel):
81         self._server = server
82         self._port = port
83         self._db = False
84         self._uid = False
85         self._login = False
86         self._password = False
87         self.model_factory = model_factory
88         self._locale = 'en_US'
89         self.context = {}
90         self.contexts_store = {}
91         self.domains_store = {}
92         self._lang = {}
93         self.remote_timezone = 'utc'
94         self.client_timezone = False
95
96     def proxy(self, service):
97         s = xmlrpctimeout.TimeoutServerProxy('http://%s:%s/xmlrpc/%s' % (self._server, self._port, service), timeout=5)
98         return s
99
100     def bind(self, db, uid, password):
101         self._db = db
102         self._uid = uid
103         self._password = password
104
105     def login(self, db, login, password):
106         uid = self.proxy('common').login(db, login, password)
107         self.bind(db, uid, password)
108         self._login = login
109         
110         if uid: self.get_context()
111         return uid
112
113     def execute(self, model, func, *l, **d):
114         if not (self._db and self._uid and self._password):
115             raise OpenERPUnboundException()
116         r = self.proxy('object').execute(self._db, self._uid, self._password, model, func, *l, **d)
117         return r
118
119     def model(self, model):
120         """ Get an RPC proxy for the object ``model``, bound to this session.
121
122         :param model: an OpenERP model name
123         :type model: str
124         :rtype: :class:`openerpweb.openerpweb.OpenERPModel`
125         """
126         return self.model_factory(self, model)
127
128     def get_context(self):
129         """ Re-initializes the current user's session context (based on
130         his preferences) by calling res.users.get_context() with the old
131         context
132
133         :returns: the new context
134         """
135         assert self._uid, "The user needs to be logged-in to initialize his context"
136         self.context = self.model('res.users').context_get(self.context)
137         
138         self.client_timezone = self.context.get("tz", False)
139         if self.client_timezone:
140             self.remote_timezone = self.execute('common', 'timezone_get')
141             
142         self._locale = self.context.get('lang','en_US')
143         lang_ids = self.execute('res.lang','search', [('code', '=', self._locale)])
144         if lang_ids:
145             self._lang = self.execute('res.lang', 'read',lang_ids[0], [])
146         return self.context
147
148     @property
149     def base_eval_context(self):
150         """ Default evaluation context for the session.
151
152         Used to evaluate contexts and domains.
153         """
154         base = dict(
155             uid=self._uid,
156             current_date=datetime.date.today().strftime('%Y-%m-%d'),
157             time=time,
158             datetime=datetime,
159             relativedelta=dateutil.relativedelta.relativedelta
160         )
161         base.update(self.context)
162         return base
163
164     def evaluation_context(self, context=None):
165         """ Returns the session's evaluation context, augmented with the
166         provided context if any.
167
168         :param dict context: to add merge in the session's base eval context
169         :returns: the augmented context
170         :rtype: dict
171         """
172         d = {}
173         d.update(self.base_eval_context)
174         if context:
175             d.update(context)
176         return d
177
178     def eval_context(self, context_to_eval, context=None):
179         """ Evaluates the provided context_to_eval in the context (haha) of
180         the context.
181
182         :param context_to_eval: a context to evaluate. Must be a dict or a
183                                 non-literal context. If it's a dict, will be
184                                 returned as-is
185         :type context_to_eval: openerpweb.nonliterals.Context
186         :returns: the evaluated context
187         :rtype: dict
188
189         :raises: ``TypeError`` if ``context_to_eval`` is neither a dict nor
190                  a Context
191         """
192         if not isinstance(context_to_eval, (dict, nonliterals.Domain)):
193             raise TypeError("Context %r is not a dict or a nonliteral Context",
194                              context_to_eval)
195
196         if isinstance(context_to_eval, dict):
197             return context_to_eval
198
199         ctx = dict(context or {})
200         ctx['context'] = ctx
201
202         # if the domain was unpacked from JSON, it needs the current
203         # OpenERPSession for its data retrieval
204         context_to_eval.session = self
205         return context_to_eval.evaluate(ctx)
206
207     def eval_contexts(self, contexts, context=None):
208         """ Evaluates a sequence of contexts to build a single final result
209
210         :param list contexts: a list of Context or dict contexts
211         :param dict context: a base context, if needed
212         :returns: the final combination of all provided contexts
213         :rtype: dict
214         """
215         # This is the context we use to evaluate stuff
216         current_context = dict(
217             self.base_eval_context,
218             **(context or {}))
219         # this is our result, it should not contain the values
220         # of the base context above
221         final_context = {}
222         for ctx in contexts:
223             # evaluate the current context in the sequence, merge it into
224             # the result
225             final_context.update(
226                 self.eval_context(
227                     ctx, current_context))
228             # update the current evaluation context so that future
229             # evaluations can use the results we just gathered
230             current_context.update(final_context)
231         return final_context
232
233     def eval_domain(self, domain, context=None):
234         """ Evaluates the provided domain using the provided context
235         (merged with the session's evaluation context)
236
237         :param domain: an OpenERP domain as a list or as a
238                        :class:`openerpweb.nonliterals.Domain` instance
239
240                        In the second case, it will be evaluated and returned.
241         :type domain: openerpweb.nonliterals.Domain
242         :param dict context: the context to use in the evaluation, if any.
243         :returns: the evaluated domain
244         :rtype: list
245
246         :raises: ``TypeError`` if ``domain`` is neither a list nor a Domain
247         """
248         if not isinstance(domain, (list, nonliterals.Domain)):
249             raise TypeError("Domain %r is not a list or a nonliteral Domain",
250                              domain)
251
252         if isinstance(domain, list):
253             return domain
254
255         ctx = dict(context or {})
256         ctx['context'] = ctx
257
258         # if the domain was unpacked from JSON, it needs the current
259         # OpenERPSession for its data retrieval
260         domain.session = self
261         return domain.evaluate(ctx)
262
263     def eval_domains(self, domains, context=None):
264         """ Evaluates and concatenates the provided domains using the
265         provided context for all of them.
266
267         Returns the final, concatenated result.
268
269         :param list domains: a list of Domain or list domains
270         :param dict context: the context in which the domains
271                              should be evaluated (if evaluations need
272                              to happen)
273         :returns: the final combination of all domains in the sequence
274         :rtype: list
275         """
276         final_domain = []
277         for domain in domains:
278             final_domain.extend(
279                 self.eval_domain(domain, context))
280         return final_domain
281
282 #----------------------------------------------------------
283 # OpenERP Web RequestHandler
284 #----------------------------------------------------------
285 class JsonRequest(object):
286     """ JSON-RPC2 over HTTP POST using non standard POST encoding.
287     Difference with the standard:
288
289     * the json string is passed as a form parameter named "request"
290     * method is currently ignored
291
292     Sucessful request:
293     --> {"jsonrpc": "2.0", "method": "call", "params": {"session_id": "SID", "context": {}, "arg1": "val1" }, "id": null}
294     <-- {"jsonrpc": "2.0", "result": { "res1": "val1" }, "id": null}
295
296     Request producing a error:
297     --> {"jsonrpc": "2.0", "method": "call", "params": {"session_id": "SID", "context": {}, "arg1": "val1" }, "id": null}
298     <-- {"jsonrpc": "2.0", "error": {"code": 1, "message": "End user error message.", "data": {"code": "codestring", "debug": "traceback" } }, "id": null}
299
300     """
301
302     def parse(self, request):
303         self.request = request
304         self.params = request.get("params", {})
305         self.applicationsession = applicationsession
306         self.httpsession_id = "cookieid"
307         self.httpsession = cherrypy.session
308         self.session_id = self.params.pop("session_id", None) or uuid.uuid4().hex
309         self.session = self.httpsession.setdefault(self.session_id, OpenERPSession())
310         self.context = self.params.pop('context', None)
311         return self.params
312
313     def dispatch(self, controller, method, requestf=None, request=None):
314         ''' Calls the method asked for by the JSON-RPC2 request
315
316         :param controller: the instance of the controller which received the request
317         :type controller: type
318         :param method: the method which received the request
319         :type method: callable
320         :param requestf: a file-like object containing an encoded JSON-RPC2 request
321         :type requestf: <read() -> bytes>
322         :param request: an encoded JSON-RPC2 request
323         :type request: bytes
324
325         :returns: a string-encoded JSON-RPC2 reply
326         :rtype: bytes
327         '''
328         # Read POST content or POST Form Data named "request"
329         if requestf:
330             request = simplejson.load(requestf, object_hook=nonliterals.non_literal_decoder)
331         else:
332             request = simplejson.loads(request, object_hook=nonliterals.non_literal_decoder)
333         try:
334             print "--> %s.%s %s" % (controller.__class__.__name__, method.__name__, request)
335             error = None
336             self.parse(request)
337             result = method(controller, self, **self.params)
338         except OpenERPUnboundException:
339             error = {
340                 'code': 100,
341                 'message': "OpenERP Session Invalid",
342                 'data': {
343                     'type': 'session_invalid',
344                     'debug': traceback.format_exc()
345                 }
346             }
347         except xmlrpclib.Fault, e:
348             error = {
349                 'code': 200,
350                 'message': "OpenERP Server Error",
351                 'data': {
352                     'type': 'server_exception',
353                     'fault_code': e.faultCode,
354                     'debug': "Client %s\nServer %s" % (
355                     "".join(traceback.format_exception("", None, sys.exc_traceback)), e.faultString)
356                 }
357             }
358         except Exception:
359             cherrypy.log("An error occured while handling a json request",
360                          severity=logging.ERROR, traceback=True)
361             error = {
362                 'code': 300,
363                 'message': "OpenERP WebClient Error",
364                 'data': {
365                     'type': 'client_exception',
366                     'debug': "Client %s" % traceback.format_exc()
367                 }
368             }
369         response = {"jsonrpc": "2.0", "id": request.get('id')}
370         if error:
371             response["error"] = error
372         else:
373             response["result"] = result
374
375         print "<--", response
376         print
377
378         content = simplejson.dumps(response, cls=nonliterals.NonLiteralEncoder)
379         cherrypy.response.headers['Content-Type'] = 'application/json'
380         cherrypy.response.headers['Content-Length'] = len(content)
381         return content
382
383 def jsonrequest(f):
384     @cherrypy.expose
385     @functools.wraps(f)
386     def json_handler(controller):
387         return JsonRequest().dispatch(controller, f, requestf=cherrypy.request.body)
388
389     return json_handler
390
391 class HttpRequest(object):
392     """ Regular GET/POST request
393     """
394     def dispatch(self, controller, f, request, **kw):
395         self.request = request
396         self.applicationsession = applicationsession
397         self.httpsession_id = "cookieid"
398         self.httpsession = cherrypy.session
399         self.result = ""
400         print "GET/POST --> %s.%s %s %r" % (controller.__class__.__name__, f.__name__, request, kw)
401         r = f(controller, self, **kw)
402         print "<--", r
403         print
404         return r
405
406 def httprequest(f):
407     # check cleaner wrapping:
408     # functools.wraps(f)(lambda x: JsonRequest().dispatch(x, f))
409     def http_handler(self,*l, **kw):
410         return HttpRequest().dispatch(self, f, cherrypy.request, **kw)
411     http_handler.exposed = 1
412     return http_handler
413
414 #-----------------------------------------------------------
415 # Cherrypy stuff
416 #-----------------------------------------------------------
417
418 class ControllerType(type):
419     def __init__(cls, name, bases, attrs):
420         super(ControllerType, cls).__init__(name, bases, attrs)
421         controllers_class["%s.%s" % (cls.__module__, cls.__name__)] = cls
422
423 class Controller(object):
424     __metaclass__ = ControllerType
425
426 class Root(object):
427     def __init__(self):
428         self.addons = {}
429         self._load_addons()
430
431     def _load_addons(self):
432         if path_addons not in sys.path:
433             sys.path.insert(0, path_addons)
434         for i in os.listdir(path_addons):
435             if i not in sys.modules:
436                 manifest_path = os.path.join(path_addons, i, '__openerp__.py')
437                 if os.path.isfile(manifest_path):
438                     manifest = eval(open(manifest_path).read())
439                     if manifest.get('active', True):
440                         print "Loading", i
441                         m = __import__(i)
442                         addons_module[i] = m
443                         addons_manifest[i] = manifest
444         for k, v in controllers_class.items():
445             if k not in controllers_object:
446                 o = v()
447                 controllers_object[k] = o
448                 if hasattr(o, '_cp_path'):
449                     controllers_path[o._cp_path] = o
450
451     def default(self, *l, **kw):
452         #print "default",l,kw
453         # handle static files
454         if len(l) > 2 and l[1] == 'static':
455             # sanitize path
456             p = os.path.normpath(os.path.join(*l))
457             return cherrypy.lib.static.serve_file(os.path.join(path_addons, p))
458         elif len(l) > 1:
459             for i in range(len(l), 1, -1):
460                 ps = "/" + "/".join(l[0:i])
461                 if ps in controllers_path:
462                     c = controllers_path[ps]
463                     rest = l[i:] or ['index']
464                     meth = rest[0]
465                     m = getattr(c, meth)
466                     if getattr(m, 'exposed', 0):
467                         print "Calling", ps, c, meth, m
468                         return m(**kw)
469             raise cherrypy.NotFound('/' + '/'.join(l))
470         else:
471             raise cherrypy.HTTPRedirect('/base/static/src/base.html', 301)
472     default.exposed = True
473
474 def main(argv):
475     # Parse config
476     op = optparse.OptionParser()
477     op.add_option("-p", "--port", dest="socket_port", help="listening port", metavar="NUMBER", default=8002)
478     op.add_option("-s", "--session-path", dest="storage_path",
479                   help="directory used for session storage", metavar="DIR",
480                   default=os.path.join(tempfile.gettempdir(), "cpsessions"))
481     (o, args) = op.parse_args(argv[1:])
482
483     # Prepare cherrypy config from options
484     if not os.path.exists(o.storage_path):
485         os.mkdir(o.storage_path, 0700)
486     config = {
487         'server.socket_port': int(o.socket_port),
488         'server.socket_host': '0.0.0.0',
489         #'server.thread_pool' = 10,
490         'tools.sessions.on': True,
491         'tools.sessions.storage_type': 'file',
492         'tools.sessions.storage_path': o.storage_path,
493         'tools.sessions.timeout': 60
494     }
495
496     # Setup and run cherrypy
497     cherrypy.tree.mount(Root())
498     cherrypy.config.update(config)
499     cherrypy.server.subscribe()
500     cherrypy.engine.start()
501     cherrypy.engine.block()
502