[MERGE] forward port of branch 8.0 up to 83bd9ee
[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     @http.route('/web/view/undo_custom', type='json', auth="user")
1020     def undo_custom(self, view_id, reset=False):
1021         CustomView = request.session.model('ir.ui.view.custom')
1022         vcustom = CustomView.search([('user_id', '=', request.session.uid), ('ref_id' ,'=', view_id)],
1023                                     0, False, False, request.context)
1024         if vcustom:
1025             if reset:
1026                 CustomView.unlink(vcustom, request.context)
1027             else:
1028                 CustomView.unlink([vcustom[0]], request.context)
1029             return {'result': True}
1030         return {'result': False}
1031
1032 class TreeView(View):
1033
1034     @http.route('/web/treeview/action', type='json', auth="user")
1035     def action(self, model, id):
1036         return load_actions_from_ir_values(
1037             'action', 'tree_but_open',[(model, id)],
1038             False)
1039
1040 class Binary(http.Controller):
1041
1042     @http.route('/web/binary/image', type='http', auth="public")
1043     def image(self, model, id, field, **kw):
1044         last_update = '__last_update'
1045         Model = request.registry[model]
1046         cr, uid, context = request.cr, request.uid, request.context
1047         headers = [('Content-Type', 'image/png')]
1048         etag = request.httprequest.headers.get('If-None-Match')
1049         hashed_session = hashlib.md5(request.session_id).hexdigest()
1050         retag = hashed_session
1051         id = None if not id else simplejson.loads(id)
1052         if type(id) is list:
1053             id = id[0] # m2o
1054         try:
1055             if etag:
1056                 if not id and hashed_session == etag:
1057                     return werkzeug.wrappers.Response(status=304)
1058                 else:
1059                     date = Model.read(cr, uid, [id], [last_update], context)[0].get(last_update)
1060                     if hashlib.md5(date).hexdigest() == etag:
1061                         return werkzeug.wrappers.Response(status=304)
1062
1063             if not id:
1064                 res = Model.default_get(cr, uid, [field], context).get(field)
1065                 image_base64 = res
1066             else:
1067                 res = Model.read(cr, uid, [id], [last_update, field], context)[0]
1068                 retag = hashlib.md5(res.get(last_update)).hexdigest()
1069                 image_base64 = res.get(field)
1070
1071             if kw.get('resize'):
1072                 resize = kw.get('resize').split(',')
1073                 if len(resize) == 2 and int(resize[0]) and int(resize[1]):
1074                     width = int(resize[0])
1075                     height = int(resize[1])
1076                     # resize maximum 500*500
1077                     if width > 500: width = 500
1078                     if height > 500: height = 500
1079                     image_base64 = openerp.tools.image_resize_image(base64_source=image_base64, size=(width, height), encoding='base64', filetype='PNG')
1080
1081             image_data = base64.b64decode(image_base64)
1082
1083         except Exception:
1084             image_data = self.placeholder()
1085         headers.append(('ETag', retag))
1086         headers.append(('Content-Length', len(image_data)))
1087         try:
1088             ncache = int(kw.get('cache'))
1089             headers.append(('Cache-Control', 'no-cache' if ncache == 0 else 'max-age=%s' % (ncache)))
1090         except:
1091             pass
1092         return request.make_response(image_data, headers)
1093
1094     def placeholder(self, image='placeholder.png'):
1095         addons_path = http.addons_manifest['web']['addons_path']
1096         return open(os.path.join(addons_path, 'web', 'static', 'src', 'img', image), 'rb').read()
1097
1098     @http.route('/web/binary/saveas', type='http', auth="public")
1099     @serialize_exception
1100     def saveas(self, model, field, id=None, filename_field=None, **kw):
1101         """ Download link for files stored as binary fields.
1102
1103         If the ``id`` parameter is omitted, fetches the default value for the
1104         binary field (via ``default_get``), otherwise fetches the field for
1105         that precise record.
1106
1107         :param str model: name of the model to fetch the binary from
1108         :param str field: binary field
1109         :param str id: id of the record from which to fetch the binary
1110         :param str filename_field: field holding the file's name, if any
1111         :returns: :class:`werkzeug.wrappers.Response`
1112         """
1113         Model = request.registry[model]
1114         cr, uid, context = request.cr, request.uid, request.context
1115         fields = [field]
1116         if filename_field:
1117             fields.append(filename_field)
1118         if id:
1119             res = Model.read(cr, uid, [int(id)], fields, context)[0]
1120         else:
1121             res = Model.default_get(cr, uid, fields, context)
1122         filecontent = base64.b64decode(res.get(field, ''))
1123         if not filecontent:
1124             return request.not_found()
1125         else:
1126             filename = '%s_%s' % (model.replace('.', '_'), id)
1127             if filename_field:
1128                 filename = res.get(filename_field, '') or filename
1129             return request.make_response(filecontent,
1130                 [('Content-Type', 'application/octet-stream'),
1131                  ('Content-Disposition', content_disposition(filename))])
1132
1133     @http.route('/web/binary/saveas_ajax', type='http', auth="public")
1134     @serialize_exception
1135     def saveas_ajax(self, data, token):
1136         jdata = simplejson.loads(data)
1137         model = jdata['model']
1138         field = jdata['field']
1139         data = jdata['data']
1140         id = jdata.get('id', None)
1141         filename_field = jdata.get('filename_field', None)
1142         context = jdata.get('context', {})
1143
1144         Model = request.session.model(model)
1145         fields = [field]
1146         if filename_field:
1147             fields.append(filename_field)
1148         if data:
1149             res = { field: data }
1150         elif id:
1151             res = Model.read([int(id)], fields, context)[0]
1152         else:
1153             res = Model.default_get(fields, context)
1154         filecontent = base64.b64decode(res.get(field, ''))
1155         if not filecontent:
1156             raise ValueError(_("No content found for field '%s' on '%s:%s'") %
1157                 (field, model, id))
1158         else:
1159             filename = '%s_%s' % (model.replace('.', '_'), id)
1160             if filename_field:
1161                 filename = res.get(filename_field, '') or filename
1162             return request.make_response(filecontent,
1163                 headers=[('Content-Type', 'application/octet-stream'),
1164                         ('Content-Disposition', content_disposition(filename))],
1165                 cookies={'fileToken': token})
1166
1167     @http.route('/web/binary/upload', type='http', auth="user")
1168     @serialize_exception
1169     def upload(self, callback, ufile):
1170         # TODO: might be useful to have a configuration flag for max-length file uploads
1171         out = """<script language="javascript" type="text/javascript">
1172                     var win = window.top.window;
1173                     win.jQuery(win).trigger(%s, %s);
1174                 </script>"""
1175         try:
1176             data = ufile.read()
1177             args = [len(data), ufile.filename,
1178                     ufile.content_type, base64.b64encode(data)]
1179         except Exception, e:
1180             args = [False, e.message]
1181         return out % (simplejson.dumps(callback), simplejson.dumps(args))
1182
1183     @http.route('/web/binary/upload_attachment', type='http', auth="user")
1184     @serialize_exception
1185     def upload_attachment(self, callback, model, id, ufile):
1186         Model = request.session.model('ir.attachment')
1187         out = """<script language="javascript" type="text/javascript">
1188                     var win = window.top.window;
1189                     win.jQuery(win).trigger(%s, %s);
1190                 </script>"""
1191         try:
1192             attachment_id = Model.create({
1193                 'name': ufile.filename,
1194                 'datas': base64.encodestring(ufile.read()),
1195                 'datas_fname': ufile.filename,
1196                 'res_model': model,
1197                 'res_id': int(id)
1198             }, request.context)
1199             args = {
1200                 'filename': ufile.filename,
1201                 'id':  attachment_id
1202             }
1203         except Exception:
1204             args = {'error': "Something horrible happened"}
1205         return out % (simplejson.dumps(callback), simplejson.dumps(args))
1206
1207     @http.route([
1208         '/web/binary/company_logo',
1209         '/logo',
1210         '/logo.png',
1211     ], type='http', auth="none", cors="*")
1212     def company_logo(self, dbname=None, **kw):
1213         imgname = 'logo.png'
1214         placeholder = functools.partial(get_module_resource, 'web', 'static', 'src', 'img')
1215         uid = None
1216         if request.session.db:
1217             dbname = request.session.db
1218             uid = request.session.uid
1219         elif dbname is None:
1220             dbname = db_monodb()
1221
1222         if not uid:
1223             uid = openerp.SUPERUSER_ID
1224
1225         if not dbname:
1226             response = http.send_file(placeholder(imgname))
1227         else:
1228             try:
1229                 # create an empty registry
1230                 registry = openerp.modules.registry.Registry(dbname)
1231                 with registry.cursor() as cr:
1232                     cr.execute("""SELECT c.logo_web, c.write_date
1233                                     FROM res_users u
1234                                LEFT JOIN res_company c
1235                                       ON c.id = u.company_id
1236                                    WHERE u.id = %s
1237                                """, (uid,))
1238                     row = cr.fetchone()
1239                     if row and row[0]:
1240                         image_data = StringIO(str(row[0]).decode('base64'))
1241                         response = http.send_file(image_data, filename=imgname, mtime=row[1])
1242                     else:
1243                         response = http.send_file(placeholder('nologo.png'))
1244             except Exception:
1245                 response = http.send_file(placeholder(imgname))
1246
1247         return response
1248
1249 class Action(http.Controller):
1250
1251     @http.route('/web/action/load', type='json', auth="user")
1252     def load(self, action_id, do_not_eval=False, additional_context=None):
1253         Actions = request.session.model('ir.actions.actions')
1254         value = False
1255         try:
1256             action_id = int(action_id)
1257         except ValueError:
1258             try:
1259                 module, xmlid = action_id.split('.', 1)
1260                 model, action_id = request.session.model('ir.model.data').get_object_reference(module, xmlid)
1261                 assert model.startswith('ir.actions.')
1262             except Exception:
1263                 action_id = 0   # force failed read
1264
1265         base_action = Actions.read([action_id], ['type'], request.context)
1266         if base_action:
1267             ctx = request.context
1268             action_type = base_action[0]['type']
1269             if action_type == 'ir.actions.report.xml':
1270                 ctx.update({'bin_size': True})
1271             if additional_context:
1272                 ctx.update(additional_context)
1273             action = request.session.model(action_type).read([action_id], False, ctx)
1274             if action:
1275                 value = clean_action(action[0])
1276         return value
1277
1278     @http.route('/web/action/run', type='json', auth="user")
1279     def run(self, action_id):
1280         return_action = request.session.model('ir.actions.server').run(
1281             [action_id], request.context)
1282         if return_action:
1283             return clean_action(return_action)
1284         else:
1285             return False
1286
1287 class Export(http.Controller):
1288
1289     @http.route('/web/export/formats', type='json', auth="user")
1290     def formats(self):
1291         """ Returns all valid export formats
1292
1293         :returns: for each export format, a pair of identifier and printable name
1294         :rtype: [(str, str)]
1295         """
1296         return [
1297             {'tag': 'csv', 'label': 'CSV'},
1298             {'tag': 'xls', 'label': 'Excel', 'error': None if xlwt else "XLWT required"},
1299         ]
1300
1301     def fields_get(self, model):
1302         Model = request.session.model(model)
1303         fields = Model.fields_get(False, request.context)
1304         return fields
1305
1306     @http.route('/web/export/get_fields', type='json', auth="user")
1307     def get_fields(self, model, prefix='', parent_name= '',
1308                    import_compat=True, parent_field_type=None,
1309                    exclude=None):
1310
1311         if import_compat and parent_field_type == "many2one":
1312             fields = {}
1313         else:
1314             fields = self.fields_get(model)
1315
1316         if import_compat:
1317             fields.pop('id', None)
1318         else:
1319             fields['.id'] = fields.pop('id', {'string': 'ID'})
1320
1321         fields_sequence = sorted(fields.iteritems(),
1322             key=lambda field: openerp.tools.ustr(field[1].get('string', '')))
1323
1324         records = []
1325         for field_name, field in fields_sequence:
1326             if import_compat:
1327                 if exclude and field_name in exclude:
1328                     continue
1329                 if field.get('readonly'):
1330                     # If none of the field's states unsets readonly, skip the field
1331                     if all(dict(attrs).get('readonly', True)
1332                            for attrs in field.get('states', {}).values()):
1333                         continue
1334             if not field.get('exportable', True):
1335                 continue
1336
1337             id = prefix + (prefix and '/'or '') + field_name
1338             name = parent_name + (parent_name and '/' or '') + field['string']
1339             record = {'id': id, 'string': name,
1340                       'value': id, 'children': False,
1341                       'field_type': field.get('type'),
1342                       'required': field.get('required'),
1343                       'relation_field': field.get('relation_field')}
1344             records.append(record)
1345
1346             if len(name.split('/')) < 3 and 'relation' in field:
1347                 ref = field.pop('relation')
1348                 record['value'] += '/id'
1349                 record['params'] = {'model': ref, 'prefix': id, 'name': name}
1350
1351                 if not import_compat or field['type'] == 'one2many':
1352                     # m2m field in import_compat is childless
1353                     record['children'] = True
1354
1355         return records
1356
1357     @http.route('/web/export/namelist', type='json', auth="user")
1358     def namelist(self, model, export_id):
1359         # TODO: namelist really has no reason to be in Python (although itertools.groupby helps)
1360         export = request.session.model("ir.exports").read([export_id])[0]
1361         export_fields_list = request.session.model("ir.exports.line").read(
1362             export['export_fields'])
1363
1364         fields_data = self.fields_info(
1365             model, map(operator.itemgetter('name'), export_fields_list))
1366
1367         return [
1368             {'name': field['name'], 'label': fields_data[field['name']]}
1369             for field in export_fields_list
1370         ]
1371
1372     def fields_info(self, model, export_fields):
1373         info = {}
1374         fields = self.fields_get(model)
1375         if ".id" in export_fields:
1376             fields['.id'] = fields.pop('id', {'string': 'ID'})
1377
1378         # To make fields retrieval more efficient, fetch all sub-fields of a
1379         # given field at the same time. Because the order in the export list is
1380         # arbitrary, this requires ordering all sub-fields of a given field
1381         # together so they can be fetched at the same time
1382         #
1383         # Works the following way:
1384         # * sort the list of fields to export, the default sorting order will
1385         #   put the field itself (if present, for xmlid) and all of its
1386         #   sub-fields right after it
1387         # * then, group on: the first field of the path (which is the same for
1388         #   a field and for its subfields and the length of splitting on the
1389         #   first '/', which basically means grouping the field on one side and
1390         #   all of the subfields on the other. This way, we have the field (for
1391         #   the xmlid) with length 1, and all of the subfields with the same
1392         #   base but a length "flag" of 2
1393         # * if we have a normal field (length 1), just add it to the info
1394         #   mapping (with its string) as-is
1395         # * otherwise, recursively call fields_info via graft_subfields.
1396         #   all graft_subfields does is take the result of fields_info (on the
1397         #   field's model) and prepend the current base (current field), which
1398         #   rebuilds the whole sub-tree for the field
1399         #
1400         # result: because we're not fetching the fields_get for half the
1401         # database models, fetching a namelist with a dozen fields (including
1402         # relational data) falls from ~6s to ~300ms (on the leads model).
1403         # export lists with no sub-fields (e.g. import_compatible lists with
1404         # no o2m) are even more efficient (from the same 6s to ~170ms, as
1405         # there's a single fields_get to execute)
1406         for (base, length), subfields in itertools.groupby(
1407                 sorted(export_fields),
1408                 lambda field: (field.split('/', 1)[0], len(field.split('/', 1)))):
1409             subfields = list(subfields)
1410             if length == 2:
1411                 # subfields is a seq of $base/*rest, and not loaded yet
1412                 info.update(self.graft_subfields(
1413                     fields[base]['relation'], base, fields[base]['string'],
1414                     subfields
1415                 ))
1416             elif base in fields:
1417                 info[base] = fields[base]['string']
1418
1419         return info
1420
1421     def graft_subfields(self, model, prefix, prefix_string, fields):
1422         export_fields = [field.split('/', 1)[1] for field in fields]
1423         return (
1424             (prefix + '/' + k, prefix_string + '/' + v)
1425             for k, v in self.fields_info(model, export_fields).iteritems())
1426
1427 class ExportFormat(object):
1428     raw_data = False
1429
1430     @property
1431     def content_type(self):
1432         """ Provides the format's content type """
1433         raise NotImplementedError()
1434
1435     def filename(self, base):
1436         """ Creates a valid filename for the format (with extension) from the
1437          provided base name (exension-less)
1438         """
1439         raise NotImplementedError()
1440
1441     def from_data(self, fields, rows):
1442         """ Conversion method from OpenERP's export data to whatever the
1443         current export class outputs
1444
1445         :params list fields: a list of fields to export
1446         :params list rows: a list of records to export
1447         :returns:
1448         :rtype: bytes
1449         """
1450         raise NotImplementedError()
1451
1452     def base(self, data, token):
1453         params = simplejson.loads(data)
1454         model, fields, ids, domain, import_compat = \
1455             operator.itemgetter('model', 'fields', 'ids', 'domain',
1456                                 'import_compat')(
1457                 params)
1458
1459         Model = request.session.model(model)
1460         context = dict(request.context or {}, **params.get('context', {}))
1461         ids = ids or Model.search(domain, 0, False, False, context)
1462
1463         field_names = map(operator.itemgetter('name'), fields)
1464         import_data = Model.export_data(ids, field_names, self.raw_data, context=context).get('datas',[])
1465
1466         if import_compat:
1467             columns_headers = field_names
1468         else:
1469             columns_headers = [val['label'].strip() for val in fields]
1470
1471
1472         return request.make_response(self.from_data(columns_headers, import_data),
1473             headers=[('Content-Disposition',
1474                             content_disposition(self.filename(model))),
1475                      ('Content-Type', self.content_type)],
1476             cookies={'fileToken': token})
1477
1478 class CSVExport(ExportFormat, http.Controller):
1479
1480     @http.route('/web/export/csv', type='http', auth="user")
1481     @serialize_exception
1482     def index(self, data, token):
1483         return self.base(data, token)
1484
1485     @property
1486     def content_type(self):
1487         return 'text/csv;charset=utf8'
1488
1489     def filename(self, base):
1490         return base + '.csv'
1491
1492     def from_data(self, fields, rows):
1493         fp = StringIO()
1494         writer = csv.writer(fp, quoting=csv.QUOTE_ALL)
1495
1496         writer.writerow([name.encode('utf-8') for name in fields])
1497
1498         for data in rows:
1499             row = []
1500             for d in data:
1501                 if isinstance(d, basestring):
1502                     d = d.replace('\n',' ').replace('\t',' ')
1503                     try:
1504                         d = d.encode('utf-8')
1505                     except UnicodeError:
1506                         pass
1507                 if d is False: d = None
1508                 row.append(d)
1509             writer.writerow(row)
1510
1511         fp.seek(0)
1512         data = fp.read()
1513         fp.close()
1514         return data
1515
1516 class ExcelExport(ExportFormat, http.Controller):
1517     # Excel needs raw data to correctly handle numbers and date values
1518     raw_data = True
1519
1520     @http.route('/web/export/xls', type='http', auth="user")
1521     @serialize_exception
1522     def index(self, data, token):
1523         return self.base(data, token)
1524
1525     @property
1526     def content_type(self):
1527         return 'application/vnd.ms-excel'
1528
1529     def filename(self, base):
1530         return base + '.xls'
1531
1532     def from_data(self, fields, rows):
1533         workbook = xlwt.Workbook()
1534         worksheet = workbook.add_sheet('Sheet 1')
1535
1536         for i, fieldname in enumerate(fields):
1537             worksheet.write(0, i, fieldname)
1538             worksheet.col(i).width = 8000 # around 220 pixels
1539
1540         base_style = xlwt.easyxf('align: wrap yes')
1541         date_style = xlwt.easyxf('align: wrap yes', num_format_str='YYYY-MM-DD')
1542         datetime_style = xlwt.easyxf('align: wrap yes', num_format_str='YYYY-MM-DD HH:mm:SS')
1543
1544         for row_index, row in enumerate(rows):
1545             for cell_index, cell_value in enumerate(row):
1546                 cell_style = base_style
1547                 if isinstance(cell_value, basestring):
1548                     cell_value = re.sub("\r", " ", cell_value)
1549                 elif isinstance(cell_value, datetime.datetime):
1550                     cell_style = datetime_style
1551                 elif isinstance(cell_value, datetime.date):
1552                     cell_style = date_style
1553                 worksheet.write(row_index + 1, cell_index, cell_value, cell_style)
1554
1555         fp = StringIO()
1556         workbook.save(fp)
1557         fp.seek(0)
1558         data = fp.read()
1559         fp.close()
1560         return data
1561
1562 class Reports(http.Controller):
1563     POLLING_DELAY = 0.25
1564     TYPES_MAPPING = {
1565         'doc': 'application/vnd.ms-word',
1566         'html': 'text/html',
1567         'odt': 'application/vnd.oasis.opendocument.text',
1568         'pdf': 'application/pdf',
1569         'sxw': 'application/vnd.sun.xml.writer',
1570         'xls': 'application/vnd.ms-excel',
1571     }
1572
1573     @http.route('/web/report', type='http', auth="user")
1574     @serialize_exception
1575     def index(self, action, token):
1576         action = simplejson.loads(action)
1577         report_srv = request.session.proxy("report")
1578         context = dict(request.context)
1579         context.update(action["context"])
1580
1581         report_data = {}
1582         report_ids = context.get("active_ids", None)
1583         if 'report_type' in action:
1584             report_data['report_type'] = action['report_type']
1585         if 'datas' in action:
1586             if 'ids' in action['datas']:
1587                 report_ids = action['datas'].pop('ids')
1588             report_data.update(action['datas'])
1589
1590         report_id = report_srv.report(
1591             request.session.db, request.session.uid, request.session.password,
1592             action["report_name"], report_ids,
1593             report_data, context)
1594
1595         report_struct = None
1596         while True:
1597             report_struct = report_srv.report_get(
1598                 request.session.db, request.session.uid, request.session.password, report_id)
1599             if report_struct["state"]:
1600                 break
1601
1602             time.sleep(self.POLLING_DELAY)
1603
1604         report = base64.b64decode(report_struct['result'])
1605         if report_struct.get('code') == 'zlib':
1606             report = zlib.decompress(report)
1607         report_mimetype = self.TYPES_MAPPING.get(
1608             report_struct['format'], 'octet-stream')
1609         file_name = action.get('name', 'report')
1610         if 'name' not in action:
1611             reports = request.session.model('ir.actions.report.xml')
1612             res_id = reports.search([('report_name', '=', action['report_name']),],
1613                                     0, False, False, context)
1614             if len(res_id) > 0:
1615                 file_name = reports.read(res_id[0], ['name'], context)['name']
1616             else:
1617                 file_name = action['report_name']
1618         file_name = '%s.%s' % (file_name, report_struct['format'])
1619         headers=[
1620              ('Content-Disposition', content_disposition(file_name)),
1621              ('Content-Type', report_mimetype),
1622              ('Content-Length', len(report))]
1623         if action.get('pdf_viewer'):
1624             del headers[0]
1625         return request.make_response(report,
1626              headers=headers,
1627              cookies={'fileToken': token})
1628
1629 class Apps(http.Controller):
1630     @http.route('/apps/<app>', auth='user')
1631     def get_app_url(self, req, app):
1632         act_window_obj = request.session.model('ir.actions.act_window')
1633         ir_model_data = request.session.model('ir.model.data')
1634         try:
1635             action_id = ir_model_data.get_object_reference('base', 'open_module_tree')[1]
1636             action = act_window_obj.read(action_id, ['name', 'type', 'res_model', 'view_mode', 'view_type', 'context', 'views', 'domain'])
1637             action['target'] = 'current'
1638         except ValueError:
1639             action = False
1640         try:
1641             app_id = ir_model_data.get_object_reference('base', 'module_%s' % app)[1]
1642         except ValueError:
1643             app_id = False
1644
1645         if action and app_id:
1646             action['res_id'] = app_id
1647             action['view_mode'] = 'form'
1648             action['views'] = [(False, u'form')]
1649
1650         sakey = Session().save_session_action(action)
1651         debug = '?debug' if req.debug else ''
1652         return werkzeug.utils.redirect('/web{0}#sa={1}'.format(debug, sakey))
1653
1654
1655
1656 # vim:expandtab:tabstop=4:softtabstop=4:shiftwidth=4: