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