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