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