[MERGE] forward port of branch 8.0 up to 591e329
[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             "company_id": request.env.user.company_id.id if request.session.uid else None,
800         }
801
802     @http.route('/web/session/get_session_info', type='json', auth="none")
803     def get_session_info(self):
804         request.uid = request.session.uid
805         request.disable_db = False
806         return self.session_info()
807
808     @http.route('/web/session/authenticate', type='json', auth="none")
809     def authenticate(self, db, login, password, base_location=None):
810         request.session.authenticate(db, login, password)
811
812         return self.session_info()
813
814     @http.route('/web/session/change_password', type='json', auth="user")
815     def change_password(self, fields):
816         old_password, new_password,confirm_password = operator.itemgetter('old_pwd', 'new_password','confirm_pwd')(
817                 dict(map(operator.itemgetter('name', 'value'), fields)))
818         if not (old_password.strip() and new_password.strip() and confirm_password.strip()):
819             return {'error':_('You cannot leave any password empty.'),'title': _('Change Password')}
820         if new_password != confirm_password:
821             return {'error': _('The new password and its confirmation must be identical.'),'title': _('Change Password')}
822         try:
823             if request.session.model('res.users').change_password(
824                 old_password, new_password):
825                 return {'new_password':new_password}
826         except Exception:
827             return {'error': _('The old password you provided is incorrect, your password was not changed.'), 'title': _('Change Password')}
828         return {'error': _('Error, password not changed !'), 'title': _('Change Password')}
829
830     @http.route('/web/session/get_lang_list', type='json', auth="none")
831     def get_lang_list(self):
832         try:
833             return request.session.proxy("db").list_lang() or []
834         except Exception, e:
835             return {"error": e, "title": _("Languages")}
836
837     @http.route('/web/session/modules', type='json', auth="user")
838     def modules(self):
839         # return all installed modules. Web client is smart enough to not load a module twice
840         return module_installed()
841
842     @http.route('/web/session/save_session_action', type='json', auth="user")
843     def save_session_action(self, the_action):
844         """
845         This method store an action object in the session object and returns an integer
846         identifying that action. The method get_session_action() can be used to get
847         back the action.
848
849         :param the_action: The action to save in the session.
850         :type the_action: anything
851         :return: A key identifying the saved action.
852         :rtype: integer
853         """
854         return request.httpsession.save_action(the_action)
855
856     @http.route('/web/session/get_session_action', type='json', auth="user")
857     def get_session_action(self, key):
858         """
859         Gets back a previously saved action. This method can return None if the action
860         was saved since too much time (this case should be handled in a smart way).
861
862         :param key: The key given by save_session_action()
863         :type key: integer
864         :return: The saved action or None.
865         :rtype: anything
866         """
867         return request.httpsession.get_action(key)
868
869     @http.route('/web/session/check', type='json', auth="user")
870     def check(self):
871         request.session.assert_valid()
872         return None
873
874     @http.route('/web/session/destroy', type='json', auth="user")
875     def destroy(self):
876         request.session.logout()
877
878     @http.route('/web/session/logout', type='http', auth="none")
879     def logout(self, redirect='/web'):
880         request.session.logout(keep_db=True)
881         return werkzeug.utils.redirect(redirect, 303)
882
883 class Menu(http.Controller):
884
885     @http.route('/web/menu/load_needaction', type='json', auth="user")
886     def load_needaction(self, menu_ids):
887         """ Loads needaction counters for specific menu ids.
888
889             :return: needaction data
890             :rtype: dict(menu_id: {'needaction_enabled': boolean, 'needaction_counter': int})
891         """
892         return request.session.model('ir.ui.menu').get_needaction_data(menu_ids, request.context)
893
894 class DataSet(http.Controller):
895
896     @http.route('/web/dataset/search_read', type='json', auth="user")
897     def search_read(self, model, fields=False, offset=0, limit=False, domain=None, sort=None):
898         return self.do_search_read(model, fields, offset, limit, domain, sort)
899     def do_search_read(self, model, fields=False, offset=0, limit=False, domain=None
900                        , sort=None):
901         """ Performs a search() followed by a read() (if needed) using the
902         provided search criteria
903
904         :param str model: the name of the model to search on
905         :param fields: a list of the fields to return in the result records
906         :type fields: [str]
907         :param int offset: from which index should the results start being returned
908         :param int limit: the maximum number of records to return
909         :param list domain: the search domain for the query
910         :param list sort: sorting directives
911         :returns: A structure (dict) with two keys: ids (all the ids matching
912                   the (domain, context) pair) and records (paginated records
913                   matching fields selection set)
914         :rtype: list
915         """
916         Model = request.session.model(model)
917
918         records = Model.search_read(domain, fields, offset or 0, limit or False, sort or False,
919                            request.context)
920         if not records:
921             return {
922                 'length': 0,
923                 'records': []
924             }
925         if limit and len(records) == limit:
926             length = Model.search_count(domain, request.context)
927         else:
928             length = len(records) + (offset or 0)
929         return {
930             'length': length,
931             'records': records
932         }
933
934     @http.route('/web/dataset/load', type='json', auth="user")
935     def load(self, model, id, fields):
936         m = request.session.model(model)
937         value = {}
938         r = m.read([id], False, request.context)
939         if r:
940             value = r[0]
941         return {'value': value}
942
943     def call_common(self, model, method, args, domain_id=None, context_id=None):
944         return self._call_kw(model, method, args, {})
945
946     def _call_kw(self, model, method, args, kwargs):
947         # Temporary implements future display_name special field for model#read()
948         if method in ('read', 'search_read') and kwargs.get('context', {}).get('future_display_name'):
949             if 'display_name' in args[1]:
950                 if method == 'read':
951                     names = dict(request.session.model(model).name_get(args[0], **kwargs))
952                 else:
953                     names = dict(request.session.model(model).name_search('', args[0], **kwargs))
954                 args[1].remove('display_name')
955                 records = getattr(request.session.model(model), method)(*args, **kwargs)
956                 for record in records:
957                     record['display_name'] = \
958                         names.get(record['id']) or "{0}#{1}".format(model, (record['id']))
959                 return records
960
961         if method.startswith('_'):
962             raise Exception("Access Denied: Underscore prefixed methods cannot be remotely called")
963
964         return getattr(request.registry.get(model), method)(request.cr, request.uid, *args, **kwargs)
965
966     @http.route('/web/dataset/call', type='json', auth="user")
967     def call(self, model, method, args, domain_id=None, context_id=None):
968         return self._call_kw(model, method, args, {})
969
970     @http.route(['/web/dataset/call_kw', '/web/dataset/call_kw/<path:path>'], type='json', auth="user")
971     def call_kw(self, model, method, args, kwargs, path=None):
972         return self._call_kw(model, method, args, kwargs)
973
974     @http.route('/web/dataset/call_button', type='json', auth="user")
975     def call_button(self, model, method, args, domain_id=None, context_id=None):
976         action = self._call_kw(model, method, args, {})
977         if isinstance(action, dict) and action.get('type') != '':
978             return clean_action(action)
979         return False
980
981     @http.route('/web/dataset/exec_workflow', type='json', auth="user")
982     def exec_workflow(self, model, id, signal):
983         return request.session.exec_workflow(model, id, signal)
984
985     @http.route('/web/dataset/resequence', type='json', auth="user")
986     def resequence(self, model, ids, field='sequence', offset=0):
987         """ Re-sequences a number of records in the model, by their ids
988
989         The re-sequencing starts at the first model of ``ids``, the sequence
990         number is incremented by one after each record and starts at ``offset``
991
992         :param ids: identifiers of the records to resequence, in the new sequence order
993         :type ids: list(id)
994         :param str field: field used for sequence specification, defaults to
995                           "sequence"
996         :param int offset: sequence number for first record in ``ids``, allows
997                            starting the resequencing from an arbitrary number,
998                            defaults to ``0``
999         """
1000         m = request.session.model(model)
1001         if not m.fields_get([field]):
1002             return False
1003         # python 2.6 has no start parameter
1004         for i, id in enumerate(ids):
1005             m.write(id, { field: i + offset })
1006         return True
1007
1008 class View(http.Controller):
1009
1010     @http.route('/web/view/add_custom', type='json', auth="user")
1011     def add_custom(self, view_id, arch):
1012         CustomView = request.session.model('ir.ui.view.custom')
1013         CustomView.create({
1014             'user_id': request.session.uid,
1015             'ref_id': view_id,
1016             'arch': arch
1017         }, request.context)
1018         return {'result': True}
1019
1020 class TreeView(View):
1021
1022     @http.route('/web/treeview/action', type='json', auth="user")
1023     def action(self, model, id):
1024         return load_actions_from_ir_values(
1025             'action', 'tree_but_open',[(model, id)],
1026             False)
1027
1028 class Binary(http.Controller):
1029
1030     @http.route('/web/binary/image', type='http', auth="public")
1031     def image(self, model, id, field, **kw):
1032         last_update = '__last_update'
1033         Model = request.registry[model]
1034         cr, uid, context = request.cr, request.uid, request.context
1035         headers = [('Content-Type', 'image/png')]
1036         etag = request.httprequest.headers.get('If-None-Match')
1037         hashed_session = hashlib.md5(request.session_id).hexdigest()
1038         retag = hashed_session
1039         id = None if not id else simplejson.loads(id)
1040         if type(id) is list:
1041             id = id[0] # m2o
1042         try:
1043             if etag:
1044                 if not id and hashed_session == etag:
1045                     return werkzeug.wrappers.Response(status=304)
1046                 else:
1047                     date = Model.read(cr, uid, [id], [last_update], context)[0].get(last_update)
1048                     if hashlib.md5(date).hexdigest() == etag:
1049                         return werkzeug.wrappers.Response(status=304)
1050
1051             if not id:
1052                 res = Model.default_get(cr, uid, [field], context).get(field)
1053                 image_base64 = res
1054             else:
1055                 res = Model.read(cr, uid, [id], [last_update, field], context)[0]
1056                 retag = hashlib.md5(res.get(last_update)).hexdigest()
1057                 image_base64 = res.get(field)
1058
1059             if kw.get('resize'):
1060                 resize = kw.get('resize').split(',')
1061                 if len(resize) == 2 and int(resize[0]) and int(resize[1]):
1062                     width = int(resize[0])
1063                     height = int(resize[1])
1064                     # resize maximum 500*500
1065                     if width > 500: width = 500
1066                     if height > 500: height = 500
1067                     image_base64 = openerp.tools.image_resize_image(base64_source=image_base64, size=(width, height), encoding='base64', filetype='PNG')
1068
1069             image_data = base64.b64decode(image_base64)
1070
1071         except Exception:
1072             image_data = self.placeholder()
1073         headers.append(('ETag', retag))
1074         headers.append(('Content-Length', len(image_data)))
1075         try:
1076             ncache = int(kw.get('cache'))
1077             headers.append(('Cache-Control', 'no-cache' if ncache == 0 else 'max-age=%s' % (ncache)))
1078         except:
1079             pass
1080         return request.make_response(image_data, headers)
1081
1082     def placeholder(self, image='placeholder.png'):
1083         addons_path = http.addons_manifest['web']['addons_path']
1084         return open(os.path.join(addons_path, 'web', 'static', 'src', 'img', image), 'rb').read()
1085
1086     @http.route('/web/binary/saveas', type='http', auth="public")
1087     @serialize_exception
1088     def saveas(self, model, field, id=None, filename_field=None, **kw):
1089         """ Download link for files stored as binary fields.
1090
1091         If the ``id`` parameter is omitted, fetches the default value for the
1092         binary field (via ``default_get``), otherwise fetches the field for
1093         that precise record.
1094
1095         :param str model: name of the model to fetch the binary from
1096         :param str field: binary field
1097         :param str id: id of the record from which to fetch the binary
1098         :param str filename_field: field holding the file's name, if any
1099         :returns: :class:`werkzeug.wrappers.Response`
1100         """
1101         Model = request.registry[model]
1102         cr, uid, context = request.cr, request.uid, request.context
1103         fields = [field]
1104         if filename_field:
1105             fields.append(filename_field)
1106         if id:
1107             res = Model.read(cr, uid, [int(id)], fields, context)[0]
1108         else:
1109             res = Model.default_get(cr, uid, fields, context)
1110         filecontent = base64.b64decode(res.get(field, ''))
1111         if not filecontent:
1112             return request.not_found()
1113         else:
1114             filename = '%s_%s' % (model.replace('.', '_'), id)
1115             if filename_field:
1116                 filename = res.get(filename_field, '') or filename
1117             return request.make_response(filecontent,
1118                 [('Content-Type', 'application/octet-stream'),
1119                  ('Content-Disposition', content_disposition(filename))])
1120
1121     @http.route('/web/binary/saveas_ajax', type='http', auth="public")
1122     @serialize_exception
1123     def saveas_ajax(self, data, token):
1124         jdata = simplejson.loads(data)
1125         model = jdata['model']
1126         field = jdata['field']
1127         data = jdata['data']
1128         id = jdata.get('id', None)
1129         filename_field = jdata.get('filename_field', None)
1130         context = jdata.get('context', {})
1131
1132         Model = request.session.model(model)
1133         fields = [field]
1134         if filename_field:
1135             fields.append(filename_field)
1136         if data:
1137             res = { field: data }
1138         elif id:
1139             res = Model.read([int(id)], fields, context)[0]
1140         else:
1141             res = Model.default_get(fields, context)
1142         filecontent = base64.b64decode(res.get(field, ''))
1143         if not filecontent:
1144             raise ValueError(_("No content found for field '%s' on '%s:%s'") %
1145                 (field, model, id))
1146         else:
1147             filename = '%s_%s' % (model.replace('.', '_'), id)
1148             if filename_field:
1149                 filename = res.get(filename_field, '') or filename
1150             return request.make_response(filecontent,
1151                 headers=[('Content-Type', 'application/octet-stream'),
1152                         ('Content-Disposition', content_disposition(filename))],
1153                 cookies={'fileToken': token})
1154
1155     @http.route('/web/binary/upload', type='http', auth="user")
1156     @serialize_exception
1157     def upload(self, callback, ufile):
1158         # TODO: might be useful to have a configuration flag for max-length file uploads
1159         out = """<script language="javascript" type="text/javascript">
1160                     var win = window.top.window;
1161                     win.jQuery(win).trigger(%s, %s);
1162                 </script>"""
1163         try:
1164             data = ufile.read()
1165             args = [len(data), ufile.filename,
1166                     ufile.content_type, base64.b64encode(data)]
1167         except Exception, e:
1168             args = [False, e.message]
1169         return out % (simplejson.dumps(callback), simplejson.dumps(args))
1170
1171     @http.route('/web/binary/upload_attachment', type='http', auth="user")
1172     @serialize_exception
1173     def upload_attachment(self, callback, model, id, ufile):
1174         Model = request.session.model('ir.attachment')
1175         out = """<script language="javascript" type="text/javascript">
1176                     var win = window.top.window;
1177                     win.jQuery(win).trigger(%s, %s);
1178                 </script>"""
1179         try:
1180             attachment_id = Model.create({
1181                 'name': ufile.filename,
1182                 'datas': base64.encodestring(ufile.read()),
1183                 'datas_fname': ufile.filename,
1184                 'res_model': model,
1185                 'res_id': int(id)
1186             }, request.context)
1187             args = {
1188                 'filename': ufile.filename,
1189                 'id':  attachment_id
1190             }
1191         except Exception:
1192             args = {'error': "Something horrible happened"}
1193         return out % (simplejson.dumps(callback), simplejson.dumps(args))
1194
1195     @http.route([
1196         '/web/binary/company_logo',
1197         '/logo',
1198         '/logo.png',
1199     ], type='http', auth="none", cors="*")
1200     def company_logo(self, dbname=None, **kw):
1201         imgname = 'logo.png'
1202         placeholder = functools.partial(get_module_resource, 'web', 'static', 'src', 'img')
1203         uid = None
1204         if request.session.db:
1205             dbname = request.session.db
1206             uid = request.session.uid
1207         elif dbname is None:
1208             dbname = db_monodb()
1209
1210         if not uid:
1211             uid = openerp.SUPERUSER_ID
1212
1213         if not dbname:
1214             response = http.send_file(placeholder(imgname))
1215         else:
1216             try:
1217                 # create an empty registry
1218                 registry = openerp.modules.registry.Registry(dbname)
1219                 with registry.cursor() as cr:
1220                     cr.execute("""SELECT c.logo_web, c.write_date
1221                                     FROM res_users u
1222                                LEFT JOIN res_company c
1223                                       ON c.id = u.company_id
1224                                    WHERE u.id = %s
1225                                """, (uid,))
1226                     row = cr.fetchone()
1227                     if row and row[0]:
1228                         image_data = StringIO(str(row[0]).decode('base64'))
1229                         response = http.send_file(image_data, filename=imgname, mtime=row[1])
1230                     else:
1231                         response = http.send_file(placeholder('nologo.png'))
1232             except Exception:
1233                 response = http.send_file(placeholder(imgname))
1234
1235         return response
1236
1237 class Action(http.Controller):
1238
1239     @http.route('/web/action/load', type='json', auth="user")
1240     def load(self, action_id, do_not_eval=False, additional_context=None):
1241         Actions = request.session.model('ir.actions.actions')
1242         value = False
1243         try:
1244             action_id = int(action_id)
1245         except ValueError:
1246             try:
1247                 module, xmlid = action_id.split('.', 1)
1248                 model, action_id = request.session.model('ir.model.data').get_object_reference(module, xmlid)
1249                 assert model.startswith('ir.actions.')
1250             except Exception:
1251                 action_id = 0   # force failed read
1252
1253         base_action = Actions.read([action_id], ['type'], request.context)
1254         if base_action:
1255             ctx = request.context
1256             action_type = base_action[0]['type']
1257             if action_type == 'ir.actions.report.xml':
1258                 ctx.update({'bin_size': True})
1259             if additional_context:
1260                 ctx.update(additional_context)
1261             action = request.session.model(action_type).read([action_id], False, ctx)
1262             if action:
1263                 value = clean_action(action[0])
1264         return value
1265
1266     @http.route('/web/action/run', type='json', auth="user")
1267     def run(self, action_id):
1268         return_action = request.session.model('ir.actions.server').run(
1269             [action_id], request.context)
1270         if return_action:
1271             return clean_action(return_action)
1272         else:
1273             return False
1274
1275 class Export(http.Controller):
1276
1277     @http.route('/web/export/formats', type='json', auth="user")
1278     def formats(self):
1279         """ Returns all valid export formats
1280
1281         :returns: for each export format, a pair of identifier and printable name
1282         :rtype: [(str, str)]
1283         """
1284         return [
1285             {'tag': 'csv', 'label': 'CSV'},
1286             {'tag': 'xls', 'label': 'Excel', 'error': None if xlwt else "XLWT required"},
1287         ]
1288
1289     def fields_get(self, model):
1290         Model = request.session.model(model)
1291         fields = Model.fields_get(False, request.context)
1292         return fields
1293
1294     @http.route('/web/export/get_fields', type='json', auth="user")
1295     def get_fields(self, model, prefix='', parent_name= '',
1296                    import_compat=True, parent_field_type=None,
1297                    exclude=None):
1298
1299         if import_compat and parent_field_type == "many2one":
1300             fields = {}
1301         else:
1302             fields = self.fields_get(model)
1303
1304         if import_compat:
1305             fields.pop('id', None)
1306         else:
1307             fields['.id'] = fields.pop('id', {'string': 'ID'})
1308
1309         fields_sequence = sorted(fields.iteritems(),
1310             key=lambda field: openerp.tools.ustr(field[1].get('string', '')))
1311
1312         records = []
1313         for field_name, field in fields_sequence:
1314             if import_compat:
1315                 if exclude and field_name in exclude:
1316                     continue
1317                 if field.get('readonly'):
1318                     # If none of the field's states unsets readonly, skip the field
1319                     if all(dict(attrs).get('readonly', True)
1320                            for attrs in field.get('states', {}).values()):
1321                         continue
1322             if not field.get('exportable', True):
1323                 continue
1324
1325             id = prefix + (prefix and '/'or '') + field_name
1326             name = parent_name + (parent_name and '/' or '') + field['string']
1327             record = {'id': id, 'string': name,
1328                       'value': id, 'children': False,
1329                       'field_type': field.get('type'),
1330                       'required': field.get('required'),
1331                       'relation_field': field.get('relation_field')}
1332             records.append(record)
1333
1334             if len(name.split('/')) < 3 and 'relation' in field:
1335                 ref = field.pop('relation')
1336                 record['value'] += '/id'
1337                 record['params'] = {'model': ref, 'prefix': id, 'name': name}
1338
1339                 if not import_compat or field['type'] == 'one2many':
1340                     # m2m field in import_compat is childless
1341                     record['children'] = True
1342
1343         return records
1344
1345     @http.route('/web/export/namelist', type='json', auth="user")
1346     def namelist(self, model, export_id):
1347         # TODO: namelist really has no reason to be in Python (although itertools.groupby helps)
1348         export = request.session.model("ir.exports").read([export_id])[0]
1349         export_fields_list = request.session.model("ir.exports.line").read(
1350             export['export_fields'])
1351
1352         fields_data = self.fields_info(
1353             model, map(operator.itemgetter('name'), export_fields_list))
1354
1355         return [
1356             {'name': field['name'], 'label': fields_data[field['name']]}
1357             for field in export_fields_list
1358         ]
1359
1360     def fields_info(self, model, export_fields):
1361         info = {}
1362         fields = self.fields_get(model)
1363         if ".id" in export_fields:
1364             fields['.id'] = fields.pop('id', {'string': 'ID'})
1365
1366         # To make fields retrieval more efficient, fetch all sub-fields of a
1367         # given field at the same time. Because the order in the export list is
1368         # arbitrary, this requires ordering all sub-fields of a given field
1369         # together so they can be fetched at the same time
1370         #
1371         # Works the following way:
1372         # * sort the list of fields to export, the default sorting order will
1373         #   put the field itself (if present, for xmlid) and all of its
1374         #   sub-fields right after it
1375         # * then, group on: the first field of the path (which is the same for
1376         #   a field and for its subfields and the length of splitting on the
1377         #   first '/', which basically means grouping the field on one side and
1378         #   all of the subfields on the other. This way, we have the field (for
1379         #   the xmlid) with length 1, and all of the subfields with the same
1380         #   base but a length "flag" of 2
1381         # * if we have a normal field (length 1), just add it to the info
1382         #   mapping (with its string) as-is
1383         # * otherwise, recursively call fields_info via graft_subfields.
1384         #   all graft_subfields does is take the result of fields_info (on the
1385         #   field's model) and prepend the current base (current field), which
1386         #   rebuilds the whole sub-tree for the field
1387         #
1388         # result: because we're not fetching the fields_get for half the
1389         # database models, fetching a namelist with a dozen fields (including
1390         # relational data) falls from ~6s to ~300ms (on the leads model).
1391         # export lists with no sub-fields (e.g. import_compatible lists with
1392         # no o2m) are even more efficient (from the same 6s to ~170ms, as
1393         # there's a single fields_get to execute)
1394         for (base, length), subfields in itertools.groupby(
1395                 sorted(export_fields),
1396                 lambda field: (field.split('/', 1)[0], len(field.split('/', 1)))):
1397             subfields = list(subfields)
1398             if length == 2:
1399                 # subfields is a seq of $base/*rest, and not loaded yet
1400                 info.update(self.graft_subfields(
1401                     fields[base]['relation'], base, fields[base]['string'],
1402                     subfields
1403                 ))
1404             elif base in fields:
1405                 info[base] = fields[base]['string']
1406
1407         return info
1408
1409     def graft_subfields(self, model, prefix, prefix_string, fields):
1410         export_fields = [field.split('/', 1)[1] for field in fields]
1411         return (
1412             (prefix + '/' + k, prefix_string + '/' + v)
1413             for k, v in self.fields_info(model, export_fields).iteritems())
1414
1415 class ExportFormat(object):
1416     raw_data = False
1417
1418     @property
1419     def content_type(self):
1420         """ Provides the format's content type """
1421         raise NotImplementedError()
1422
1423     def filename(self, base):
1424         """ Creates a valid filename for the format (with extension) from the
1425          provided base name (exension-less)
1426         """
1427         raise NotImplementedError()
1428
1429     def from_data(self, fields, rows):
1430         """ Conversion method from OpenERP's export data to whatever the
1431         current export class outputs
1432
1433         :params list fields: a list of fields to export
1434         :params list rows: a list of records to export
1435         :returns:
1436         :rtype: bytes
1437         """
1438         raise NotImplementedError()
1439
1440     def base(self, data, token):
1441         params = simplejson.loads(data)
1442         model, fields, ids, domain, import_compat = \
1443             operator.itemgetter('model', 'fields', 'ids', 'domain',
1444                                 'import_compat')(
1445                 params)
1446
1447         Model = request.session.model(model)
1448         context = dict(request.context or {}, **params.get('context', {}))
1449         ids = ids or Model.search(domain, 0, False, False, context)
1450
1451         field_names = map(operator.itemgetter('name'), fields)
1452         import_data = Model.export_data(ids, field_names, self.raw_data, context=context).get('datas',[])
1453
1454         if import_compat:
1455             columns_headers = field_names
1456         else:
1457             columns_headers = [val['label'].strip() for val in fields]
1458
1459
1460         return request.make_response(self.from_data(columns_headers, import_data),
1461             headers=[('Content-Disposition',
1462                             content_disposition(self.filename(model))),
1463                      ('Content-Type', self.content_type)],
1464             cookies={'fileToken': token})
1465
1466 class CSVExport(ExportFormat, http.Controller):
1467
1468     @http.route('/web/export/csv', type='http', auth="user")
1469     @serialize_exception
1470     def index(self, data, token):
1471         return self.base(data, token)
1472
1473     @property
1474     def content_type(self):
1475         return 'text/csv;charset=utf8'
1476
1477     def filename(self, base):
1478         return base + '.csv'
1479
1480     def from_data(self, fields, rows):
1481         fp = StringIO()
1482         writer = csv.writer(fp, quoting=csv.QUOTE_ALL)
1483
1484         writer.writerow([name.encode('utf-8') for name in fields])
1485
1486         for data in rows:
1487             row = []
1488             for d in data:
1489                 if isinstance(d, basestring):
1490                     d = d.replace('\n',' ').replace('\t',' ')
1491                     try:
1492                         d = d.encode('utf-8')
1493                     except UnicodeError:
1494                         pass
1495                 if d is False: d = None
1496                 row.append(d)
1497             writer.writerow(row)
1498
1499         fp.seek(0)
1500         data = fp.read()
1501         fp.close()
1502         return data
1503
1504 class ExcelExport(ExportFormat, http.Controller):
1505     # Excel needs raw data to correctly handle numbers and date values
1506     raw_data = True
1507
1508     @http.route('/web/export/xls', type='http', auth="user")
1509     @serialize_exception
1510     def index(self, data, token):
1511         return self.base(data, token)
1512
1513     @property
1514     def content_type(self):
1515         return 'application/vnd.ms-excel'
1516
1517     def filename(self, base):
1518         return base + '.xls'
1519
1520     def from_data(self, fields, rows):
1521         workbook = xlwt.Workbook()
1522         worksheet = workbook.add_sheet('Sheet 1')
1523
1524         for i, fieldname in enumerate(fields):
1525             worksheet.write(0, i, fieldname)
1526             worksheet.col(i).width = 8000 # around 220 pixels
1527
1528         base_style = xlwt.easyxf('align: wrap yes')
1529         date_style = xlwt.easyxf('align: wrap yes', num_format_str='YYYY-MM-DD')
1530         datetime_style = xlwt.easyxf('align: wrap yes', num_format_str='YYYY-MM-DD HH:mm:SS')
1531
1532         for row_index, row in enumerate(rows):
1533             for cell_index, cell_value in enumerate(row):
1534                 cell_style = base_style
1535                 if isinstance(cell_value, basestring):
1536                     cell_value = re.sub("\r", " ", cell_value)
1537                 elif isinstance(cell_value, datetime.datetime):
1538                     cell_style = datetime_style
1539                 elif isinstance(cell_value, datetime.date):
1540                     cell_style = date_style
1541                 worksheet.write(row_index + 1, cell_index, cell_value, cell_style)
1542
1543         fp = StringIO()
1544         workbook.save(fp)
1545         fp.seek(0)
1546         data = fp.read()
1547         fp.close()
1548         return data
1549
1550 class Reports(http.Controller):
1551     POLLING_DELAY = 0.25
1552     TYPES_MAPPING = {
1553         'doc': 'application/vnd.ms-word',
1554         'html': 'text/html',
1555         'odt': 'application/vnd.oasis.opendocument.text',
1556         'pdf': 'application/pdf',
1557         'sxw': 'application/vnd.sun.xml.writer',
1558         'xls': 'application/vnd.ms-excel',
1559     }
1560
1561     @http.route('/web/report', type='http', auth="user")
1562     @serialize_exception
1563     def index(self, action, token):
1564         action = simplejson.loads(action)
1565         report_srv = request.session.proxy("report")
1566         context = dict(request.context)
1567         context.update(action["context"])
1568
1569         report_data = {}
1570         report_ids = context.get("active_ids", None)
1571         if 'report_type' in action:
1572             report_data['report_type'] = action['report_type']
1573         if 'datas' in action:
1574             if 'ids' in action['datas']:
1575                 report_ids = action['datas'].pop('ids')
1576             report_data.update(action['datas'])
1577
1578         report_id = report_srv.report(
1579             request.session.db, request.session.uid, request.session.password,
1580             action["report_name"], report_ids,
1581             report_data, context)
1582
1583         report_struct = None
1584         while True:
1585             report_struct = report_srv.report_get(
1586                 request.session.db, request.session.uid, request.session.password, report_id)
1587             if report_struct["state"]:
1588                 break
1589
1590             time.sleep(self.POLLING_DELAY)
1591
1592         report = base64.b64decode(report_struct['result'])
1593         if report_struct.get('code') == 'zlib':
1594             report = zlib.decompress(report)
1595         report_mimetype = self.TYPES_MAPPING.get(
1596             report_struct['format'], 'octet-stream')
1597         file_name = action.get('name', 'report')
1598         if 'name' not in action:
1599             reports = request.session.model('ir.actions.report.xml')
1600             res_id = reports.search([('report_name', '=', action['report_name']),],
1601                                     0, False, False, context)
1602             if len(res_id) > 0:
1603                 file_name = reports.read(res_id[0], ['name'], context)['name']
1604             else:
1605                 file_name = action['report_name']
1606         file_name = '%s.%s' % (file_name, report_struct['format'])
1607         headers=[
1608              ('Content-Disposition', content_disposition(file_name)),
1609              ('Content-Type', report_mimetype),
1610              ('Content-Length', len(report))]
1611         if action.get('pdf_viewer'):
1612             del headers[0]
1613         return request.make_response(report,
1614              headers=headers,
1615              cookies={'fileToken': token})
1616
1617 class Apps(http.Controller):
1618     @http.route('/apps/<app>', auth='user')
1619     def get_app_url(self, req, app):
1620         act_window_obj = request.session.model('ir.actions.act_window')
1621         ir_model_data = request.session.model('ir.model.data')
1622         try:
1623             action_id = ir_model_data.get_object_reference('base', 'open_module_tree')[1]
1624             action = act_window_obj.read(action_id, ['name', 'type', 'res_model', 'view_mode', 'view_type', 'context', 'views', 'domain'])
1625             action['target'] = 'current'
1626         except ValueError:
1627             action = False
1628         try:
1629             app_id = ir_model_data.get_object_reference('base', 'module_%s' % app)[1]
1630         except ValueError:
1631             app_id = False
1632
1633         if action and app_id:
1634             action['res_id'] = app_id
1635             action['view_mode'] = 'form'
1636             action['views'] = [(False, u'form')]
1637
1638         sakey = Session().save_session_action(action)
1639         debug = '?debug' if req.debug else ''
1640         return werkzeug.utils.redirect('/web{0}#sa={1}'.format(debug, sakey))
1641
1642
1643
1644 # vim:expandtab:tabstop=4:softtabstop=4:shiftwidth=4: