[FIX] use of conditional responses in bundling
[odoo/odoo.git] / addons / web / controllers / main.py
1 # -*- coding: utf-8 -*-
2
3 import ast
4 import base64
5 import csv
6 import functools
7 import glob
8 import itertools
9 import jinja2
10 import logging
11 import operator
12 import datetime
13 import hashlib
14 import os
15 import re
16 import simplejson
17 import sys
18 import time
19 import urllib2
20 import zlib
21 from xml.etree import ElementTree
22 from cStringIO import StringIO
23
24 import babel.messages.pofile
25 import werkzeug.utils
26 import werkzeug.wrappers
27 try:
28     import xlwt
29 except ImportError:
30     xlwt = None
31
32 import openerp
33 import openerp.modules.registry
34 from openerp.addons.base.ir.ir_qweb import AssetsBundle
35 from openerp.tools.translate import _
36 from openerp import http
37
38 from openerp.http import request, serialize_exception as _serialize_exception
39
40 _logger = logging.getLogger(__name__)
41
42 if hasattr(sys, 'frozen'):
43     # When running on compiled windows binary, we don't have access to package loader.
44     path = os.path.realpath(os.path.join(os.path.dirname(__file__), '..', 'views'))
45     loader = jinja2.FileSystemLoader(path)
46 else:
47     loader = jinja2.PackageLoader('openerp.addons.web', "views")
48
49 env = jinja2.Environment(loader=loader, autoescape=True)
50 env.filters["json"] = simplejson.dumps
51
52 #----------------------------------------------------------
53 # OpenERP Web helpers
54 #----------------------------------------------------------
55
56 db_list = http.db_list
57
58 db_monodb = http.db_monodb
59
60 def serialize_exception(f):
61     @functools.wraps(f)
62     def wrap(*args, **kwargs):
63         try:
64             return f(*args, **kwargs)
65         except Exception, e:
66             _logger.exception("An exception occured during an http request")
67             se = _serialize_exception(e)
68             error = {
69                 'code': 200,
70                 'message': "OpenERP Server Error",
71                 'data': se
72             }
73             return werkzeug.exceptions.InternalServerError(simplejson.dumps(error))
74     return wrap
75
76 def redirect_with_hash(*args, **kw):
77     """
78         .. deprecated:: 8.0
79
80         Use the ``http.redirect_with_hash()`` function instead.
81     """
82     return http.redirect_with_hash(*args, **kw)
83
84 def abort_and_redirect(url):
85     r = request.httprequest
86     response = werkzeug.utils.redirect(url, 302)
87     response = r.app.get_response(r, response, explicit_session=False)
88     werkzeug.exceptions.abort(response)
89
90 def ensure_db(redirect='/web/database/selector'):
91     # This helper should be used in web client auth="none" routes
92     # if those routes needs a db to work with.
93     # If the heuristics does not find any database, then the users will be
94     # redirected to db selector or any url specified by `redirect` argument.
95     # If the db is taken out of a query parameter, it will be checked against
96     # `http.db_filter()` in order to ensure it's legit and thus avoid db
97     # forgering that could lead to xss attacks.
98     db = request.params.get('db')
99
100     # Ensure db is legit
101     if db and db not in http.db_filter([db]):
102         db = None
103
104     if db and not request.session.db:
105         # User asked a specific database on a new session.
106         # That mean the nodb router has been used to find the route
107         # Depending on installed module in the database, the rendering of the page
108         # may depend on data injected by the database route dispatcher.
109         # Thus, we redirect the user to the same page but with the session cookie set.
110         # This will force using the database route dispatcher...
111         r = request.httprequest
112         url_redirect = r.base_url
113         if r.query_string:
114             # Can't use werkzeug.wrappers.BaseRequest.url with encoded hashes:
115             # https://github.com/amigrave/werkzeug/commit/b4a62433f2f7678c234cdcac6247a869f90a7eb7
116             url_redirect += '?' + r.query_string
117         response = werkzeug.utils.redirect(url_redirect, 302)
118         request.session.db = db
119         abort_and_redirect(url_redirect)
120
121     # if db not provided, use the session one
122     if not db:
123         db = request.session.db
124
125     # if no database provided and no database in session, use monodb
126     if not db:
127         db = db_monodb(request.httprequest)
128
129     # if no db can be found til here, send to the database selector
130     # the database selector will redirect to database manager if needed
131     if not db:
132         werkzeug.exceptions.abort(werkzeug.utils.redirect(redirect, 303))
133
134     # always switch the session to the computed db
135     if db != request.session.db:
136         request.session.logout()
137         abort_and_redirect(request.httprequest.url)
138
139     request.session.db = db
140
141 def module_topological_sort(modules):
142     """ Return a list of module names sorted so that their dependencies of the
143     modules are listed before the module itself
144
145     modules is a dict of {module_name: dependencies}
146
147     :param modules: modules to sort
148     :type modules: dict
149     :returns: list(str)
150     """
151
152     dependencies = set(itertools.chain.from_iterable(modules.itervalues()))
153     # incoming edge: dependency on other module (if a depends on b, a has an
154     # incoming edge from b, aka there's an edge from b to a)
155     # outgoing edge: other module depending on this one
156
157     # [Tarjan 1976], http://en.wikipedia.org/wiki/Topological_sorting#Algorithms
158     #L ← Empty list that will contain the sorted nodes
159     L = []
160     #S ← Set of all nodes with no outgoing edges (modules on which no other
161     #    module depends)
162     S = set(module for module in modules if module not in dependencies)
163
164     visited = set()
165     #function visit(node n)
166     def visit(n):
167         #if n has not been visited yet then
168         if n not in visited:
169             #mark n as visited
170             visited.add(n)
171             #change: n not web module, can not be resolved, ignore
172             if n not in modules: return
173             #for each node m with an edge from m to n do (dependencies of n)
174             for m in modules[n]:
175                 #visit(m)
176                 visit(m)
177             #add n to L
178             L.append(n)
179     #for each node n in S do
180     for n in S:
181         #visit(n)
182         visit(n)
183     return L
184
185 def module_installed():
186     # Candidates module the current heuristic is the /static dir
187     loadable = http.addons_manifest.keys()
188     modules = {}
189
190     # Retrieve database installed modules
191     # TODO The following code should move to ir.module.module.list_installed_modules()
192     Modules = request.session.model('ir.module.module')
193     domain = [('state','=','installed'), ('name','in', loadable)]
194     for module in Modules.search_read(domain, ['name', 'dependencies_id']):
195         modules[module['name']] = []
196         deps = module.get('dependencies_id')
197         if deps:
198             deps_read = request.session.model('ir.module.module.dependency').read(deps, ['name'])
199             dependencies = [i['name'] for i in deps_read]
200             modules[module['name']] = dependencies
201
202     sorted_modules = module_topological_sort(modules)
203     return sorted_modules
204
205 def module_installed_bypass_session(dbname):
206     loadable = http.addons_manifest.keys()
207     modules = {}
208     try:
209         registry = openerp.modules.registry.RegistryManager.get(dbname)
210         with registry.cursor() as cr:
211             m = registry.get('ir.module.module')
212             # TODO The following code should move to ir.module.module.list_installed_modules()
213             domain = [('state','=','installed'), ('name','in', loadable)]
214             ids = m.search(cr, 1, [('state','=','installed'), ('name','in', loadable)])
215             for module in m.read(cr, 1, ids, ['name', 'dependencies_id']):
216                 modules[module['name']] = []
217                 deps = module.get('dependencies_id')
218                 if deps:
219                     deps_read = registry.get('ir.module.module.dependency').read(cr, 1, deps, ['name'])
220                     dependencies = [i['name'] for i in deps_read]
221                     modules[module['name']] = dependencies
222     except Exception,e:
223         pass
224     sorted_modules = module_topological_sort(modules)
225     return sorted_modules
226
227 def module_boot(db=None):
228     server_wide_modules = openerp.conf.server_wide_modules or ['web']
229     serverside = []
230     dbside = []
231     for i in server_wide_modules:
232         if i in http.addons_manifest:
233             serverside.append(i)
234     monodb = db or db_monodb()
235     if monodb:
236         dbside = module_installed_bypass_session(monodb)
237         dbside = [i for i in dbside if i not in serverside]
238     addons = serverside + dbside
239     return addons
240
241 def concat_xml(file_list):
242     """Concatenate xml files
243
244     :param list(str) file_list: list of files to check
245     :returns: (concatenation_result, checksum)
246     :rtype: (str, str)
247     """
248     checksum = hashlib.new('sha1')
249     if not file_list:
250         return '', checksum.hexdigest()
251
252     root = None
253     for fname in file_list:
254         with open(fname, 'rb') as fp:
255             contents = fp.read()
256             checksum.update(contents)
257             fp.seek(0)
258             xml = ElementTree.parse(fp).getroot()
259
260         if root is None:
261             root = ElementTree.Element(xml.tag)
262         #elif root.tag != xml.tag:
263         #    raise ValueError("Root tags missmatch: %r != %r" % (root.tag, xml.tag))
264
265         for child in xml.getchildren():
266             root.append(child)
267     return ElementTree.tostring(root, 'utf-8'), checksum.hexdigest()
268
269 def fs2web(path):
270     """convert FS path into web path"""
271     return '/'.join(path.split(os.path.sep))
272
273 def manifest_glob(extension, addons=None, db=None, include_remotes=False):
274     if addons is None:
275         addons = module_boot(db=db)
276     else:
277         addons = addons.split(',')
278     r = []
279     for addon in addons:
280         manifest = http.addons_manifest.get(addon, None)
281         if not manifest:
282             continue
283         # ensure does not ends with /
284         addons_path = os.path.join(manifest['addons_path'], '')[:-1]
285         globlist = manifest.get(extension, [])
286         for pattern in globlist:
287             if pattern.startswith(('http://', 'https://', '//')):
288                 if include_remotes:
289                     r.append((None, pattern))
290             else:
291                 for path in glob.glob(os.path.normpath(os.path.join(addons_path, addon, pattern))):
292                     r.append((path, fs2web(path[len(addons_path):])))
293     return r
294
295 def manifest_list(extension, mods=None, db=None, debug=None):
296     """ list ressources to load specifying either:
297     mods: a comma separated string listing modules
298     db: a database name (return all installed modules in that database)
299     """
300     if debug is not None:
301         _logger.warning("openerp.addons.web.main.manifest_list(): debug parameter is deprecated")
302     files = manifest_glob(extension, addons=mods, db=db, include_remotes=True)
303     return [wp for _fp, wp in files]
304
305 def get_last_modified(files):
306     """ Returns the modification time of the most recently modified
307     file provided
308
309     :param list(str) files: names of files to check
310     :return: most recent modification time amongst the fileset
311     :rtype: datetime.datetime
312     """
313     files = list(files)
314     if files:
315         return max(datetime.datetime.fromtimestamp(os.path.getmtime(f))
316                    for f in files)
317     return datetime.datetime(1970, 1, 1)
318
319 def make_conditional(response, last_modified=None, etag=None, max_age=0):
320     """ Makes the provided response conditional based upon the request,
321     and mandates revalidation from clients
322
323     Uses Werkzeug's own :meth:`ETagResponseMixin.make_conditional`, after
324     setting ``last_modified`` and ``etag`` correctly on the response object
325
326     :param response: Werkzeug response
327     :type response: werkzeug.wrappers.Response
328     :param datetime.datetime last_modified: last modification date of the response content
329     :param str etag: some sort of checksum of the content (deep etag)
330     :return: the response object provided
331     :rtype: werkzeug.wrappers.Response
332     """
333     response.cache_control.must_revalidate = True
334     response.cache_control.max_age = max_age
335     if last_modified:
336         response.last_modified = last_modified
337     if etag:
338         response.set_etag(etag)
339     return response.make_conditional(request.httprequest)
340
341 def login_and_redirect(db, login, key, redirect_url='/web'):
342     request.session.authenticate(db, login, key)
343     return set_cookie_and_redirect(redirect_url)
344
345 def set_cookie_and_redirect(redirect_url):
346     redirect = werkzeug.utils.redirect(redirect_url, 303)
347     redirect.autocorrect_location_header = False
348     return redirect
349
350 def login_redirect():
351     url = '/web/login?'
352     if request.debug:
353         url += 'debug&'
354     return """<html><head><script>
355         window.location = '%sredirect=' + encodeURIComponent(window.location);
356     </script></head></html>
357     """ % (url,)
358
359 def load_actions_from_ir_values(key, key2, models, meta):
360     Values = request.session.model('ir.values')
361     actions = Values.get(key, key2, models, meta, request.context)
362
363     return [(id, name, clean_action(action))
364             for id, name, action in actions]
365
366 def clean_action(action):
367     action.setdefault('flags', {})
368     action_type = action.setdefault('type', 'ir.actions.act_window_close')
369     if action_type == 'ir.actions.act_window':
370         return fix_view_modes(action)
371     return action
372
373 # I think generate_views,fix_view_modes should go into js ActionManager
374 def generate_views(action):
375     """
376     While the server generates a sequence called "views" computing dependencies
377     between a bunch of stuff for views coming directly from the database
378     (the ``ir.actions.act_window model``), it's also possible for e.g. buttons
379     to return custom view dictionaries generated on the fly.
380
381     In that case, there is no ``views`` key available on the action.
382
383     Since the web client relies on ``action['views']``, generate it here from
384     ``view_mode`` and ``view_id``.
385
386     Currently handles two different cases:
387
388     * no view_id, multiple view_mode
389     * single view_id, single view_mode
390
391     :param dict action: action descriptor dictionary to generate a views key for
392     """
393     view_id = action.get('view_id') or False
394     if isinstance(view_id, (list, tuple)):
395         view_id = view_id[0]
396
397     # providing at least one view mode is a requirement, not an option
398     view_modes = action['view_mode'].split(',')
399
400     if len(view_modes) > 1:
401         if view_id:
402             raise ValueError('Non-db action dictionaries should provide '
403                              'either multiple view modes or a single view '
404                              'mode and an optional view id.\n\n Got view '
405                              'modes %r and view id %r for action %r' % (
406                 view_modes, view_id, action))
407         action['views'] = [(False, mode) for mode in view_modes]
408         return
409     action['views'] = [(view_id, view_modes[0])]
410
411 def fix_view_modes(action):
412     """ For historical reasons, OpenERP has weird dealings in relation to
413     view_mode and the view_type attribute (on window actions):
414
415     * one of the view modes is ``tree``, which stands for both list views
416       and tree views
417     * the choice is made by checking ``view_type``, which is either
418       ``form`` for a list view or ``tree`` for an actual tree view
419
420     This methods simply folds the view_type into view_mode by adding a
421     new view mode ``list`` which is the result of the ``tree`` view_mode
422     in conjunction with the ``form`` view_type.
423
424     TODO: this should go into the doc, some kind of "peculiarities" section
425
426     :param dict action: an action descriptor
427     :returns: nothing, the action is modified in place
428     """
429     if not action.get('views'):
430         generate_views(action)
431
432     if action.pop('view_type', 'form') != 'form':
433         return action
434
435     if 'view_mode' in action:
436         action['view_mode'] = ','.join(
437             mode if mode != 'tree' else 'list'
438             for mode in action['view_mode'].split(','))
439     action['views'] = [
440         [id, mode if mode != 'tree' else 'list']
441         for id, mode in action['views']
442     ]
443
444     return action
445
446 def _local_web_translations(trans_file):
447     messages = []
448     try:
449         with open(trans_file) as t_file:
450             po = babel.messages.pofile.read_po(t_file)
451     except Exception:
452         return
453     for x in po:
454         if x.id and x.string and "openerp-web" in x.auto_comments:
455             messages.append({'id': x.id, 'string': x.string})
456     return messages
457
458 def xml2json_from_elementtree(el, preserve_whitespaces=False):
459     """ xml2json-direct
460     Simple and straightforward XML-to-JSON converter in Python
461     New BSD Licensed
462     http://code.google.com/p/xml2json-direct/
463     """
464     res = {}
465     if el.tag[0] == "{":
466         ns, name = el.tag.rsplit("}", 1)
467         res["tag"] = name
468         res["namespace"] = ns[1:]
469     else:
470         res["tag"] = el.tag
471     res["attrs"] = {}
472     for k, v in el.items():
473         res["attrs"][k] = v
474     kids = []
475     if el.text and (preserve_whitespaces or el.text.strip() != ''):
476         kids.append(el.text)
477     for kid in el:
478         kids.append(xml2json_from_elementtree(kid, preserve_whitespaces))
479         if kid.tail and (preserve_whitespaces or kid.tail.strip() != ''):
480             kids.append(kid.tail)
481     res["children"] = kids
482     return res
483
484 def content_disposition(filename):
485     filename = filename.encode('utf8')
486     escaped = urllib2.quote(filename)
487     browser = request.httprequest.user_agent.browser
488     version = int((request.httprequest.user_agent.version or '0').split('.')[0])
489     if browser == 'msie' and version < 9:
490         return "attachment; filename=%s" % escaped
491     elif browser == 'safari':
492         return "attachment; filename=%s" % filename
493     else:
494         return "attachment; filename*=UTF-8''%s" % escaped
495
496
497 #----------------------------------------------------------
498 # OpenERP Web web Controllers
499 #----------------------------------------------------------
500
501 # TODO: to remove once the database manager has been migrated server side
502 #       and `edi` + `pos` addons has been adapted to use render_bootstrap_template()
503 html_template = """<!DOCTYPE html>
504 <html style="height: 100%%">
505     <head>
506         <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"/>
507         <meta http-equiv="content-type" content="text/html; charset=utf-8" />
508         <title>OpenERP</title>
509         <link rel="shortcut icon" href="/web/static/src/img/favicon.ico" type="image/x-icon"/>
510         <link rel="stylesheet" href="/web/static/src/css/full.css" />
511         %(css)s
512         %(js)s
513         <script type="text/javascript">
514             $(function() {
515                 var s = new openerp.init(%(modules)s);
516                 %(init)s
517             });
518         </script>
519     </head>
520     <body>
521         <!--[if lte IE 8]>
522         <script src="//ajax.googleapis.com/ajax/libs/chrome-frame/1/CFInstall.min.js"></script>
523         <script>CFInstall.check({mode: "overlay"});</script>
524         <![endif]-->
525     </body>
526 </html>
527 """
528
529 def render_bootstrap_template(template, values=None, **kw):
530     if values is None:
531         values = dict()
532     try:
533         values['databases'] = http.db_list()
534     except openerp.exceptions.AccessDenied:
535         values['databases'] = None
536
537     if 'modules' not in values:
538         values['modules'] = module_boot()
539     values['modules'] = simplejson.dumps(values['modules'])
540
541     return request.render(template, values, **kw)
542
543 class Home(http.Controller):
544
545     @http.route('/', type='http', auth="none")
546     def index(self, s_action=None, db=None, **kw):
547         return http.local_redirect('/web', query=request.params, keep_hash=True)
548
549     @http.route('/web', type='http', auth="none")
550     def web_client(self, s_action=None, **kw):
551         ensure_db()
552
553         if request.session.uid:
554             if kw.get('redirect'):
555                 return werkzeug.utils.redirect(kw.get('redirect'), 303)
556
557             headers = {
558                 'Cache-Control': 'no-cache',
559                 'Content-Type': 'text/html; charset=utf-8',
560             }
561             return render_bootstrap_template("web.webclient_bootstrap", headers=headers)
562         else:
563             return login_redirect()
564
565     @http.route('/web/login', type='http', auth="none")
566     def web_login(self, redirect=None, **kw):
567         ensure_db()
568
569         if request.httprequest.method == 'GET' and redirect and request.session.uid:
570             return http.redirect_with_hash(redirect)
571
572         if not request.uid:
573             request.uid = openerp.SUPERUSER_ID
574
575         values = request.params.copy()
576         if not redirect:
577             redirect = '/web?' + request.httprequest.query_string
578         values['redirect'] = redirect
579         if request.httprequest.method == 'POST':
580             uid = request.session.authenticate(request.session.db, request.params['login'], request.params['password'])
581             if uid is not False:
582                 return http.redirect_with_hash(redirect)
583             values['error'] = "Wrong login/password"
584         return render_bootstrap_template('web.login', values)
585
586     @http.route('/login', type='http', auth="none")
587     def login(self, db, login, key, redirect="/web", **kw):
588         return login_and_redirect(db, login, key, redirect_url=redirect)
589
590     @http.route('/web/js/<xmlid>', type='http', auth="none")
591     def js_bundle(self, xmlid, **kw):
592         # manifest backward compatible mode, to be removed
593         values = {'manifest_list': manifest_list}
594         assets_html = request.render(xmlid, lazy=False, qcontext=values)
595         bundle = AssetsBundle(xmlid, assets_html)
596
597         response = request.make_response(
598             bundle.js(), [('Content-Type', 'application/javascript')])
599
600         # TODO: check that we don't do weird lazy overriding of __call__ which break body-removal
601         return make_conditional(
602             response, bundle.last_modified, bundle.checksum, max_age=60*5)
603
604     @http.route('/web/css/<xmlid>', type='http', auth='none')
605     def css_bundle(self, xmlid, **kw):
606         values = {'manifest_list': manifest_list} # manifest backward compatible mode, to be removed
607         assets_html = request.render(xmlid, lazy=False, qcontext=values)
608         bundle = AssetsBundle(xmlid, assets_html)
609
610         response = request.make_response(
611             bundle.css(), [('Content-Type', 'text/css')])
612
613         return make_conditional(
614             response, bundle.last_modified, bundle.checksum, max_age=60*5)
615
616
617 class WebClient(http.Controller):
618
619     @http.route('/web/webclient/csslist', type='json', auth="none")
620     def csslist(self, mods=None):
621         return manifest_list('css', mods=mods)
622
623     @http.route('/web/webclient/jslist', type='json', auth="none")
624     def jslist(self, mods=None):
625         return manifest_list('js', mods=mods)
626
627     @http.route('/web/webclient/qweblist', type='json', auth="none")
628     def qweblist(self, mods=None):
629         return manifest_list('qweb', mods=mods)
630
631     @http.route('/web/webclient/qweb', type='http', auth="none")
632     def qweb(self, mods=None, db=None):
633         files = [f[0] for f in manifest_glob('qweb', addons=mods, db=db)]
634         last_modified = get_last_modified(files)
635         if request.httprequest.if_modified_since and request.httprequest.if_modified_since >= last_modified:
636             return werkzeug.wrappers.Response(status=304)
637
638         content, checksum = concat_xml(files)
639
640         return make_conditional(
641             request.make_response(content, [('Content-Type', 'text/xml')]),
642             last_modified, checksum)
643
644     @http.route('/web/webclient/bootstrap_translations', type='json', auth="none")
645     def bootstrap_translations(self, mods):
646         """ Load local translations from *.po files, as a temporary solution
647             until we have established a valid session. This is meant only
648             for translating the login page and db management chrome, using
649             the browser's language. """
650         # For performance reasons we only load a single translation, so for
651         # sub-languages (that should only be partially translated) we load the
652         # main language PO instead - that should be enough for the login screen.
653         lang = request.lang.split('_')[0]
654
655         translations_per_module = {}
656         for addon_name in mods:
657             if http.addons_manifest[addon_name].get('bootstrap'):
658                 addons_path = http.addons_manifest[addon_name]['addons_path']
659                 f_name = os.path.join(addons_path, addon_name, "i18n", lang + ".po")
660                 if not os.path.exists(f_name):
661                     continue
662                 translations_per_module[addon_name] = {'messages': _local_web_translations(f_name)}
663
664         return {"modules": translations_per_module,
665                 "lang_parameters": None}
666
667     @http.route('/web/webclient/translations', type='json', auth="none")
668     def translations(self, mods=None, lang=None):
669         request.disable_db = False
670         uid = openerp.SUPERUSER_ID
671         if mods is None:
672             m = request.registry.get('ir.module.module')
673             mods = [x['name'] for x in m.search_read(request.cr, uid,
674                 [('state','=','installed')], ['name'])]
675         if lang is None:
676             lang = request.context["lang"]
677         res_lang = request.registry.get('res.lang')
678         ids = res_lang.search(request.cr, uid, [("code", "=", lang)])
679         lang_params = None
680         if ids:
681             lang_params = res_lang.read(request.cr, uid, ids[0], ["direction", "date_format", "time_format",
682                                                 "grouping", "decimal_point", "thousands_sep"])
683
684         # Regional languages (ll_CC) must inherit/override their parent lang (ll), but this is
685         # done server-side when the language is loaded, so we only need to load the user's lang.
686         ir_translation = request.registry.get('ir.translation')
687         translations_per_module = {}
688         messages = ir_translation.search_read(request.cr, uid, [('module','in',mods),('lang','=',lang),
689                                                ('comments','like','openerp-web'),('value','!=',False),
690                                                ('value','!=','')],
691                                               ['module','src','value','lang'], order='module')
692         for mod, msg_group in itertools.groupby(messages, key=operator.itemgetter('module')):
693             translations_per_module.setdefault(mod,{'messages':[]})
694             translations_per_module[mod]['messages'].extend({'id': m['src'],
695                                                              'string': m['value']} \
696                                                                 for m in msg_group)
697         return {"modules": translations_per_module,
698                 "lang_parameters": lang_params}
699
700     @http.route('/web/webclient/version_info', type='json', auth="none")
701     def version_info(self):
702         return openerp.service.common.exp_version()
703
704 class Proxy(http.Controller):
705
706     @http.route('/web/proxy/load', type='json', auth="none")
707     def load(self, path):
708         """ Proxies an HTTP request through a JSON request.
709
710         It is strongly recommended to not request binary files through this,
711         as the result will be a binary data blob as well.
712
713         :param path: actual request path
714         :return: file content
715         """
716         from werkzeug.test import Client
717         from werkzeug.wrappers import BaseResponse
718
719         return Client(request.httprequest.app, BaseResponse).get(path).data
720
721 class Database(http.Controller):
722
723     @http.route('/web/database/selector', type='http', auth="none")
724     def selector(self, **kw):
725         try:
726             dbs = http.db_list()
727             if not dbs:
728                 return http.local_redirect('/web/database/manager')
729         except openerp.exceptions.AccessDenied:
730             dbs = False
731         return env.get_template("database_selector.html").render({
732             'databases': dbs,
733             'debug': request.debug,
734         })
735
736     @http.route('/web/database/manager', type='http', auth="none")
737     def manager(self, **kw):
738         # TODO: migrate the webclient's database manager to server side views
739         request.session.logout()
740         js = "\n        ".join('<script type="text/javascript" src="%s"></script>' % i for i in manifest_list('js', debug=request.debug))
741         css = "\n        ".join('<link rel="stylesheet" href="%s">' % i for i in manifest_list('css', debug=request.debug))
742
743         r = html_template % {
744             'js': js,
745             'css': css,
746             'modules': simplejson.dumps(module_boot()),
747             'init': """
748                 var wc = new s.web.WebClient(null, { action: 'database_manager' });
749                 wc.appendTo($(document.body));
750             """
751         }
752         return r
753
754     @http.route('/web/database/get_list', type='json', auth="none")
755     def get_list(self):
756         # TODO change js to avoid calling this method if in monodb mode
757         try:
758             return http.db_list()
759         except openerp.exceptions.AccessDenied:
760             monodb = db_monodb()
761             if monodb:
762                 return [monodb]
763             raise
764
765     @http.route('/web/database/create', type='json', auth="none")
766     def create(self, fields):
767         params = dict(map(operator.itemgetter('name', 'value'), fields))
768         db_created = request.session.proxy("db").create_database(
769             params['super_admin_pwd'],
770             params['db_name'],
771             bool(params.get('demo_data')),
772             params['db_lang'],
773             params['create_admin_pwd'])
774         if db_created:
775             request.session.authenticate(params['db_name'], 'admin', params['create_admin_pwd'])
776         return db_created
777
778     @http.route('/web/database/duplicate', type='json', auth="none")
779     def duplicate(self, fields):
780         params = dict(map(operator.itemgetter('name', 'value'), fields))
781         duplicate_attrs = (
782             params['super_admin_pwd'],
783             params['db_original_name'],
784             params['db_name'],
785         )
786
787         return request.session.proxy("db").duplicate_database(*duplicate_attrs)
788
789     @http.route('/web/database/drop', type='json', auth="none")
790     def drop(self, fields):
791         password, db = operator.itemgetter(
792             'drop_pwd', 'drop_db')(
793                 dict(map(operator.itemgetter('name', 'value'), fields)))
794         
795         try:
796             if request.session.proxy("db").drop(password, db):
797                 return True
798             else:
799                 return False
800         except openerp.exceptions.AccessDenied:
801             return {'error': 'AccessDenied', 'title': 'Drop Database'}
802         except Exception:
803             return {'error': _('Could not drop database !'), 'title': _('Drop Database')}
804
805     @http.route('/web/database/backup', type='http', auth="none")
806     def backup(self, backup_db, backup_pwd, token):
807         try:
808             db_dump = base64.b64decode(
809                 request.session.proxy("db").dump(backup_pwd, backup_db))
810             filename = "%(db)s_%(timestamp)s.dump" % {
811                 'db': backup_db,
812                 'timestamp': datetime.datetime.utcnow().strftime(
813                     "%Y-%m-%d_%H-%M-%SZ")
814             }
815             return request.make_response(db_dump,
816                [('Content-Type', 'application/octet-stream; charset=binary'),
817                ('Content-Disposition', content_disposition(filename))],
818                {'fileToken': token}
819             )
820         except Exception, e:
821             return simplejson.dumps([[],[{'error': openerp.tools.ustr(e), 'title': _('Backup Database')}]])
822
823     @http.route('/web/database/restore', type='http', auth="none")
824     def restore(self, db_file, restore_pwd, new_db, mode):
825         try:
826             copy = mode == 'copy'
827             data = base64.b64encode(db_file.read())
828             request.session.proxy("db").restore(restore_pwd, new_db, data, copy)
829             return ''
830         except openerp.exceptions.AccessDenied, e:
831             raise Exception("AccessDenied")
832
833     @http.route('/web/database/change_password', type='json', auth="none")
834     def change_password(self, fields):
835         old_password, new_password = operator.itemgetter(
836             'old_pwd', 'new_pwd')(
837                 dict(map(operator.itemgetter('name', 'value'), fields)))
838         try:
839             return request.session.proxy("db").change_admin_password(old_password, new_password)
840         except openerp.exceptions.AccessDenied:
841             return {'error': 'AccessDenied', 'title': _('Change Password')}
842         except Exception:
843             return {'error': _('Error, password not changed !'), 'title': _('Change Password')}
844
845 class Session(http.Controller):
846
847     def session_info(self):
848         request.session.ensure_valid()
849         return {
850             "session_id": request.session_id,
851             "uid": request.session.uid,
852             "user_context": request.session.get_context() if request.session.uid else {},
853             "db": request.session.db,
854             "username": request.session.login,
855         }
856
857     @http.route('/web/session/get_session_info', type='json', auth="none")
858     def get_session_info(self):
859         request.uid = request.session.uid
860         request.disable_db = False
861         return self.session_info()
862
863     @http.route('/web/session/authenticate', type='json', auth="none")
864     def authenticate(self, db, login, password, base_location=None):
865         request.session.authenticate(db, login, password)
866
867         return self.session_info()
868
869     @http.route('/web/session/change_password', type='json', auth="user")
870     def change_password(self, fields):
871         old_password, new_password,confirm_password = operator.itemgetter('old_pwd', 'new_password','confirm_pwd')(
872                 dict(map(operator.itemgetter('name', 'value'), fields)))
873         if not (old_password.strip() and new_password.strip() and confirm_password.strip()):
874             return {'error':_('You cannot leave any password empty.'),'title': _('Change Password')}
875         if new_password != confirm_password:
876             return {'error': _('The new password and its confirmation must be identical.'),'title': _('Change Password')}
877         try:
878             if request.session.model('res.users').change_password(
879                 old_password, new_password):
880                 return {'new_password':new_password}
881         except Exception:
882             return {'error': _('The old password you provided is incorrect, your password was not changed.'), 'title': _('Change Password')}
883         return {'error': _('Error, password not changed !'), 'title': _('Change Password')}
884
885     @http.route('/web/session/get_lang_list', type='json', auth="none")
886     def get_lang_list(self):
887         try:
888             return request.session.proxy("db").list_lang() or []
889         except Exception, e:
890             return {"error": e, "title": _("Languages")}
891
892     @http.route('/web/session/modules', type='json', auth="user")
893     def modules(self):
894         # return all installed modules. Web client is smart enough to not load a module twice
895         return module_installed()
896
897     @http.route('/web/session/save_session_action', type='json', auth="user")
898     def save_session_action(self, the_action):
899         """
900         This method store an action object in the session object and returns an integer
901         identifying that action. The method get_session_action() can be used to get
902         back the action.
903
904         :param the_action: The action to save in the session.
905         :type the_action: anything
906         :return: A key identifying the saved action.
907         :rtype: integer
908         """
909         return request.httpsession.save_action(the_action)
910
911     @http.route('/web/session/get_session_action', type='json', auth="user")
912     def get_session_action(self, key):
913         """
914         Gets back a previously saved action. This method can return None if the action
915         was saved since too much time (this case should be handled in a smart way).
916
917         :param key: The key given by save_session_action()
918         :type key: integer
919         :return: The saved action or None.
920         :rtype: anything
921         """
922         return request.httpsession.get_action(key)
923
924     @http.route('/web/session/check', type='json', auth="user")
925     def check(self):
926         request.session.assert_valid()
927         return None
928
929     @http.route('/web/session/destroy', type='json', auth="user")
930     def destroy(self):
931         request.session.logout()
932
933     @http.route('/web/session/logout', type='http', auth="none")
934     def logout(self, redirect='/web'):
935         request.session.logout(keep_db=True)
936         return werkzeug.utils.redirect(redirect, 303)
937
938 class Menu(http.Controller):
939
940     @http.route('/web/menu/get_user_roots', type='json', auth="user")
941     def get_user_roots(self):
942         """ Return all root menu ids visible for the session user.
943
944         :return: the root menu ids
945         :rtype: list(int)
946         """
947         s = request.session
948         Menus = s.model('ir.ui.menu')
949         # If a menu action is defined use its domain to get the root menu items
950         user_menu_id = s.model('res.users').read([s.uid], ['menu_id'],
951                                                  request.context)[0]['menu_id']
952
953         menu_domain = [('parent_id', '=', False)]
954         if user_menu_id:
955             domain_string = s.model('ir.actions.act_window').read(
956                 [user_menu_id[0]], ['domain'],request.context)[0]['domain']
957             if domain_string:
958                 menu_domain = ast.literal_eval(domain_string)
959
960         return Menus.search(menu_domain, 0, False, False, request.context)
961
962     @http.route('/web/menu/load', type='json', auth="user")
963     def load(self):
964         """ Loads all menu items (all applications and their sub-menus).
965
966         :return: the menu root
967         :rtype: dict('children': menu_nodes)
968         """
969         Menus = request.session.model('ir.ui.menu')
970
971         fields = ['name', 'sequence', 'parent_id', 'action']
972         menu_root_ids = self.get_user_roots()
973         menu_roots = Menus.read(menu_root_ids, fields, request.context) if menu_root_ids else []
974         menu_root = {
975             'id': False,
976             'name': 'root',
977             'parent_id': [-1, ''],
978             'children': menu_roots,
979             'all_menu_ids': menu_root_ids,
980         }
981         if not menu_roots:
982             return menu_root
983
984         # menus are loaded fully unlike a regular tree view, cause there are a
985         # limited number of items (752 when all 6.1 addons are installed)
986         menu_ids = Menus.search([('id', 'child_of', menu_root_ids)], 0, False, False, request.context)
987         menu_items = Menus.read(menu_ids, fields, request.context)
988         # adds roots at the end of the sequence, so that they will overwrite
989         # equivalent menu items from full menu read when put into id:item
990         # mapping, resulting in children being correctly set on the roots.
991         menu_items.extend(menu_roots)
992         menu_root['all_menu_ids'] = menu_ids # includes menu_root_ids!
993
994         # make a tree using parent_id
995         menu_items_map = dict(
996             (menu_item["id"], menu_item) for menu_item in menu_items)
997         for menu_item in menu_items:
998             if menu_item['parent_id']:
999                 parent = menu_item['parent_id'][0]
1000             else:
1001                 parent = False
1002             if parent in menu_items_map:
1003                 menu_items_map[parent].setdefault(
1004                     'children', []).append(menu_item)
1005
1006         # sort by sequence a tree using parent_id
1007         for menu_item in menu_items:
1008             menu_item.setdefault('children', []).sort(
1009                 key=operator.itemgetter('sequence'))
1010
1011         return menu_root
1012
1013     @http.route('/web/menu/load_needaction', type='json', auth="user")
1014     def load_needaction(self, menu_ids):
1015         """ Loads needaction counters for specific menu ids.
1016
1017             :return: needaction data
1018             :rtype: dict(menu_id: {'needaction_enabled': boolean, 'needaction_counter': int})
1019         """
1020         return request.session.model('ir.ui.menu').get_needaction_data(menu_ids, request.context)
1021
1022 class DataSet(http.Controller):
1023
1024     @http.route('/web/dataset/search_read', type='json', auth="user")
1025     def search_read(self, model, fields=False, offset=0, limit=False, domain=None, sort=None):
1026         return self.do_search_read(model, fields, offset, limit, domain, sort)
1027     def do_search_read(self, model, fields=False, offset=0, limit=False, domain=None
1028                        , sort=None):
1029         """ Performs a search() followed by a read() (if needed) using the
1030         provided search criteria
1031
1032         :param str model: the name of the model to search on
1033         :param fields: a list of the fields to return in the result records
1034         :type fields: [str]
1035         :param int offset: from which index should the results start being returned
1036         :param int limit: the maximum number of records to return
1037         :param list domain: the search domain for the query
1038         :param list sort: sorting directives
1039         :returns: A structure (dict) with two keys: ids (all the ids matching
1040                   the (domain, context) pair) and records (paginated records
1041                   matching fields selection set)
1042         :rtype: list
1043         """
1044         Model = request.session.model(model)
1045
1046         records = Model.search_read(domain, fields, offset or 0, limit or False, sort or False,
1047                            request.context)
1048         if not records:
1049             return {
1050                 'length': 0,
1051                 'records': []
1052             }
1053         if limit and len(records) == limit:
1054             length = Model.search_count(domain, request.context)
1055         else:
1056             length = len(records) + (offset or 0)
1057         return {
1058             'length': length,
1059             'records': records
1060         }
1061
1062     @http.route('/web/dataset/load', type='json', auth="user")
1063     def load(self, model, id, fields):
1064         m = request.session.model(model)
1065         value = {}
1066         r = m.read([id], False, request.context)
1067         if r:
1068             value = r[0]
1069         return {'value': value}
1070
1071     def call_common(self, model, method, args, domain_id=None, context_id=None):
1072         return self._call_kw(model, method, args, {})
1073
1074     def _call_kw(self, model, method, args, kwargs):
1075         # Temporary implements future display_name special field for model#read()
1076         if method in ('read', 'search_read') and kwargs.get('context', {}).get('future_display_name'):
1077             if 'display_name' in args[1]:
1078                 if method == 'read':
1079                     names = dict(request.session.model(model).name_get(args[0], **kwargs))
1080                 else:
1081                     names = dict(request.session.model(model).name_search('', args[0], **kwargs))
1082                 args[1].remove('display_name')
1083                 records = getattr(request.session.model(model), method)(*args, **kwargs)
1084                 for record in records:
1085                     record['display_name'] = \
1086                         names.get(record['id']) or "%s#%d" % (model, (record['id']))
1087                 return records
1088
1089         if method.startswith('_'):
1090             raise Exception("Access Denied: Underscore prefixed methods cannot be remotely called")
1091
1092         return getattr(request.registry.get(model), method)(request.cr, request.uid, *args, **kwargs)
1093
1094     @http.route('/web/dataset/call', type='json', auth="user")
1095     def call(self, model, method, args, domain_id=None, context_id=None):
1096         return self._call_kw(model, method, args, {})
1097
1098     @http.route(['/web/dataset/call_kw', '/web/dataset/call_kw/<path:path>'], type='json', auth="user")
1099     def call_kw(self, model, method, args, kwargs, path=None):
1100         return self._call_kw(model, method, args, kwargs)
1101
1102     @http.route('/web/dataset/call_button', type='json', auth="user")
1103     def call_button(self, model, method, args, domain_id=None, context_id=None):
1104         action = self._call_kw(model, method, args, {})
1105         if isinstance(action, dict) and action.get('type') != '':
1106             return clean_action(action)
1107         return False
1108
1109     @http.route('/web/dataset/exec_workflow', type='json', auth="user")
1110     def exec_workflow(self, model, id, signal):
1111         return request.session.exec_workflow(model, id, signal)
1112
1113     @http.route('/web/dataset/resequence', type='json', auth="user")
1114     def resequence(self, model, ids, field='sequence', offset=0):
1115         """ Re-sequences a number of records in the model, by their ids
1116
1117         The re-sequencing starts at the first model of ``ids``, the sequence
1118         number is incremented by one after each record and starts at ``offset``
1119
1120         :param ids: identifiers of the records to resequence, in the new sequence order
1121         :type ids: list(id)
1122         :param str field: field used for sequence specification, defaults to
1123                           "sequence"
1124         :param int offset: sequence number for first record in ``ids``, allows
1125                            starting the resequencing from an arbitrary number,
1126                            defaults to ``0``
1127         """
1128         m = request.session.model(model)
1129         if not m.fields_get([field]):
1130             return False
1131         # python 2.6 has no start parameter
1132         for i, id in enumerate(ids):
1133             m.write(id, { field: i + offset })
1134         return True
1135
1136 class View(http.Controller):
1137
1138     @http.route('/web/view/add_custom', type='json', auth="user")
1139     def add_custom(self, view_id, arch):
1140         CustomView = request.session.model('ir.ui.view.custom')
1141         CustomView.create({
1142             'user_id': request.session.uid,
1143             'ref_id': view_id,
1144             'arch': arch
1145         }, request.context)
1146         return {'result': True}
1147
1148     @http.route('/web/view/undo_custom', type='json', auth="user")
1149     def undo_custom(self, view_id, reset=False):
1150         CustomView = request.session.model('ir.ui.view.custom')
1151         vcustom = CustomView.search([('user_id', '=', request.session.uid), ('ref_id' ,'=', view_id)],
1152                                     0, False, False, request.context)
1153         if vcustom:
1154             if reset:
1155                 CustomView.unlink(vcustom, request.context)
1156             else:
1157                 CustomView.unlink([vcustom[0]], request.context)
1158             return {'result': True}
1159         return {'result': False}
1160
1161 class TreeView(View):
1162
1163     @http.route('/web/treeview/action', type='json', auth="user")
1164     def action(self, model, id):
1165         return load_actions_from_ir_values(
1166             'action', 'tree_but_open',[(model, id)],
1167             False)
1168
1169 class Binary(http.Controller):
1170
1171     @http.route('/web/binary/image', type='http', auth="user")
1172     def image(self, model, id, field, **kw):
1173         last_update = '__last_update'
1174         Model = request.session.model(model)
1175         headers = [('Content-Type', 'image/png')]
1176         etag = request.httprequest.headers.get('If-None-Match')
1177         hashed_session = hashlib.md5(request.session_id).hexdigest()
1178         retag = hashed_session
1179         id = None if not id else simplejson.loads(id)
1180         if type(id) is list:
1181             id = id[0] # m2o
1182         try:
1183             if etag:
1184                 if not id and hashed_session == etag:
1185                     return werkzeug.wrappers.Response(status=304)
1186                 else:
1187                     date = Model.read([id], [last_update], request.context)[0].get(last_update)
1188                     if hashlib.md5(date).hexdigest() == etag:
1189                         return werkzeug.wrappers.Response(status=304)
1190
1191             if not id:
1192                 res = Model.default_get([field], request.context).get(field)
1193                 image_base64 = res
1194             else:
1195                 res = Model.read([id], [last_update, field], request.context)[0]
1196                 retag = hashlib.md5(res.get(last_update)).hexdigest()
1197                 image_base64 = res.get(field)
1198
1199             if kw.get('resize'):
1200                 resize = kw.get('resize').split(',')
1201                 if len(resize) == 2 and int(resize[0]) and int(resize[1]):
1202                     width = int(resize[0])
1203                     height = int(resize[1])
1204                     # resize maximum 500*500
1205                     if width > 500: width = 500
1206                     if height > 500: height = 500
1207                     image_base64 = openerp.tools.image_resize_image(base64_source=image_base64, size=(width, height), encoding='base64', filetype='PNG')
1208
1209             image_data = base64.b64decode(image_base64)
1210
1211         except Exception:
1212             image_data = self.placeholder()
1213         headers.append(('ETag', retag))
1214         headers.append(('Content-Length', len(image_data)))
1215         try:
1216             ncache = int(kw.get('cache'))
1217             headers.append(('Cache-Control', 'no-cache' if ncache == 0 else 'max-age=%s' % (ncache)))
1218         except:
1219             pass
1220         return request.make_response(image_data, headers)
1221
1222     def placeholder(self, image='placeholder.png'):
1223         addons_path = http.addons_manifest['web']['addons_path']
1224         return open(os.path.join(addons_path, 'web', 'static', 'src', 'img', image), 'rb').read()
1225
1226     @http.route('/web/binary/saveas', type='http', auth="user")
1227     @serialize_exception
1228     def saveas(self, model, field, id=None, filename_field=None, **kw):
1229         """ Download link for files stored as binary fields.
1230
1231         If the ``id`` parameter is omitted, fetches the default value for the
1232         binary field (via ``default_get``), otherwise fetches the field for
1233         that precise record.
1234
1235         :param str model: name of the model to fetch the binary from
1236         :param str field: binary field
1237         :param str id: id of the record from which to fetch the binary
1238         :param str filename_field: field holding the file's name, if any
1239         :returns: :class:`werkzeug.wrappers.Response`
1240         """
1241         Model = request.session.model(model)
1242         fields = [field]
1243         if filename_field:
1244             fields.append(filename_field)
1245         if id:
1246             res = Model.read([int(id)], fields, request.context)[0]
1247         else:
1248             res = Model.default_get(fields, request.context)
1249         filecontent = base64.b64decode(res.get(field, ''))
1250         if not filecontent:
1251             return request.not_found()
1252         else:
1253             filename = '%s_%s' % (model.replace('.', '_'), id)
1254             if filename_field:
1255                 filename = res.get(filename_field, '') or filename
1256             return request.make_response(filecontent,
1257                 [('Content-Type', 'application/octet-stream'),
1258                  ('Content-Disposition', content_disposition(filename))])
1259
1260     @http.route('/web/binary/saveas_ajax', type='http', auth="user")
1261     @serialize_exception
1262     def saveas_ajax(self, data, token):
1263         jdata = simplejson.loads(data)
1264         model = jdata['model']
1265         field = jdata['field']
1266         data = jdata['data']
1267         id = jdata.get('id', None)
1268         filename_field = jdata.get('filename_field', None)
1269         context = jdata.get('context', {})
1270
1271         Model = request.session.model(model)
1272         fields = [field]
1273         if filename_field:
1274             fields.append(filename_field)
1275         if data:
1276             res = { field: data }
1277         elif id:
1278             res = Model.read([int(id)], fields, context)[0]
1279         else:
1280             res = Model.default_get(fields, context)
1281         filecontent = base64.b64decode(res.get(field, ''))
1282         if not filecontent:
1283             raise ValueError(_("No content found for field '%s' on '%s:%s'") %
1284                 (field, model, id))
1285         else:
1286             filename = '%s_%s' % (model.replace('.', '_'), id)
1287             if filename_field:
1288                 filename = res.get(filename_field, '') or filename
1289             return request.make_response(filecontent,
1290                 headers=[('Content-Type', 'application/octet-stream'),
1291                         ('Content-Disposition', content_disposition(filename))],
1292                 cookies={'fileToken': token})
1293
1294     @http.route('/web/binary/upload', type='http', auth="user")
1295     @serialize_exception
1296     def upload(self, callback, ufile):
1297         # TODO: might be useful to have a configuration flag for max-length file uploads
1298         out = """<script language="javascript" type="text/javascript">
1299                     var win = window.top.window;
1300                     win.jQuery(win).trigger(%s, %s);
1301                 </script>"""
1302         try:
1303             data = ufile.read()
1304             args = [len(data), ufile.filename,
1305                     ufile.content_type, base64.b64encode(data)]
1306         except Exception, e:
1307             args = [False, e.message]
1308         return out % (simplejson.dumps(callback), simplejson.dumps(args))
1309
1310     @http.route('/web/binary/upload_attachment', type='http', auth="user")
1311     @serialize_exception
1312     def upload_attachment(self, callback, model, id, ufile):
1313         Model = request.session.model('ir.attachment')
1314         out = """<script language="javascript" type="text/javascript">
1315                     var win = window.top.window;
1316                     win.jQuery(win).trigger(%s, %s);
1317                 </script>"""
1318         try:
1319             attachment_id = Model.create({
1320                 'name': ufile.filename,
1321                 'datas': base64.encodestring(ufile.read()),
1322                 'datas_fname': ufile.filename,
1323                 'res_model': model,
1324                 'res_id': int(id)
1325             }, request.context)
1326             args = {
1327                 'filename': ufile.filename,
1328                 'id':  attachment_id
1329             }
1330         except Exception:
1331             args = {'error': "Something horrible happened"}
1332         return out % (simplejson.dumps(callback), simplejson.dumps(args))
1333
1334     @http.route([
1335         '/web/binary/company_logo',
1336         '/logo',
1337         '/logo.png',
1338     ], type='http', auth="none")
1339     def company_logo(self, dbname=None):
1340         # TODO add etag, refactor to use /image code for etag
1341         uid = None
1342         if request.session.db:
1343             dbname = request.session.db
1344             uid = request.session.uid
1345         elif dbname is None:
1346             dbname = db_monodb()
1347
1348         if not uid:
1349             uid = openerp.SUPERUSER_ID
1350
1351         if not dbname:
1352             image_data = self.placeholder('logo.png')
1353         else:
1354             try:
1355                 # create an empty registry
1356                 registry = openerp.modules.registry.Registry(dbname)
1357                 with registry.cursor() as cr:
1358                     cr.execute("""SELECT c.logo_web
1359                                     FROM res_users u
1360                                LEFT JOIN res_company c
1361                                       ON c.id = u.company_id
1362                                    WHERE u.id = %s
1363                                """, (uid,))
1364                     row = cr.fetchone()
1365                     if row and row[0]:
1366                         image_data = str(row[0]).decode('base64')
1367                     else:
1368                         image_data = self.placeholder('nologo.png')
1369             except Exception:
1370                 image_data = self.placeholder('logo.png')
1371
1372         headers = [
1373             ('Content-Type', 'image/png'),
1374             ('Content-Length', len(image_data)),
1375         ]
1376         return request.make_response(image_data, headers)
1377
1378 class Action(http.Controller):
1379
1380     @http.route('/web/action/load', type='json', auth="user")
1381     def load(self, action_id, do_not_eval=False):
1382         Actions = request.session.model('ir.actions.actions')
1383         value = False
1384         try:
1385             action_id = int(action_id)
1386         except ValueError:
1387             try:
1388                 module, xmlid = action_id.split('.', 1)
1389                 model, action_id = request.session.model('ir.model.data').get_object_reference(module, xmlid)
1390                 assert model.startswith('ir.actions.')
1391             except Exception:
1392                 action_id = 0   # force failed read
1393
1394         base_action = Actions.read([action_id], ['type'], request.context)
1395         if base_action:
1396             ctx = {}
1397             action_type = base_action[0]['type']
1398             if action_type == 'ir.actions.report.xml':
1399                 ctx.update({'bin_size': True})
1400             ctx.update(request.context)
1401             action = request.session.model(action_type).read([action_id], False, ctx)
1402             if action:
1403                 value = clean_action(action[0])
1404         return value
1405
1406     @http.route('/web/action/run', type='json', auth="user")
1407     def run(self, action_id):
1408         return_action = request.session.model('ir.actions.server').run(
1409             [action_id], request.context)
1410         if return_action:
1411             return clean_action(return_action)
1412         else:
1413             return False
1414
1415 class Export(http.Controller):
1416
1417     @http.route('/web/export/formats', type='json', auth="user")
1418     def formats(self):
1419         """ Returns all valid export formats
1420
1421         :returns: for each export format, a pair of identifier and printable name
1422         :rtype: [(str, str)]
1423         """
1424         return [
1425             {'tag': 'csv', 'label': 'CSV'},
1426             {'tag': 'xls', 'label': 'Excel', 'error': None if xlwt else "XLWT required"},
1427         ]
1428
1429     def fields_get(self, model):
1430         Model = request.session.model(model)
1431         fields = Model.fields_get(False, request.context)
1432         return fields
1433
1434     @http.route('/web/export/get_fields', type='json', auth="user")
1435     def get_fields(self, model, prefix='', parent_name= '',
1436                    import_compat=True, parent_field_type=None,
1437                    exclude=None):
1438
1439         if import_compat and parent_field_type == "many2one":
1440             fields = {}
1441         else:
1442             fields = self.fields_get(model)
1443
1444         if import_compat:
1445             fields.pop('id', None)
1446         else:
1447             fields['.id'] = fields.pop('id', {'string': 'ID'})
1448
1449         fields_sequence = sorted(fields.iteritems(),
1450             key=lambda field: field[1].get('string', ''))
1451
1452         records = []
1453         for field_name, field in fields_sequence:
1454             if import_compat:
1455                 if exclude and field_name in exclude:
1456                     continue
1457                 if field.get('readonly'):
1458                     # If none of the field's states unsets readonly, skip the field
1459                     if all(dict(attrs).get('readonly', True)
1460                            for attrs in field.get('states', {}).values()):
1461                         continue
1462             if not field.get('exportable', True):
1463                 continue
1464
1465             id = prefix + (prefix and '/'or '') + field_name
1466             name = parent_name + (parent_name and '/' or '') + field['string']
1467             record = {'id': id, 'string': name,
1468                       'value': id, 'children': False,
1469                       'field_type': field.get('type'),
1470                       'required': field.get('required'),
1471                       'relation_field': field.get('relation_field')}
1472             records.append(record)
1473
1474             if len(name.split('/')) < 3 and 'relation' in field:
1475                 ref = field.pop('relation')
1476                 record['value'] += '/id'
1477                 record['params'] = {'model': ref, 'prefix': id, 'name': name}
1478
1479                 if not import_compat or field['type'] == 'one2many':
1480                     # m2m field in import_compat is childless
1481                     record['children'] = True
1482
1483         return records
1484
1485     @http.route('/web/export/namelist', type='json', auth="user")
1486     def namelist(self, model, export_id):
1487         # TODO: namelist really has no reason to be in Python (although itertools.groupby helps)
1488         export = request.session.model("ir.exports").read([export_id])[0]
1489         export_fields_list = request.session.model("ir.exports.line").read(
1490             export['export_fields'])
1491
1492         fields_data = self.fields_info(
1493             model, map(operator.itemgetter('name'), export_fields_list))
1494
1495         return [
1496             {'name': field['name'], 'label': fields_data[field['name']]}
1497             for field in export_fields_list
1498         ]
1499
1500     def fields_info(self, model, export_fields):
1501         info = {}
1502         fields = self.fields_get(model)
1503         if ".id" in export_fields:
1504             fields['.id'] = fields.pop('id', {'string': 'ID'})
1505
1506         # To make fields retrieval more efficient, fetch all sub-fields of a
1507         # given field at the same time. Because the order in the export list is
1508         # arbitrary, this requires ordering all sub-fields of a given field
1509         # together so they can be fetched at the same time
1510         #
1511         # Works the following way:
1512         # * sort the list of fields to export, the default sorting order will
1513         #   put the field itself (if present, for xmlid) and all of its
1514         #   sub-fields right after it
1515         # * then, group on: the first field of the path (which is the same for
1516         #   a field and for its subfields and the length of splitting on the
1517         #   first '/', which basically means grouping the field on one side and
1518         #   all of the subfields on the other. This way, we have the field (for
1519         #   the xmlid) with length 1, and all of the subfields with the same
1520         #   base but a length "flag" of 2
1521         # * if we have a normal field (length 1), just add it to the info
1522         #   mapping (with its string) as-is
1523         # * otherwise, recursively call fields_info via graft_subfields.
1524         #   all graft_subfields does is take the result of fields_info (on the
1525         #   field's model) and prepend the current base (current field), which
1526         #   rebuilds the whole sub-tree for the field
1527         #
1528         # result: because we're not fetching the fields_get for half the
1529         # database models, fetching a namelist with a dozen fields (including
1530         # relational data) falls from ~6s to ~300ms (on the leads model).
1531         # export lists with no sub-fields (e.g. import_compatible lists with
1532         # no o2m) are even more efficient (from the same 6s to ~170ms, as
1533         # there's a single fields_get to execute)
1534         for (base, length), subfields in itertools.groupby(
1535                 sorted(export_fields),
1536                 lambda field: (field.split('/', 1)[0], len(field.split('/', 1)))):
1537             subfields = list(subfields)
1538             if length == 2:
1539                 # subfields is a seq of $base/*rest, and not loaded yet
1540                 info.update(self.graft_subfields(
1541                     fields[base]['relation'], base, fields[base]['string'],
1542                     subfields
1543                 ))
1544             elif base in fields:
1545                 info[base] = fields[base]['string']
1546
1547         return info
1548
1549     def graft_subfields(self, model, prefix, prefix_string, fields):
1550         export_fields = [field.split('/', 1)[1] for field in fields]
1551         return (
1552             (prefix + '/' + k, prefix_string + '/' + v)
1553             for k, v in self.fields_info(model, export_fields).iteritems())
1554
1555 class ExportFormat(object):
1556     raw_data = False
1557
1558     @property
1559     def content_type(self):
1560         """ Provides the format's content type """
1561         raise NotImplementedError()
1562
1563     def filename(self, base):
1564         """ Creates a valid filename for the format (with extension) from the
1565          provided base name (exension-less)
1566         """
1567         raise NotImplementedError()
1568
1569     def from_data(self, fields, rows):
1570         """ Conversion method from OpenERP's export data to whatever the
1571         current export class outputs
1572
1573         :params list fields: a list of fields to export
1574         :params list rows: a list of records to export
1575         :returns:
1576         :rtype: bytes
1577         """
1578         raise NotImplementedError()
1579
1580     def base(self, data, token):
1581         model, fields, ids, domain, import_compat = \
1582             operator.itemgetter('model', 'fields', 'ids', 'domain',
1583                                 'import_compat')(
1584                 simplejson.loads(data))
1585
1586         Model = request.session.model(model)
1587         ids = ids or Model.search(domain, 0, False, False, request.context)
1588
1589         field_names = map(operator.itemgetter('name'), fields)
1590         import_data = Model.export_data(ids, field_names, self.raw_data, context=request.context).get('datas',[])
1591
1592         if import_compat:
1593             columns_headers = field_names
1594         else:
1595             columns_headers = [val['label'].strip() for val in fields]
1596
1597
1598         return request.make_response(self.from_data(columns_headers, import_data),
1599             headers=[('Content-Disposition',
1600                             content_disposition(self.filename(model))),
1601                      ('Content-Type', self.content_type)],
1602             cookies={'fileToken': token})
1603
1604 class CSVExport(ExportFormat, http.Controller):
1605
1606     @http.route('/web/export/csv', type='http', auth="user")
1607     @serialize_exception
1608     def index(self, data, token):
1609         return self.base(data, token)
1610
1611     @property
1612     def content_type(self):
1613         return 'text/csv;charset=utf8'
1614
1615     def filename(self, base):
1616         return base + '.csv'
1617
1618     def from_data(self, fields, rows):
1619         fp = StringIO()
1620         writer = csv.writer(fp, quoting=csv.QUOTE_ALL)
1621
1622         writer.writerow([name.encode('utf-8') for name in fields])
1623
1624         for data in rows:
1625             row = []
1626             for d in data:
1627                 if isinstance(d, basestring):
1628                     d = d.replace('\n',' ').replace('\t',' ')
1629                     try:
1630                         d = d.encode('utf-8')
1631                     except UnicodeError:
1632                         pass
1633                 if d is False: d = None
1634                 row.append(d)
1635             writer.writerow(row)
1636
1637         fp.seek(0)
1638         data = fp.read()
1639         fp.close()
1640         return data
1641
1642 class ExcelExport(ExportFormat, http.Controller):
1643     # Excel needs raw data to correctly handle numbers and date values
1644     raw_data = True
1645
1646     @http.route('/web/export/xls', type='http', auth="user")
1647     @serialize_exception
1648     def index(self, data, token):
1649         return self.base(data, token)
1650
1651     @property
1652     def content_type(self):
1653         return 'application/vnd.ms-excel'
1654
1655     def filename(self, base):
1656         return base + '.xls'
1657
1658     def from_data(self, fields, rows):
1659         workbook = xlwt.Workbook()
1660         worksheet = workbook.add_sheet('Sheet 1')
1661
1662         for i, fieldname in enumerate(fields):
1663             worksheet.write(0, i, fieldname)
1664             worksheet.col(i).width = 8000 # around 220 pixels
1665
1666         base_style = xlwt.easyxf('align: wrap yes')
1667         date_style = xlwt.easyxf('align: wrap yes', num_format_str='YYYY-MM-DD')
1668         datetime_style = xlwt.easyxf('align: wrap yes', num_format_str='YYYY-MM-DD HH:mm:SS')
1669
1670         for row_index, row in enumerate(rows):
1671             for cell_index, cell_value in enumerate(row):
1672                 cell_style = base_style
1673                 if isinstance(cell_value, basestring):
1674                     cell_value = re.sub("\r", " ", cell_value)
1675                 elif isinstance(cell_value, datetime.datetime):
1676                     cell_style = datetime_style
1677                 elif isinstance(cell_value, datetime.date):
1678                     cell_style = date_style
1679                 worksheet.write(row_index + 1, cell_index, cell_value, cell_style)
1680
1681         fp = StringIO()
1682         workbook.save(fp)
1683         fp.seek(0)
1684         data = fp.read()
1685         fp.close()
1686         return data
1687
1688 class Reports(http.Controller):
1689     POLLING_DELAY = 0.25
1690     TYPES_MAPPING = {
1691         'doc': 'application/vnd.ms-word',
1692         'html': 'text/html',
1693         'odt': 'application/vnd.oasis.opendocument.text',
1694         'pdf': 'application/pdf',
1695         'sxw': 'application/vnd.sun.xml.writer',
1696         'xls': 'application/vnd.ms-excel',
1697     }
1698
1699     @http.route('/web/report', type='http', auth="user")
1700     @serialize_exception
1701     def index(self, action, token):
1702         action = simplejson.loads(action)
1703
1704         report_srv = request.session.proxy("report")
1705         context = dict(request.context)
1706         context.update(action["context"])
1707
1708         report_data = {}
1709         report_ids = context.get("active_ids", None)
1710         if 'report_type' in action:
1711             report_data['report_type'] = action['report_type']
1712         if 'datas' in action:
1713             if 'ids' in action['datas']:
1714                 report_ids = action['datas'].pop('ids')
1715             report_data.update(action['datas'])
1716
1717         report_id = report_srv.report(
1718             request.session.db, request.session.uid, request.session.password,
1719             action["report_name"], report_ids,
1720             report_data, context)
1721
1722         report_struct = None
1723         while True:
1724             report_struct = report_srv.report_get(
1725                 request.session.db, request.session.uid, request.session.password, report_id)
1726             if report_struct["state"]:
1727                 break
1728
1729             time.sleep(self.POLLING_DELAY)
1730
1731         report = base64.b64decode(report_struct['result'])
1732         if report_struct.get('code') == 'zlib':
1733             report = zlib.decompress(report)
1734         report_mimetype = self.TYPES_MAPPING.get(
1735             report_struct['format'], 'octet-stream')
1736         file_name = action.get('name', 'report')
1737         if 'name' not in action:
1738             reports = request.session.model('ir.actions.report.xml')
1739             res_id = reports.search([('report_name', '=', action['report_name']),],
1740                                     0, False, False, context)
1741             if len(res_id) > 0:
1742                 file_name = reports.read(res_id[0], ['name'], context)['name']
1743             else:
1744                 file_name = action['report_name']
1745         file_name = '%s.%s' % (file_name, report_struct['format'])
1746
1747         return request.make_response(report,
1748              headers=[
1749                  ('Content-Disposition', content_disposition(file_name)),
1750                  ('Content-Type', report_mimetype),
1751                  ('Content-Length', len(report))],
1752              cookies={'fileToken': token})
1753
1754 class Apps(http.Controller):
1755     @http.route('/apps/<app>', auth='user')
1756     def get_app_url(self, req, app):
1757         act_window_obj = request.session.model('ir.actions.act_window')
1758         ir_model_data = request.session.model('ir.model.data')
1759         try:
1760             action_id = ir_model_data.get_object_reference('base', 'open_module_tree')[1]
1761             action = act_window_obj.read(action_id, ['name', 'type', 'res_model', 'view_mode', 'view_type', 'context', 'views', 'domain'])
1762             action['target'] = 'current'
1763         except ValueError:
1764             action = False
1765         try:
1766             app_id = ir_model_data.get_object_reference('base', 'module_%s' % app)[1]
1767         except ValueError:
1768             app_id = False
1769
1770         if action and app_id:
1771             action['res_id'] = app_id
1772             action['view_mode'] = 'form'
1773             action['views'] = [(False, u'form')]
1774
1775         sakey = Session().save_session_action(action)
1776         debug = '?debug' if req.debug else ''
1777         return werkzeug.utils.redirect('/web{0}#sa={1}'.format(debug, sakey))
1778         
1779
1780
1781 # vim:expandtab:tabstop=4:softtabstop=4:shiftwidth=4: