1# -*- coding: utf-8 -*-
2# Part of Odoo. See LICENSE file for full copyright and licensing details.
3
4import babel.messages.pofile
5import base64
6import copy
7import datetime
8import functools
9import glob
10import hashlib
11import io
12import itertools
13import jinja2
14import json
15import logging
16import operator
17import os
18import re
19import sys
20import tempfile
21
22import werkzeug
23import werkzeug.exceptions
24import werkzeug.utils
25import werkzeug.wrappers
26import werkzeug.wsgi
27from collections import OrderedDict, defaultdict, Counter
28from werkzeug.urls import url_encode, url_decode, iri_to_uri
29from lxml import etree
30import unicodedata
31
32
33import odoo
34import odoo.modules.registry
35from odoo.api import call_kw, Environment
36from odoo.modules import get_module_path, get_resource_path
37from odoo.tools import image_process, topological_sort, html_escape, pycompat, ustr, apply_inheritance_specs, lazy_property, float_repr
38from odoo.tools.mimetypes import guess_mimetype
39from odoo.tools.translate import _
40from odoo.tools.misc import str2bool, xlsxwriter, file_open
41from odoo.tools.safe_eval import safe_eval, time
42from odoo import http, tools
43from odoo.http import content_disposition, dispatch_rpc, request, serialize_exception as _serialize_exception, Response
44from odoo.exceptions import AccessError, UserError, AccessDenied
45from odoo.models import check_method_name
46from odoo.service import db, security
47
48_logger = logging.getLogger(__name__)
49
50if hasattr(sys, 'frozen'):
51    # When running on compiled windows binary, we don't have access to package loader.
52    path = os.path.realpath(os.path.join(os.path.dirname(__file__), '..', 'views'))
53    loader = jinja2.FileSystemLoader(path)
54else:
55    loader = jinja2.PackageLoader('odoo.addons.web', "views")
56
57env = jinja2.Environment(loader=loader, autoescape=True)
58env.filters["json"] = json.dumps
59
60CONTENT_MAXAGE = http.STATIC_CACHE_LONG  # menus, translations, static qweb
61
62DBNAME_PATTERN = '^[a-zA-Z0-9][a-zA-Z0-9_.-]+$'
63
64COMMENT_PATTERN = r'Modified by [\s\w\-.]+ from [\s\w\-.]+'
65
66
67def none_values_filtered(func):
68    @functools.wraps(func)
69    def wrap(iterable):
70        return func(v for v in iterable if v is not None)
71    return wrap
72
73def allow_empty_iterable(func):
74    """
75    Some functions do not accept empty iterables (e.g. max, min with no default value)
76    This returns the function `func` such that it returns None if the iterable
77    is empty instead of raising a ValueError.
78    """
79    @functools.wraps(func)
80    def wrap(iterable):
81        iterator = iter(iterable)
82        try:
83            value = next(iterator)
84            return func(itertools.chain([value], iterator))
85        except StopIteration:
86            return None
87    return wrap
88
89OPERATOR_MAPPING = {
90    'max': none_values_filtered(allow_empty_iterable(max)),
91    'min': none_values_filtered(allow_empty_iterable(min)),
92    'sum': sum,
93    'bool_and': all,
94    'bool_or': any,
95}
96
97#----------------------------------------------------------
98# Odoo Web helpers
99#----------------------------------------------------------
100
101db_list = http.db_list
102
103db_monodb = http.db_monodb
104
105def clean(name): return name.replace('\x3c', '')
106def serialize_exception(f):
107    @functools.wraps(f)
108    def wrap(*args, **kwargs):
109        try:
110            return f(*args, **kwargs)
111        except Exception as e:
112            _logger.exception("An exception occured during an http request")
113            se = _serialize_exception(e)
114            error = {
115                'code': 200,
116                'message': "Odoo Server Error",
117                'data': se
118            }
119            return werkzeug.exceptions.InternalServerError(json.dumps(error))
120    return wrap
121
122def redirect_with_hash(*args, **kw):
123    """
124        .. deprecated:: 8.0
125
126        Use the ``http.redirect_with_hash()`` function instead.
127    """
128    return http.redirect_with_hash(*args, **kw)
129
130def abort_and_redirect(url):
131    r = request.httprequest
132    response = werkzeug.utils.redirect(url, 302)
133    response = r.app.get_response(r, response, explicit_session=False)
134    werkzeug.exceptions.abort(response)
135
136def ensure_db(redirect='/web/database/selector'):
137    # This helper should be used in web client auth="none" routes
138    # if those routes needs a db to work with.
139    # If the heuristics does not find any database, then the users will be
140    # redirected to db selector or any url specified by `redirect` argument.
141    # If the db is taken out of a query parameter, it will be checked against
142    # `http.db_filter()` in order to ensure it's legit and thus avoid db
143    # forgering that could lead to xss attacks.
144    db = request.params.get('db') and request.params.get('db').strip()
145
146    # Ensure db is legit
147    if db and db not in http.db_filter([db]):
148        db = None
149
150    if db and not request.session.db:
151        # User asked a specific database on a new session.
152        # That mean the nodb router has been used to find the route
153        # Depending on installed module in the database, the rendering of the page
154        # may depend on data injected by the database route dispatcher.
155        # Thus, we redirect the user to the same page but with the session cookie set.
156        # This will force using the database route dispatcher...
157        r = request.httprequest
158        url_redirect = werkzeug.urls.url_parse(r.base_url)
159        if r.query_string:
160            # in P3, request.query_string is bytes, the rest is text, can't mix them
161            query_string = iri_to_uri(r.query_string)
162            url_redirect = url_redirect.replace(query=query_string)
163        request.session.db = db
164        abort_and_redirect(url_redirect)
165
166    # if db not provided, use the session one
167    if not db and request.session.db and http.db_filter([request.session.db]):
168        db = request.session.db
169
170    # if no database provided and no database in session, use monodb
171    if not db:
172        db = db_monodb(request.httprequest)
173
174    # if no db can be found til here, send to the database selector
175    # the database selector will redirect to database manager if needed
176    if not db:
177        werkzeug.exceptions.abort(werkzeug.utils.redirect(redirect, 303))
178
179    # always switch the session to the computed db
180    if db != request.session.db:
181        request.session.logout()
182        abort_and_redirect(request.httprequest.url)
183
184    request.session.db = db
185
186def module_installed(environment):
187    # Candidates module the current heuristic is the /static dir
188    loadable = list(http.addons_manifest)
189
190    # Retrieve database installed modules
191    # TODO The following code should move to ir.module.module.list_installed_modules()
192    Modules = environment['ir.module.module']
193    domain = [('state','=','installed'), ('name','in', loadable)]
194    modules = OrderedDict(
195        (module.name, module.dependencies_id.mapped('name'))
196        for module in Modules.search(domain)
197    )
198
199    sorted_modules = topological_sort(modules)
200    return sorted_modules
201
202def module_installed_bypass_session(dbname):
203    try:
204        registry = odoo.registry(dbname)
205        with registry.cursor() as cr:
206            return module_installed(
207                environment=Environment(cr, odoo.SUPERUSER_ID, {}))
208    except Exception:
209        pass
210    return {}
211
212def module_boot(db=None):
213    server_wide_modules = odoo.conf.server_wide_modules or []
214    serverside = ['base', 'web']
215    dbside = []
216    for i in server_wide_modules:
217        if i in http.addons_manifest and i not in serverside:
218            serverside.append(i)
219    monodb = db or db_monodb()
220    if monodb:
221        dbside = module_installed_bypass_session(monodb)
222        dbside = [i for i in dbside if i not in serverside]
223    addons = serverside + dbside
224    return addons
225
226
227def fs2web(path):
228    """convert FS path into web path"""
229    return '/'.join(path.split(os.path.sep))
230
231def manifest_glob(extension, addons=None, db=None, include_remotes=False):
232    if addons is None:
233        addons = module_boot(db=db)
234
235    r = []
236    for addon in addons:
237        manifest = http.addons_manifest.get(addon, None)
238        if not manifest:
239            continue
240        # ensure does not ends with /
241        addons_path = os.path.join(manifest['addons_path'], '')[:-1]
242        globlist = manifest.get(extension, [])
243        for pattern in globlist:
244            if pattern.startswith(('http://', 'https://', '//')):
245                if include_remotes:
246                    r.append((None, pattern, addon))
247            else:
248                for path in glob.glob(os.path.normpath(os.path.join(addons_path, addon, pattern))):
249                    r.append((path, fs2web(path[len(addons_path):]), addon))
250    return r
251
252
253def manifest_list(extension, mods=None, db=None, debug=None):
254    """ list resources to load specifying either:
255    mods: a comma separated string listing modules
256    db: a database name (return all installed modules in that database)
257    """
258    if debug is not None:
259        _logger.warning("odoo.addons.web.main.manifest_list(): debug parameter is deprecated")
260    mods = mods.split(',')
261    files = manifest_glob(extension, addons=mods, db=db, include_remotes=True)
262    return [wp for _fp, wp, addon in files]
263
264def get_last_modified(files):
265    """ Returns the modification time of the most recently modified
266    file provided
267
268    :param list(str) files: names of files to check
269    :return: most recent modification time amongst the fileset
270    :rtype: datetime.datetime
271    """
272    files = list(files)
273    if files:
274        return max(datetime.datetime.fromtimestamp(os.path.getmtime(f))
275                   for f in files)
276    return datetime.datetime(1970, 1, 1)
277
278def make_conditional(response, last_modified=None, etag=None, max_age=0):
279    """ Makes the provided response conditional based upon the request,
280    and mandates revalidation from clients
281
282    Uses Werkzeug's own :meth:`ETagResponseMixin.make_conditional`, after
283    setting ``last_modified`` and ``etag`` correctly on the response object
284
285    :param response: Werkzeug response
286    :type response: werkzeug.wrappers.Response
287    :param datetime.datetime last_modified: last modification date of the response content
288    :param str etag: some sort of checksum of the content (deep etag)
289    :return: the response object provided
290    :rtype: werkzeug.wrappers.Response
291    """
292    response.cache_control.must_revalidate = True
293    response.cache_control.max_age = max_age
294    if last_modified:
295        response.last_modified = last_modified
296    if etag:
297        response.set_etag(etag)
298    return response.make_conditional(request.httprequest)
299
300def _get_login_redirect_url(uid, redirect=None):
301    """ Decide if user requires a specific post-login redirect, e.g. for 2FA, or if they are
302    fully logged and can proceed to the requested URL
303    """
304    if request.session.uid: # fully logged
305        return redirect or '/web'
306
307    # partial session (MFA)
308    url = request.env(user=uid)['res.users'].browse(uid)._mfa_url()
309    if not redirect:
310        return url
311
312    parsed = werkzeug.urls.url_parse(url)
313    qs = parsed.decode_query()
314    qs['redirect'] = redirect
315    return parsed.replace(query=werkzeug.urls.url_encode(qs)).to_url()
316
317def login_and_redirect(db, login, key, redirect_url='/web'):
318    uid = request.session.authenticate(db, login, key)
319    redirect_url = _get_login_redirect_url(uid, redirect_url)
320    return set_cookie_and_redirect(redirect_url)
321
322def set_cookie_and_redirect(redirect_url):
323    redirect = werkzeug.utils.redirect(redirect_url, 303)
324    redirect.autocorrect_location_header = False
325    return redirect
326
327def clean_action(action, env):
328    action_type = action.setdefault('type', 'ir.actions.act_window_close')
329    if action_type == 'ir.actions.act_window':
330        action = fix_view_modes(action)
331
332    # When returning an action, keep only relevant fields/properties
333    readable_fields = env[action['type']]._get_readable_fields()
334    action_type_fields = env[action['type']]._fields.keys()
335
336    cleaned_action = {
337        field: value
338        for field, value in action.items()
339        # keep allowed fields and custom properties fields
340        if field in readable_fields or field not in action_type_fields
341    }
342
343    # Warn about custom properties fields, because use is discouraged
344    action_name = action.get('name') or action
345    custom_properties = action.keys() - readable_fields - action_type_fields
346    if custom_properties:
347        _logger.warning("Action %r contains custom properties %s. Passing them "
348            "via the `params` or `context` properties is recommended instead",
349            action_name, ', '.join(map(repr, custom_properties)))
350
351    return cleaned_action
352
353# I think generate_views,fix_view_modes should go into js ActionManager
354def generate_views(action):
355    """
356    While the server generates a sequence called "views" computing dependencies
357    between a bunch of stuff for views coming directly from the database
358    (the ``ir.actions.act_window model``), it's also possible for e.g. buttons
359    to return custom view dictionaries generated on the fly.
360
361    In that case, there is no ``views`` key available on the action.
362
363    Since the web client relies on ``action['views']``, generate it here from
364    ``view_mode`` and ``view_id``.
365
366    Currently handles two different cases:
367
368    * no view_id, multiple view_mode
369    * single view_id, single view_mode
370
371    :param dict action: action descriptor dictionary to generate a views key for
372    """
373    view_id = action.get('view_id') or False
374    if isinstance(view_id, (list, tuple)):
375        view_id = view_id[0]
376
377    # providing at least one view mode is a requirement, not an option
378    view_modes = action['view_mode'].split(',')
379
380    if len(view_modes) > 1:
381        if view_id:
382            raise ValueError('Non-db action dictionaries should provide '
383                             'either multiple view modes or a single view '
384                             'mode and an optional view id.\n\n Got view '
385                             'modes %r and view id %r for action %r' % (
386                view_modes, view_id, action))
387        action['views'] = [(False, mode) for mode in view_modes]
388        return
389    action['views'] = [(view_id, view_modes[0])]
390
391def fix_view_modes(action):
392    """ For historical reasons, Odoo has weird dealings in relation to
393    view_mode and the view_type attribute (on window actions):
394
395    * one of the view modes is ``tree``, which stands for both list views
396      and tree views
397    * the choice is made by checking ``view_type``, which is either
398      ``form`` for a list view or ``tree`` for an actual tree view
399
400    This methods simply folds the view_type into view_mode by adding a
401    new view mode ``list`` which is the result of the ``tree`` view_mode
402    in conjunction with the ``form`` view_type.
403
404    TODO: this should go into the doc, some kind of "peculiarities" section
405
406    :param dict action: an action descriptor
407    :returns: nothing, the action is modified in place
408    """
409    if not action.get('views'):
410        generate_views(action)
411
412    if action.pop('view_type', 'form') != 'form':
413        return action
414
415    if 'view_mode' in action:
416        action['view_mode'] = ','.join(
417            mode if mode != 'tree' else 'list'
418            for mode in action['view_mode'].split(','))
419    action['views'] = [
420        [id, mode if mode != 'tree' else 'list']
421        for id, mode in action['views']
422    ]
423
424    return action
425
426def _local_web_translations(trans_file):
427    messages = []
428    try:
429        with open(trans_file) as t_file:
430            po = babel.messages.pofile.read_po(t_file)
431    except Exception:
432        return
433    for x in po:
434        if x.id and x.string and "openerp-web" in x.auto_comments:
435            messages.append({'id': x.id, 'string': x.string})
436    return messages
437
438def xml2json_from_elementtree(el, preserve_whitespaces=False):
439    """ xml2json-direct
440    Simple and straightforward XML-to-JSON converter in Python
441    New BSD Licensed
442    http://code.google.com/p/xml2json-direct/
443    """
444    res = {}
445    if el.tag[0] == "{":
446        ns, name = el.tag.rsplit("}", 1)
447        res["tag"] = name
448        res["namespace"] = ns[1:]
449    else:
450        res["tag"] = el.tag
451    res["attrs"] = {}
452    for k, v in el.items():
453        res["attrs"][k] = v
454    kids = []
455    if el.text and (preserve_whitespaces or el.text.strip() != ''):
456        kids.append(el.text)
457    for kid in el:
458        kids.append(xml2json_from_elementtree(kid, preserve_whitespaces))
459        if kid.tail and (preserve_whitespaces or kid.tail.strip() != ''):
460            kids.append(kid.tail)
461    res["children"] = kids
462    return res
463
464class HomeStaticTemplateHelpers(object):
465    """
466    Helper Class that wraps the reading of static qweb templates files
467    and xpath inheritance applied to those templates
468    /!\ Template inheritance order is defined by ir.module.module natural order
469        which is "sequence, name"
470        Then a topological sort is applied, which just puts dependencies
471        of a module before that module
472    """
473    NAME_TEMPLATE_DIRECTIVE = 't-name'
474    STATIC_INHERIT_DIRECTIVE = 't-inherit'
475    STATIC_INHERIT_MODE_DIRECTIVE = 't-inherit-mode'
476    PRIMARY_MODE = 'primary'
477    EXTENSION_MODE = 'extension'
478    DEFAULT_MODE = PRIMARY_MODE
479
480    def __init__(self, addons, db, checksum_only=False, debug=False):
481        '''
482        :param str|list addons: plain list or comma separated list of addons
483        :param str db: the current db we are working on
484        :param bool checksum_only: only computes the checksum of all files for addons
485        :param str debug: the debug mode of the session
486        '''
487        super(HomeStaticTemplateHelpers, self).__init__()
488        self.addons = addons.split(',') if isinstance(addons, str) else addons
489        self.db = db
490        self.debug = debug
491        self.checksum_only = checksum_only
492        self.template_dict = OrderedDict()
493
494    def _get_parent_template(self, addon, template):
495        """Computes the real addon name and the template name
496        of the parent template (the one that is inherited from)
497
498        :param str addon: the addon the template is declared in
499        :param etree template: the current template we are are handling
500        :returns: (str, str)
501        """
502        original_template_name = template.attrib[self.STATIC_INHERIT_DIRECTIVE]
503        split_name_attempt = original_template_name.split('.', 1)
504        parent_addon, parent_name = tuple(split_name_attempt) if len(split_name_attempt) == 2 else (addon, original_template_name)
505        if parent_addon not in self.template_dict:
506            if original_template_name in self.template_dict[addon]:
507                parent_addon = addon
508                parent_name = original_template_name
509            else:
510                raise ValueError(_('Module %s not loaded or inexistent, or templates of addon being loaded (%s) are misordered') % (parent_addon, addon))
511
512        if parent_name not in self.template_dict[parent_addon]:
513            raise ValueError(_("No template found to inherit from. Module %s and template name %s") % (parent_addon, parent_name))
514
515        return parent_addon, parent_name
516
517    def _compute_xml_tree(self, addon, file_name, source):
518        """Computes the xml tree that 'source' contains
519        Applies inheritance specs in the process
520
521        :param str addon: the current addon we are reading files for
522        :param str file_name: the current name of the file we are reading
523        :param str source: the content of the file
524        :returns: etree
525        """
526        try:
527            all_templates_tree = etree.parse(io.BytesIO(source), parser=etree.XMLParser(remove_comments=True)).getroot()
528        except etree.ParseError as e:
529            _logger.error("Could not parse file %s: %s" % (file_name, e.msg))
530            raise e
531
532        self.template_dict.setdefault(addon, OrderedDict())
533        for template_tree in list(all_templates_tree):
534            if self.NAME_TEMPLATE_DIRECTIVE in template_tree.attrib:
535                template_name = template_tree.attrib[self.NAME_TEMPLATE_DIRECTIVE]
536                dotted_names = template_name.split('.', 1)
537                if len(dotted_names) > 1 and dotted_names[0] == addon:
538                    template_name = dotted_names[1]
539            else:
540                # self.template_dict[addon] grows after processing each template
541                template_name = 'anonymous_template_%s' % len(self.template_dict[addon])
542            if self.STATIC_INHERIT_DIRECTIVE in template_tree.attrib:
543                inherit_mode = template_tree.attrib.get(self.STATIC_INHERIT_MODE_DIRECTIVE, self.DEFAULT_MODE)
544                if inherit_mode not in [self.PRIMARY_MODE, self.EXTENSION_MODE]:
545                    raise ValueError(_("Invalid inherit mode. Module %s and template name %s") % (addon, template_name))
546
547                parent_addon, parent_name = self._get_parent_template(addon, template_tree)
548
549                # After several performance tests, we found out that deepcopy is the most efficient
550                # solution in this case (compared with copy, xpath with '.' and stringifying).
551                parent_tree = copy.deepcopy(self.template_dict[parent_addon][parent_name])
552
553                xpaths = list(template_tree)
554                if self.debug and inherit_mode == self.EXTENSION_MODE:
555                    for xpath in xpaths:
556                        xpath.insert(0, etree.Comment(" Modified by %s from %s " % (template_name, addon)))
557                elif inherit_mode == self.PRIMARY_MODE:
558                    parent_tree.tag = template_tree.tag
559                inherited_template = apply_inheritance_specs(parent_tree, xpaths)
560
561                if inherit_mode == self.PRIMARY_MODE:  # New template_tree: A' = B(A)
562                    for attr_name, attr_val in template_tree.attrib.items():
563                        if attr_name not in ('t-inherit', 't-inherit-mode'):
564                            inherited_template.set(attr_name, attr_val)
565                    if self.debug:
566                        self._remove_inheritance_comments(inherited_template)
567                    self.template_dict[addon][template_name] = inherited_template
568
569                else:  # Modifies original: A = B(A)
570                    self.template_dict[parent_addon][parent_name] = inherited_template
571            else:
572                if template_name in self.template_dict[addon]:
573                    raise ValueError(_("Template %s already exists in module %s") % (template_name, addon))
574                self.template_dict[addon][template_name] = template_tree
575        return all_templates_tree
576
577    def _remove_inheritance_comments(self, inherited_template):
578        '''Remove the comments added in the template already, they come from other templates extending
579        the base of this inheritance
580
581        :param inherited_template:
582        '''
583        for comment in inherited_template.xpath('//comment()'):
584            if re.match(COMMENT_PATTERN, comment.text.strip()):
585                comment.getparent().remove(comment)
586
587    def _manifest_glob(self):
588        '''Proxy for manifest_glob
589        Usefull to make 'self' testable'''
590        return manifest_glob('qweb', self.addons, self.db)
591
592    def _read_addon_file(self, file_path):
593        """Reads the content of a file given by file_path
594        Usefull to make 'self' testable
595        :param str file_path:
596        :returns: str
597        """
598        with open(file_path, 'rb') as fp:
599            contents = fp.read()
600        return contents
601
602    def _concat_xml(self, file_dict):
603        """Concatenate xml files
604
605        :param dict(list) file_dict:
606            key: addon name
607            value: list of files for an addon
608        :returns: (concatenation_result, checksum)
609        :rtype: (bytes, str)
610        """
611        checksum = hashlib.new('sha512')  # sha512/256
612        if not file_dict:
613            return b'', checksum.hexdigest()
614
615        root = None
616        for addon, fnames in file_dict.items():
617            for fname in fnames:
618                contents = self._read_addon_file(fname)
619                checksum.update(contents)
620                if not self.checksum_only:
621                    xml = self._compute_xml_tree(addon, fname, contents)
622
623                    if root is None:
624                        root = etree.Element(xml.tag)
625
626        for addon in self.template_dict.values():
627            for template in addon.values():
628                root.append(template)
629
630        return etree.tostring(root, encoding='utf-8') if root is not None else b'', checksum.hexdigest()[:64]
631
632    def _get_qweb_templates(self):
633        """One and only entry point that gets and evaluates static qweb templates
634
635        :rtype: (str, str)
636        """
637        files = OrderedDict([(addon, list()) for addon in self.addons])
638        [files[f[2]].append(f[0]) for f in self._manifest_glob()]
639        content, checksum = self._concat_xml(files)
640        return content, checksum
641
642    @classmethod
643    def get_qweb_templates_checksum(cls, addons, db=None, debug=False):
644        return cls(addons, db, checksum_only=True, debug=debug)._get_qweb_templates()[1]
645
646    @classmethod
647    def get_qweb_templates(cls, addons, db=None, debug=False):
648        return cls(addons, db, debug=debug)._get_qweb_templates()[0]
649
650
651class GroupsTreeNode:
652    """
653    This class builds an ordered tree of groups from the result of a `read_group(lazy=False)`.
654    The `read_group` returns a list of dictionnaries and each dictionnary is used to
655    build a leaf. The entire tree is built by inserting all leaves.
656    """
657
658    def __init__(self, model, fields, groupby, groupby_type, root=None):
659        self._model = model
660        self._export_field_names = fields  # exported field names (e.g. 'journal_id', 'account_id/name', ...)
661        self._groupby = groupby
662        self._groupby_type = groupby_type
663
664        self.count = 0  # Total number of records in the subtree
665        self.children = OrderedDict()
666        self.data = []  # Only leaf nodes have data
667
668        if root:
669            self.insert_leaf(root)
670
671    def _get_aggregate(self, field_name, data, group_operator):
672        # When exporting one2many fields, multiple data lines might be exported for one record.
673        # Blank cells of additionnal lines are filled with an empty string. This could lead to '' being
674        # aggregated with an integer or float.
675        data = (value for value in data if value != '')
676
677        if group_operator == 'avg':
678            return self._get_avg_aggregate(field_name, data)
679
680        aggregate_func = OPERATOR_MAPPING.get(group_operator)
681        if not aggregate_func:
682            _logger.warning("Unsupported export of group_operator '%s' for field %s on model %s" % (group_operator, field_name, self._model._name))
683            return
684
685        if self.data:
686            return aggregate_func(data)
687        return aggregate_func((child.aggregated_values.get(field_name) for child in self.children.values()))
688
689    def _get_avg_aggregate(self, field_name, data):
690        aggregate_func = OPERATOR_MAPPING.get('sum')
691        if self.data:
692            return aggregate_func(data) / self.count
693        children_sums = (child.aggregated_values.get(field_name) * child.count for child in self.children.values())
694        return aggregate_func(children_sums) / self.count
695
696    def _get_aggregated_field_names(self):
697        """ Return field names of exported field having a group operator """
698        aggregated_field_names = []
699        for field_name in self._export_field_names:
700            if field_name == '.id':
701                field_name = 'id'
702            if '/' in field_name:
703                # Currently no support of aggregated value for nested record fields
704                # e.g. line_ids/analytic_line_ids/amount
705                continue
706            field = self._model._fields[field_name]
707            if field.group_operator:
708                aggregated_field_names.append(field_name)
709        return aggregated_field_names
710
711    # Lazy property to memoize aggregated values of children nodes to avoid useless recomputations
712    @lazy_property
713    def aggregated_values(self):
714
715        aggregated_values = {}
716
717        # Transpose the data matrix to group all values of each field in one iterable
718        field_values = zip(*self.data)
719        for field_name in self._export_field_names:
720            field_data = self.data and next(field_values) or []
721
722            if field_name in self._get_aggregated_field_names():
723                field = self._model._fields[field_name]
724                aggregated_values[field_name] = self._get_aggregate(field_name, field_data, field.group_operator)
725
726        return aggregated_values
727
728    def child(self, key):
729        """
730        Return the child identified by `key`.
731        If it doesn't exists inserts a default node and returns it.
732        :param key: child key identifier (groupby value as returned by read_group,
733                    usually (id, display_name))
734        :return: the child node
735        """
736        if key not in self.children:
737            self.children[key] = GroupsTreeNode(self._model, self._export_field_names, self._groupby, self._groupby_type)
738        return self.children[key]
739
740    def insert_leaf(self, group):
741        """
742        Build a leaf from `group` and insert it in the tree.
743        :param group: dict as returned by `read_group(lazy=False)`
744        """
745        leaf_path = [group.get(groupby_field) for groupby_field in self._groupby]
746        domain = group.pop('__domain')
747        count = group.pop('__count')
748
749        records = self._model.search(domain, offset=0, limit=False, order=False)
750
751        # Follow the path from the top level group to the deepest
752        # group which actually contains the records' data.
753        node = self # root
754        node.count += count
755        for node_key in leaf_path:
756            # Go down to the next node or create one if it does not exist yet.
757            node = node.child(node_key)
758            # Update count value and aggregated value.
759            node.count += count
760
761        node.data = records.export_data(self._export_field_names).get('datas',[])
762
763
764class ExportXlsxWriter:
765
766    def __init__(self, field_names, row_count=0):
767        self.field_names = field_names
768        self.output = io.BytesIO()
769        self.workbook = xlsxwriter.Workbook(self.output, {'in_memory': True})
770        self.base_style = self.workbook.add_format({'text_wrap': True})
771        self.header_style = self.workbook.add_format({'bold': True})
772        self.header_bold_style = self.workbook.add_format({'text_wrap': True, 'bold': True, 'bg_color': '#e9ecef'})
773        self.date_style = self.workbook.add_format({'text_wrap': True, 'num_format': 'yyyy-mm-dd'})
774        self.datetime_style = self.workbook.add_format({'text_wrap': True, 'num_format': 'yyyy-mm-dd hh:mm:ss'})
775        self.worksheet = self.workbook.add_worksheet()
776        self.value = False
777
778        if row_count > self.worksheet.xls_rowmax:
779            raise UserError(_('There are too many rows (%s rows, limit: %s) to export as Excel 2007-2013 (.xlsx) format. Consider splitting the export.') % (row_count, self.worksheet.xls_rowmax))
780
781    def __enter__(self):
782        self.write_header()
783        return self
784
785    def __exit__(self, exc_type, exc_value, exc_traceback):
786        self.close()
787
788    def write_header(self):
789        # Write main header
790        for i, fieldname in enumerate(self.field_names):
791            self.write(0, i, fieldname, self.header_style)
792        self.worksheet.set_column(0, i, 30) # around 220 pixels
793
794    def close(self):
795        self.workbook.close()
796        with self.output:
797            self.value = self.output.getvalue()
798
799    def write(self, row, column, cell_value, style=None):
800        self.worksheet.write(row, column, cell_value, style)
801
802    def write_cell(self, row, column, cell_value):
803        cell_style = self.base_style
804
805        if isinstance(cell_value, bytes):
806            try:
807                # because xlsx uses raw export, we can get a bytes object
808                # here. xlsxwriter does not support bytes values in Python 3 ->
809                # assume this is base64 and decode to a string, if this
810                # fails note that you can't export
811                cell_value = pycompat.to_text(cell_value)
812            except UnicodeDecodeError:
813                raise UserError(_("Binary fields can not be exported to Excel unless their content is base64-encoded. That does not seem to be the case for %s.", self.field_names)[column])
814
815        if isinstance(cell_value, str):
816            if len(cell_value) > self.worksheet.xls_strmax:
817                cell_value = _("The content of this cell is too long for an XLSX file (more than %s characters). Please use the CSV format for this export.", self.worksheet.xls_strmax)
818            else:
819                cell_value = cell_value.replace("\r", " ")
820        elif isinstance(cell_value, datetime.datetime):
821            cell_style = self.datetime_style
822        elif isinstance(cell_value, datetime.date):
823            cell_style = self.date_style
824        self.write(row, column, cell_value, cell_style)
825
826class GroupExportXlsxWriter(ExportXlsxWriter):
827
828    def __init__(self, fields, row_count=0):
829        super().__init__([f['label'].strip() for f in fields], row_count)
830        self.fields = fields
831
832    def write_group(self, row, column, group_name, group, group_depth=0):
833        group_name = group_name[1] if isinstance(group_name, tuple) and len(group_name) > 1 else group_name
834        if group._groupby_type[group_depth] != 'boolean':
835            group_name = group_name or _("Undefined")
836        row, column = self._write_group_header(row, column, group_name, group, group_depth)
837
838        # Recursively write sub-groups
839        for child_group_name, child_group in group.children.items():
840            row, column = self.write_group(row, column, child_group_name, child_group, group_depth + 1)
841
842        for record in group.data:
843            row, column = self._write_row(row, column, record)
844        return row, column
845
846    def _write_row(self, row, column, data):
847        for value in data:
848            self.write_cell(row, column, value)
849            column += 1
850        return row + 1, 0
851
852    def _write_group_header(self, row, column, label, group, group_depth=0):
853        aggregates = group.aggregated_values
854
855        label = '%s%s (%s)' % ('    ' * group_depth, label, group.count)
856        self.write(row, column, label, self.header_bold_style)
857        if any(f.get('type') == 'monetary' for f in self.fields[1:]):
858
859            decimal_places = [res['decimal_places'] for res in group._model.env['res.currency'].search_read([], ['decimal_places'])]
860            decimal_places = max(decimal_places) if decimal_places else 2
861        for field in self.fields[1:]: # No aggregates allowed in the first column because of the group title
862            column += 1
863            aggregated_value = aggregates.get(field['name'])
864            # Float fields may not be displayed properly because of float
865            # representation issue with non stored fields or with values
866            # that, even stored, cannot be rounded properly and it is not
867            # acceptable to display useless digits (i.e. monetary)
868            #
869            # non stored field ->  we force 2 digits
870            # stored monetary -> we force max digits of installed currencies
871            if isinstance(aggregated_value, float):
872                if field.get('type') == 'monetary':
873                    aggregated_value = float_repr(aggregated_value, decimal_places)
874                elif not field.get('store'):
875                    aggregated_value = float_repr(aggregated_value, 2)
876            self.write(row, column, str(aggregated_value if aggregated_value is not None else ''), self.header_bold_style)
877        return row + 1, 0
878
879
880#----------------------------------------------------------
881# Odoo Web web Controllers
882#----------------------------------------------------------
883class Home(http.Controller):
884
885    @http.route('/', type='http', auth="none")
886    def index(self, s_action=None, db=None, **kw):
887        return http.local_redirect('/web', query=request.params, keep_hash=True)
888
889    # ideally, this route should be `auth="user"` but that don't work in non-monodb mode.
890    @http.route('/web', type='http', auth="none")
891    def web_client(self, s_action=None, **kw):
892        ensure_db()
893        if not request.session.uid:
894            return werkzeug.utils.redirect('/web/login', 303)
895        if kw.get('redirect'):
896            return werkzeug.utils.redirect(kw.get('redirect'), 303)
897
898        request.uid = request.session.uid
899        try:
900            context = request.env['ir.http'].webclient_rendering_context()
901            response = request.render('web.webclient_bootstrap', qcontext=context)
902            response.headers['X-Frame-Options'] = 'DENY'
903            return response
904        except AccessError:
905            return werkzeug.utils.redirect('/web/login?error=access')
906
907    @http.route('/web/webclient/load_menus/<string:unique>', type='http', auth='user', methods=['GET'])
908    def web_load_menus(self, unique):
909        """
910        Loads the menus for the webclient
911        :param unique: this parameters is not used, but mandatory: it is used by the HTTP stack to make a unique request
912        :return: the menus (including the images in Base64)
913        """
914        menus = request.env["ir.ui.menu"].load_menus(request.session.debug)
915        body = json.dumps(menus, default=ustr)
916        response = request.make_response(body, [
917            # this method must specify a content-type application/json instead of using the default text/html set because
918            # the type of the route is set to HTTP, but the rpc is made with a get and expects JSON
919            ('Content-Type', 'application/json'),
920            ('Cache-Control', 'public, max-age=' + str(CONTENT_MAXAGE)),
921        ])
922        return response
923
924    def _login_redirect(self, uid, redirect=None):
925        return _get_login_redirect_url(uid, redirect)
926
927    @http.route('/web/login', type='http', auth="none")
928    def web_login(self, redirect=None, **kw):
929        ensure_db()
930        request.params['login_success'] = False
931        if request.httprequest.method == 'GET' and redirect and request.session.uid:
932            return http.redirect_with_hash(redirect)
933
934        if not request.uid:
935            request.uid = odoo.SUPERUSER_ID
936
937        values = request.params.copy()
938        try:
939            values['databases'] = http.db_list()
940        except odoo.exceptions.AccessDenied:
941            values['databases'] = None
942
943        if request.httprequest.method == 'POST':
944            old_uid = request.uid
945            try:
946                uid = request.session.authenticate(request.session.db, request.params['login'], request.params['password'])
947                request.params['login_success'] = True
948                return http.redirect_with_hash(self._login_redirect(uid, redirect=redirect))
949            except odoo.exceptions.AccessDenied as e:
950                request.uid = old_uid
951                if e.args == odoo.exceptions.AccessDenied().args:
952                    values['error'] = _("Wrong login/password")
953                else:
954                    values['error'] = e.args[0]
955        else:
956            if 'error' in request.params and request.params.get('error') == 'access':
957                values['error'] = _('Only employees can access this database. Please contact the administrator.')
958
959        if 'login' not in values and request.session.get('auth_login'):
960            values['login'] = request.session.get('auth_login')
961
962        if not odoo.tools.config['list_db']:
963            values['disable_database_manager'] = True
964
965        response = request.render('web.login', values)
966        response.headers['X-Frame-Options'] = 'DENY'
967        return response
968
969    @http.route('/web/become', type='http', auth='user', sitemap=False)
970    def switch_to_admin(self):
971        uid = request.env.user.id
972        if request.env.user._is_system():
973            uid = request.session.uid = odoo.SUPERUSER_ID
974            # invalidate session token cache as we've changed the uid
975            request.env['res.users'].clear_caches()
976            request.session.session_token = security.compute_session_token(request.session, request.env)
977
978        return http.local_redirect(self._login_redirect(uid), keep_hash=True)
979
980class WebClient(http.Controller):
981
982    @http.route('/web/webclient/csslist', type='json', auth="none")
983    def csslist(self, mods=None):
984        return manifest_list('css', mods=mods)
985
986    @http.route('/web/webclient/jslist', type='json', auth="none")
987    def jslist(self, mods=None):
988        return manifest_list('js', mods=mods)
989
990    @http.route('/web/webclient/locale/<string:lang>', type='http', auth="none")
991    def load_locale(self, lang):
992        magic_file_finding = [lang.replace("_", '-').lower(), lang.split('_')[0]]
993        for code in magic_file_finding:
994            try:
995                return http.Response(
996                    werkzeug.wsgi.wrap_file(
997                        request.httprequest.environ,
998                        file_open('web/static/lib/moment/locale/%s.js' % code, 'rb')
999                    ),
1000                    content_type='application/javascript; charset=utf-8',
1001                    headers=[('Cache-Control', 'max-age=%s' % http.STATIC_CACHE)],
1002                    direct_passthrough=True,
1003                )
1004            except IOError:
1005                _logger.debug("No moment locale for code %s", code)
1006
1007        return request.make_response("", headers=[
1008            ('Content-Type', 'application/javascript'),
1009            ('Cache-Control', 'max-age=%s' % http.STATIC_CACHE),
1010        ])
1011
1012    @http.route('/web/webclient/qweb/<string:unique>', type='http', auth="none", cors="*")
1013    def qweb(self, unique, mods=None, db=None):
1014        content = HomeStaticTemplateHelpers.get_qweb_templates(mods, db, debug=request.session.debug)
1015
1016        return request.make_response(content, [
1017                ('Content-Type', 'text/xml'),
1018                ('Cache-Control','public, max-age=' + str(CONTENT_MAXAGE))
1019            ])
1020
1021    @http.route('/web/webclient/bootstrap_translations', type='json', auth="none")
1022    def bootstrap_translations(self, mods):
1023        """ Load local translations from *.po files, as a temporary solution
1024            until we have established a valid session. This is meant only
1025            for translating the login page and db management chrome, using
1026            the browser's language. """
1027        # For performance reasons we only load a single translation, so for
1028        # sub-languages (that should only be partially translated) we load the
1029        # main language PO instead - that should be enough for the login screen.
1030        context = dict(request.context)
1031        request.session._fix_lang(context)
1032        lang = context['lang'].split('_')[0]
1033
1034        translations_per_module = {}
1035        for addon_name in mods:
1036            if http.addons_manifest[addon_name].get('bootstrap'):
1037                addons_path = http.addons_manifest[addon_name]['addons_path']
1038                f_name = os.path.join(addons_path, addon_name, "i18n", lang + ".po")
1039                if not os.path.exists(f_name):
1040                    continue
1041                translations_per_module[addon_name] = {'messages': _local_web_translations(f_name)}
1042
1043        return {"modules": translations_per_module,
1044                "lang_parameters": None}
1045
1046    @http.route('/web/webclient/translations/<string:unique>', type='http', auth="public")
1047    def translations(self, unique, mods=None, lang=None):
1048        """
1049        Load the translations for the specified language and modules
1050
1051        :param unique: this parameters is not used, but mandatory: it is used by the HTTP stack to make a unique request
1052        :param mods: the modules, a comma separated list
1053        :param lang: the language of the user
1054        :return:
1055        """
1056        request.disable_db = False
1057
1058        if mods:
1059            mods = mods.split(',')
1060        translations_per_module, lang_params = request.env["ir.translation"].get_translations_for_webclient(mods, lang)
1061
1062        body = json.dumps({
1063            'lang': lang,
1064            'lang_parameters': lang_params,
1065            'modules': translations_per_module,
1066            'multi_lang': len(request.env['res.lang'].sudo().get_installed()) > 1,
1067        })
1068        response = request.make_response(body, [
1069            # this method must specify a content-type application/json instead of using the default text/html set because
1070            # the type of the route is set to HTTP, but the rpc is made with a get and expects JSON
1071            ('Content-Type', 'application/json'),
1072            ('Cache-Control', 'public, max-age=' + str(CONTENT_MAXAGE)),
1073        ])
1074        return response
1075
1076    @http.route('/web/webclient/version_info', type='json', auth="none")
1077    def version_info(self):
1078        return odoo.service.common.exp_version()
1079
1080    @http.route('/web/tests', type='http', auth="user")
1081    def test_suite(self, mod=None, **kwargs):
1082        return request.render('web.qunit_suite')
1083
1084    @http.route('/web/tests/mobile', type='http', auth="none")
1085    def test_mobile_suite(self, mod=None, **kwargs):
1086        return request.render('web.qunit_mobile_suite')
1087
1088    @http.route('/web/benchmarks', type='http', auth="none")
1089    def benchmarks(self, mod=None, **kwargs):
1090        return request.render('web.benchmark_suite')
1091
1092
1093class Proxy(http.Controller):
1094
1095    @http.route('/web/proxy/post/<path:path>', type='http', auth='user', methods=['GET'])
1096    def post(self, path):
1097        """Effectively execute a POST request that was hooked through user login"""
1098        with request.session.load_request_data() as data:
1099            if not data:
1100                raise werkzeug.exceptions.BadRequest()
1101            from werkzeug.test import Client
1102            from werkzeug.wrappers import BaseResponse
1103            base_url = request.httprequest.base_url
1104            query_string = request.httprequest.query_string
1105            client = Client(request.httprequest.app, BaseResponse)
1106            headers = {'X-Openerp-Session-Id': request.session.sid}
1107            return client.post('/' + path, base_url=base_url, query_string=query_string,
1108                               headers=headers, data=data)
1109
1110class Database(http.Controller):
1111
1112    def _render_template(self, **d):
1113        d.setdefault('manage',True)
1114        d['insecure'] = odoo.tools.config.verify_admin_password('admin')
1115        d['list_db'] = odoo.tools.config['list_db']
1116        d['langs'] = odoo.service.db.exp_list_lang()
1117        d['countries'] = odoo.service.db.exp_list_countries()
1118        d['pattern'] = DBNAME_PATTERN
1119        # databases list
1120        d['databases'] = []
1121        try:
1122            d['databases'] = http.db_list()
1123            d['incompatible_databases'] = odoo.service.db.list_db_incompatible(d['databases'])
1124        except odoo.exceptions.AccessDenied:
1125            monodb = db_monodb()
1126            if monodb:
1127                d['databases'] = [monodb]
1128        return env.get_template("database_manager.html").render(d)
1129
1130    @http.route('/web/database/selector', type='http', auth="none")
1131    def selector(self, **kw):
1132        request._cr = None
1133        return self._render_template(manage=False)
1134
1135    @http.route('/web/database/manager', type='http', auth="none")
1136    def manager(self, **kw):
1137        request._cr = None
1138        return self._render_template()
1139
1140    @http.route('/web/database/create', type='http', auth="none", methods=['POST'], csrf=False)
1141    def create(self, master_pwd, name, lang, password, **post):
1142        insecure = odoo.tools.config.verify_admin_password('admin')
1143        if insecure and master_pwd:
1144            dispatch_rpc('db', 'change_admin_password', ["admin", master_pwd])
1145        try:
1146            if not re.match(DBNAME_PATTERN, name):
1147                raise Exception(_('Invalid database name. Only alphanumerical characters, underscore, hyphen and dot are allowed.'))
1148            # country code could be = "False" which is actually True in python
1149            country_code = post.get('country_code') or False
1150            dispatch_rpc('db', 'create_database', [master_pwd, name, bool(post.get('demo')), lang, password, post['login'], country_code, post['phone']])
1151            request.session.authenticate(name, post['login'], password)
1152            return http.local_redirect('/web/')
1153        except Exception as e:
1154            error = "Database creation error: %s" % (str(e) or repr(e))
1155        return self._render_template(error=error)
1156
1157    @http.route('/web/database/duplicate', type='http', auth="none", methods=['POST'], csrf=False)
1158    def duplicate(self, master_pwd, name, new_name):
1159        insecure = odoo.tools.config.verify_admin_password('admin')
1160        if insecure and master_pwd:
1161            dispatch_rpc('db', 'change_admin_password', ["admin", master_pwd])
1162        try:
1163            if not re.match(DBNAME_PATTERN, new_name):
1164                raise Exception(_('Invalid database name. Only alphanumerical characters, underscore, hyphen and dot are allowed.'))
1165            dispatch_rpc('db', 'duplicate_database', [master_pwd, name, new_name])
1166            request._cr = None  # duplicating a database leads to an unusable cursor
1167            return http.local_redirect('/web/database/manager')
1168        except Exception as e:
1169            error = "Database duplication error: %s" % (str(e) or repr(e))
1170            return self._render_template(error=error)
1171
1172    @http.route('/web/database/drop', type='http', auth="none", methods=['POST'], csrf=False)
1173    def drop(self, master_pwd, name):
1174        insecure = odoo.tools.config.verify_admin_password('admin')
1175        if insecure and master_pwd:
1176            dispatch_rpc('db', 'change_admin_password', ["admin", master_pwd])
1177        try:
1178            dispatch_rpc('db','drop', [master_pwd, name])
1179            request._cr = None  # dropping a database leads to an unusable cursor
1180            return http.local_redirect('/web/database/manager')
1181        except Exception as e:
1182            error = "Database deletion error: %s" % (str(e) or repr(e))
1183            return self._render_template(error=error)
1184
1185    @http.route('/web/database/backup', type='http', auth="none", methods=['POST'], csrf=False)
1186    def backup(self, master_pwd, name, backup_format = 'zip'):
1187        insecure = odoo.tools.config.verify_admin_password('admin')
1188        if insecure and master_pwd:
1189            dispatch_rpc('db', 'change_admin_password', ["admin", master_pwd])
1190        try:
1191            odoo.service.db.check_super(master_pwd)
1192            ts = datetime.datetime.utcnow().strftime("%Y-%m-%d_%H-%M-%S")
1193            filename = "%s_%s.%s" % (name, ts, backup_format)
1194            headers = [
1195                ('Content-Type', 'application/octet-stream; charset=binary'),
1196                ('Content-Disposition', content_disposition(filename)),
1197            ]
1198            dump_stream = odoo.service.db.dump_db(name, None, backup_format)
1199            response = werkzeug.wrappers.Response(dump_stream, headers=headers, direct_passthrough=True)
1200            return response
1201        except Exception as e:
1202            _logger.exception('Database.backup')
1203            error = "Database backup error: %s" % (str(e) or repr(e))
1204            return self._render_template(error=error)
1205
1206    @http.route('/web/database/restore', type='http', auth="none", methods=['POST'], csrf=False)
1207    def restore(self, master_pwd, backup_file, name, copy=False):
1208        insecure = odoo.tools.config.verify_admin_password('admin')
1209        if insecure and master_pwd:
1210            dispatch_rpc('db', 'change_admin_password', ["admin", master_pwd])
1211        try:
1212            data_file = None
1213            db.check_super(master_pwd)
1214            with tempfile.NamedTemporaryFile(delete=False) as data_file:
1215                backup_file.save(data_file)
1216            db.restore_db(name, data_file.name, str2bool(copy))
1217            return http.local_redirect('/web/database/manager')
1218        except Exception as e:
1219            error = "Database restore error: %s" % (str(e) or repr(e))
1220            return self._render_template(error=error)
1221        finally:
1222            if data_file:
1223                os.unlink(data_file.name)
1224
1225    @http.route('/web/database/change_password', type='http', auth="none", methods=['POST'], csrf=False)
1226    def change_password(self, master_pwd, master_pwd_new):
1227        try:
1228            dispatch_rpc('db', 'change_admin_password', [master_pwd, master_pwd_new])
1229            return http.local_redirect('/web/database/manager')
1230        except Exception as e:
1231            error = "Master password update error: %s" % (str(e) or repr(e))
1232            return self._render_template(error=error)
1233
1234    @http.route('/web/database/list', type='json', auth='none')
1235    def list(self):
1236        """
1237        Used by Mobile application for listing database
1238        :return: List of databases
1239        :rtype: list
1240        """
1241        return http.db_list()
1242
1243class Session(http.Controller):
1244
1245    @http.route('/web/session/get_session_info', type='json', auth="none")
1246    def get_session_info(self):
1247        request.session.check_security()
1248        request.uid = request.session.uid
1249        request.disable_db = False
1250        return request.env['ir.http'].session_info()
1251
1252    @http.route('/web/session/authenticate', type='json', auth="none")
1253    def authenticate(self, db, login, password, base_location=None):
1254        request.session.authenticate(db, login, password)
1255        return request.env['ir.http'].session_info()
1256
1257    @http.route('/web/session/change_password', type='json', auth="user")
1258    def change_password(self, fields):
1259        old_password, new_password,confirm_password = operator.itemgetter('old_pwd', 'new_password','confirm_pwd')(
1260            {f['name']: f['value'] for f in fields})
1261        if not (old_password.strip() and new_password.strip() and confirm_password.strip()):
1262            return {'error':_('You cannot leave any password empty.'),'title': _('Change Password')}
1263        if new_password != confirm_password:
1264            return {'error': _('The new password and its confirmation must be identical.'),'title': _('Change Password')}
1265
1266        msg = _("Error, password not changed !")
1267        try:
1268            if request.env['res.users'].change_password(old_password, new_password):
1269                return {'new_password':new_password}
1270        except AccessDenied as e:
1271            msg = e.args[0]
1272            if msg == AccessDenied().args[0]:
1273                msg = _('The old password you provided is incorrect, your password was not changed.')
1274        except UserError as e:
1275            msg = e.args[0]
1276        return {'title': _('Change Password'), 'error': msg}
1277
1278    @http.route('/web/session/get_lang_list', type='json', auth="none")
1279    def get_lang_list(self):
1280        try:
1281            return dispatch_rpc('db', 'list_lang', []) or []
1282        except Exception as e:
1283            return {"error": e, "title": _("Languages")}
1284
1285    @http.route('/web/session/modules', type='json', auth="user")
1286    def modules(self):
1287        # return all installed modules. Web client is smart enough to not load a module twice
1288        return module_installed(environment=request.env(user=odoo.SUPERUSER_ID))
1289
1290    @http.route('/web/session/save_session_action', type='json', auth="user")
1291    def save_session_action(self, the_action):
1292        """
1293        This method store an action object in the session object and returns an integer
1294        identifying that action. The method get_session_action() can be used to get
1295        back the action.
1296
1297        :param the_action: The action to save in the session.
1298        :type the_action: anything
1299        :return: A key identifying the saved action.
1300        :rtype: integer
1301        """
1302        return request.session.save_action(the_action)
1303
1304    @http.route('/web/session/get_session_action', type='json', auth="user")
1305    def get_session_action(self, key):
1306        """
1307        Gets back a previously saved action. This method can return None if the action
1308        was saved since too much time (this case should be handled in a smart way).
1309
1310        :param key: The key given by save_session_action()
1311        :type key: integer
1312        :return: The saved action or None.
1313        :rtype: anything
1314        """
1315        return request.session.get_action(key)
1316
1317    @http.route('/web/session/check', type='json', auth="user")
1318    def check(self):
1319        request.session.check_security()
1320        return None
1321
1322    @http.route('/web/session/account', type='json', auth="user")
1323    def account(self):
1324        ICP = request.env['ir.config_parameter'].sudo()
1325        params = {
1326            'response_type': 'token',
1327            'client_id': ICP.get_param('database.uuid') or '',
1328            'state': json.dumps({'d': request.db, 'u': ICP.get_param('web.base.url')}),
1329            'scope': 'userinfo',
1330        }
1331        return 'https://accounts.odoo.com/oauth2/auth?' + url_encode(params)
1332
1333    @http.route('/web/session/destroy', type='json', auth="user")
1334    def destroy(self):
1335        request.session.logout()
1336
1337    @http.route('/web/session/logout', type='http', auth="none")
1338    def logout(self, redirect='/web'):
1339        request.session.logout(keep_db=True)
1340        return werkzeug.utils.redirect(redirect, 303)
1341
1342
1343class DataSet(http.Controller):
1344
1345    @http.route('/web/dataset/search_read', type='json', auth="user")
1346    def search_read(self, model, fields=False, offset=0, limit=False, domain=None, sort=None):
1347        return self.do_search_read(model, fields, offset, limit, domain, sort)
1348
1349    def do_search_read(self, model, fields=False, offset=0, limit=False, domain=None, sort=None):
1350        """ Performs a search() followed by a read() (if needed) using the
1351        provided search criteria
1352
1353        :param str model: the name of the model to search on
1354        :param fields: a list of the fields to return in the result records
1355        :type fields: [str]
1356        :param int offset: from which index should the results start being returned
1357        :param int limit: the maximum number of records to return
1358        :param list domain: the search domain for the query
1359        :param list sort: sorting directives
1360        :returns: A structure (dict) with two keys: ids (all the ids matching
1361                  the (domain, context) pair) and records (paginated records
1362                  matching fields selection set)
1363        :rtype: list
1364        """
1365        Model = request.env[model]
1366        return Model.web_search_read(domain, fields, offset=offset, limit=limit, order=sort)
1367
1368    @http.route('/web/dataset/load', type='json', auth="user")
1369    def load(self, model, id, fields):
1370        value = {}
1371        r = request.env[model].browse([id]).read()
1372        if r:
1373            value = r[0]
1374        return {'value': value}
1375
1376    def call_common(self, model, method, args, domain_id=None, context_id=None):
1377        return self._call_kw(model, method, args, {})
1378
1379    def _call_kw(self, model, method, args, kwargs):
1380        check_method_name(method)
1381        return call_kw(request.env[model], method, args, kwargs)
1382
1383    @http.route('/web/dataset/call', type='json', auth="user")
1384    def call(self, model, method, args, domain_id=None, context_id=None):
1385        return self._call_kw(model, method, args, {})
1386
1387    @http.route(['/web/dataset/call_kw', '/web/dataset/call_kw/<path:path>'], type='json', auth="user")
1388    def call_kw(self, model, method, args, kwargs, path=None):
1389        return self._call_kw(model, method, args, kwargs)
1390
1391    @http.route('/web/dataset/call_button', type='json', auth="user")
1392    def call_button(self, model, method, args, kwargs):
1393        action = self._call_kw(model, method, args, kwargs)
1394        if isinstance(action, dict) and action.get('type') != '':
1395            return clean_action(action, env=request.env)
1396        return False
1397
1398    @http.route('/web/dataset/resequence', type='json', auth="user")
1399    def resequence(self, model, ids, field='sequence', offset=0):
1400        """ Re-sequences a number of records in the model, by their ids
1401
1402        The re-sequencing starts at the first model of ``ids``, the sequence
1403        number is incremented by one after each record and starts at ``offset``
1404
1405        :param ids: identifiers of the records to resequence, in the new sequence order
1406        :type ids: list(id)
1407        :param str field: field used for sequence specification, defaults to
1408                          "sequence"
1409        :param int offset: sequence number for first record in ``ids``, allows
1410                           starting the resequencing from an arbitrary number,
1411                           defaults to ``0``
1412        """
1413        m = request.env[model]
1414        if not m.fields_get([field]):
1415            return False
1416        # python 2.6 has no start parameter
1417        for i, record in enumerate(m.browse(ids)):
1418            record.write({field: i + offset})
1419        return True
1420
1421class View(http.Controller):
1422
1423    @http.route('/web/view/edit_custom', type='json', auth="user")
1424    def edit_custom(self, custom_id, arch):
1425        """
1426        Edit a custom view
1427
1428        :param int custom_id: the id of the edited custom view
1429        :param str arch: the edited arch of the custom view
1430        :returns: dict with acknowledged operation (result set to True)
1431        """
1432        custom_view = request.env['ir.ui.view.custom'].browse(custom_id)
1433        custom_view.write({ 'arch': arch })
1434        return {'result': True}
1435
1436class Binary(http.Controller):
1437
1438    @staticmethod
1439    def placeholder(image='placeholder.png'):
1440        image_path = image.lstrip('/').split('/') if '/' in image else ['web', 'static', 'src', 'img', image]
1441        with tools.file_open(get_resource_path(*image_path), 'rb') as fd:
1442            return fd.read()
1443
1444    @http.route(['/web/content',
1445        '/web/content/<string:xmlid>',
1446        '/web/content/<string:xmlid>/<string:filename>',
1447        '/web/content/<int:id>',
1448        '/web/content/<int:id>/<string:filename>',
1449        '/web/content/<int:id>-<string:unique>',
1450        '/web/content/<int:id>-<string:unique>/<string:filename>',
1451        '/web/content/<int:id>-<string:unique>/<path:extra>/<string:filename>',
1452        '/web/content/<string:model>/<int:id>/<string:field>',
1453        '/web/content/<string:model>/<int:id>/<string:field>/<string:filename>'], type='http', auth="public")
1454    def content_common(self, xmlid=None, model='ir.attachment', id=None, field='datas',
1455                       filename=None, filename_field='name', unique=None, mimetype=None,
1456                       download=None, data=None, token=None, access_token=None, **kw):
1457
1458        status, headers, content = request.env['ir.http'].binary_content(
1459            xmlid=xmlid, model=model, id=id, field=field, unique=unique, filename=filename,
1460            filename_field=filename_field, download=download, mimetype=mimetype, access_token=access_token)
1461
1462        if status != 200:
1463            return request.env['ir.http']._response_by_status(status, headers, content)
1464        else:
1465            content_base64 = base64.b64decode(content)
1466            headers.append(('Content-Length', len(content_base64)))
1467            response = request.make_response(content_base64, headers)
1468        if token:
1469            response.set_cookie('fileToken', token)
1470        return response
1471
1472    @http.route(['/web/partner_image',
1473        '/web/partner_image/<int:rec_id>',
1474        '/web/partner_image/<int:rec_id>/<string:field>',
1475        '/web/partner_image/<int:rec_id>/<string:field>/<string:model>/'], type='http', auth="public")
1476    def content_image_partner(self, rec_id, field='image_128', model='res.partner', **kwargs):
1477        # other kwargs are ignored on purpose
1478        return self._content_image(id=rec_id, model='res.partner', field=field,
1479            placeholder='user_placeholder.jpg')
1480
1481    @http.route(['/web/image',
1482        '/web/image/<string:xmlid>',
1483        '/web/image/<string:xmlid>/<string:filename>',
1484        '/web/image/<string:xmlid>/<int:width>x<int:height>',
1485        '/web/image/<string:xmlid>/<int:width>x<int:height>/<string:filename>',
1486        '/web/image/<string:model>/<int:id>/<string:field>',
1487        '/web/image/<string:model>/<int:id>/<string:field>/<string:filename>',
1488        '/web/image/<string:model>/<int:id>/<string:field>/<int:width>x<int:height>',
1489        '/web/image/<string:model>/<int:id>/<string:field>/<int:width>x<int:height>/<string:filename>',
1490        '/web/image/<int:id>',
1491        '/web/image/<int:id>/<string:filename>',
1492        '/web/image/<int:id>/<int:width>x<int:height>',
1493        '/web/image/<int:id>/<int:width>x<int:height>/<string:filename>',
1494        '/web/image/<int:id>-<string:unique>',
1495        '/web/image/<int:id>-<string:unique>/<string:filename>',
1496        '/web/image/<int:id>-<string:unique>/<int:width>x<int:height>',
1497        '/web/image/<int:id>-<string:unique>/<int:width>x<int:height>/<string:filename>'], type='http', auth="public")
1498    def content_image(self, xmlid=None, model='ir.attachment', id=None, field='datas',
1499                      filename_field='name', unique=None, filename=None, mimetype=None,
1500                      download=None, width=0, height=0, crop=False, access_token=None,
1501                      **kwargs):
1502        # other kwargs are ignored on purpose
1503        return self._content_image(xmlid=xmlid, model=model, id=id, field=field,
1504            filename_field=filename_field, unique=unique, filename=filename, mimetype=mimetype,
1505            download=download, width=width, height=height, crop=crop,
1506            quality=int(kwargs.get('quality', 0)), access_token=access_token)
1507
1508    def _content_image(self, xmlid=None, model='ir.attachment', id=None, field='datas',
1509                       filename_field='name', unique=None, filename=None, mimetype=None,
1510                       download=None, width=0, height=0, crop=False, quality=0, access_token=None,
1511                       placeholder=None, **kwargs):
1512        status, headers, image_base64 = request.env['ir.http'].binary_content(
1513            xmlid=xmlid, model=model, id=id, field=field, unique=unique, filename=filename,
1514            filename_field=filename_field, download=download, mimetype=mimetype,
1515            default_mimetype='image/png', access_token=access_token)
1516
1517        return Binary._content_image_get_response(
1518            status, headers, image_base64, model=model, id=id, field=field, download=download,
1519            width=width, height=height, crop=crop, quality=quality,
1520            placeholder=placeholder)
1521
1522    @staticmethod
1523    def _content_image_get_response(
1524            status, headers, image_base64, model='ir.attachment', id=None,
1525            field='datas', download=None, width=0, height=0, crop=False,
1526            quality=0, placeholder='placeholder.png'):
1527        if status in [301, 304] or (status != 200 and download):
1528            return request.env['ir.http']._response_by_status(status, headers, image_base64)
1529        if not image_base64:
1530            if placeholder is None and model in request.env:
1531                # Try to browse the record in case a specific placeholder
1532                # is supposed to be used. (eg: Unassigned users on a task)
1533                record = request.env[model].browse(int(id)) if id else request.env[model]
1534                placeholder_filename = record._get_placeholder_filename(field=field)
1535                placeholder_content = Binary.placeholder(image=placeholder_filename)
1536            else:
1537                placeholder_content = Binary.placeholder()
1538            # Since we set a placeholder for any missing image, the status must be 200. In case one
1539            # wants to configure a specific 404 page (e.g. though nginx), a 404 status will cause
1540            # troubles.
1541            status = 200
1542            image_base64 = base64.b64encode(placeholder_content)
1543
1544            if not (width or height):
1545                width, height = odoo.tools.image_guess_size_from_field_name(field)
1546
1547        try:
1548            image_base64 = image_process(image_base64, size=(int(width), int(height)), crop=crop, quality=int(quality))
1549        except Exception:
1550            return request.not_found()
1551
1552        content = base64.b64decode(image_base64)
1553        headers = http.set_safe_image_headers(headers, content)
1554        response = request.make_response(content, headers)
1555        response.status_code = status
1556        return response
1557
1558    # backward compatibility
1559    @http.route(['/web/binary/image'], type='http', auth="public")
1560    def content_image_backward_compatibility(self, model, id, field, resize=None, **kw):
1561        width = None
1562        height = None
1563        if resize:
1564            width, height = resize.split(",")
1565        return self.content_image(model=model, id=id, field=field, width=width, height=height)
1566
1567
1568    @http.route('/web/binary/upload', type='http', auth="user")
1569    @serialize_exception
1570    def upload(self, ufile, callback=None):
1571        # TODO: might be useful to have a configuration flag for max-length file uploads
1572        out = """<script language="javascript" type="text/javascript">
1573                    var win = window.top.window;
1574                    win.jQuery(win).trigger(%s, %s);
1575                </script>"""
1576        try:
1577            data = ufile.read()
1578            args = [len(data), ufile.filename,
1579                    ufile.content_type, pycompat.to_text(base64.b64encode(data))]
1580        except Exception as e:
1581            args = [False, str(e)]
1582        return out % (json.dumps(clean(callback)), json.dumps(args)) if callback else json.dumps(args)
1583
1584    @http.route('/web/binary/upload_attachment', type='http', auth="user")
1585    @serialize_exception
1586    def upload_attachment(self, model, id, ufile, callback=None):
1587        files = request.httprequest.files.getlist('ufile')
1588        Model = request.env['ir.attachment']
1589        out = """<script language="javascript" type="text/javascript">
1590                    var win = window.top.window;
1591                    win.jQuery(win).trigger(%s, %s);
1592                </script>"""
1593        args = []
1594        for ufile in files:
1595
1596            filename = ufile.filename
1597            if request.httprequest.user_agent.browser == 'safari':
1598                # Safari sends NFD UTF-8 (where é is composed by 'e' and [accent])
1599                # we need to send it the same stuff, otherwise it'll fail
1600                filename = unicodedata.normalize('NFD', ufile.filename)
1601
1602            try:
1603                attachment = Model.create({
1604                    'name': filename,
1605                    'datas': base64.encodebytes(ufile.read()),
1606                    'res_model': model,
1607                    'res_id': int(id)
1608                })
1609                attachment._post_add_create()
1610            except Exception:
1611                args.append({'error': _("Something horrible happened")})
1612                _logger.exception("Fail to upload attachment %s" % ufile.filename)
1613            else:
1614                args.append({
1615                    'filename': clean(filename),
1616                    'mimetype': ufile.content_type,
1617                    'id': attachment.id,
1618                    'size': attachment.file_size
1619                })
1620        return out % (json.dumps(clean(callback)), json.dumps(args)) if callback else json.dumps(args)
1621
1622    @http.route([
1623        '/web/binary/company_logo',
1624        '/logo',
1625        '/logo.png',
1626    ], type='http', auth="none", cors="*")
1627    def company_logo(self, dbname=None, **kw):
1628        imgname = 'logo'
1629        imgext = '.png'
1630        placeholder = functools.partial(get_resource_path, 'web', 'static', 'src', 'img')
1631        uid = None
1632        if request.session.db:
1633            dbname = request.session.db
1634            uid = request.session.uid
1635        elif dbname is None:
1636            dbname = db_monodb()
1637
1638        if not uid:
1639            uid = odoo.SUPERUSER_ID
1640
1641        if not dbname:
1642            response = http.send_file(placeholder(imgname + imgext))
1643        else:
1644            try:
1645                # create an empty registry
1646                registry = odoo.modules.registry.Registry(dbname)
1647                with registry.cursor() as cr:
1648                    company = int(kw['company']) if kw and kw.get('company') else False
1649                    if company:
1650                        cr.execute("""SELECT logo_web, write_date
1651                                        FROM res_company
1652                                       WHERE id = %s
1653                                   """, (company,))
1654                    else:
1655                        cr.execute("""SELECT c.logo_web, c.write_date
1656                                        FROM res_users u
1657                                   LEFT JOIN res_company c
1658                                          ON c.id = u.company_id
1659                                       WHERE u.id = %s
1660                                   """, (uid,))
1661                    row = cr.fetchone()
1662                    if row and row[0]:
1663                        image_base64 = base64.b64decode(row[0])
1664                        image_data = io.BytesIO(image_base64)
1665                        mimetype = guess_mimetype(image_base64, default='image/png')
1666                        imgext = '.' + mimetype.split('/')[1]
1667                        if imgext == '.svg+xml':
1668                            imgext = '.svg'
1669                        response = http.send_file(image_data, filename=imgname + imgext, mimetype=mimetype, mtime=row[1])
1670                    else:
1671                        response = http.send_file(placeholder('nologo.png'))
1672            except Exception:
1673                response = http.send_file(placeholder(imgname + imgext))
1674
1675        return response
1676
1677    @http.route(['/web/sign/get_fonts','/web/sign/get_fonts/<string:fontname>'], type='json', auth='public')
1678    def get_fonts(self, fontname=None):
1679        """This route will return a list of base64 encoded fonts.
1680
1681        Those fonts will be proposed to the user when creating a signature
1682        using mode 'auto'.
1683
1684        :return: base64 encoded fonts
1685        :rtype: list
1686        """
1687
1688
1689        fonts = []
1690        if fontname:
1691            module_path = get_module_path('web')
1692            fonts_folder_path = os.path.join(module_path, 'static/src/fonts/sign/')
1693            module_resource_path = get_resource_path('web', 'static/src/fonts/sign/' + fontname)
1694            if fonts_folder_path and module_resource_path:
1695                fonts_folder_path = os.path.join(os.path.normpath(fonts_folder_path), '')
1696                module_resource_path = os.path.normpath(module_resource_path)
1697                if module_resource_path.startswith(fonts_folder_path):
1698                    with file_open(module_resource_path, 'rb') as font_file:
1699                        font = base64.b64encode(font_file.read())
1700                        fonts.append(font)
1701        else:
1702            current_dir = os.path.dirname(os.path.abspath(__file__))
1703            fonts_directory = os.path.join(current_dir, '..', 'static', 'src', 'fonts', 'sign')
1704            font_filenames = sorted([fn for fn in os.listdir(fonts_directory) if fn.endswith(('.ttf', '.otf', '.woff', '.woff2'))])
1705
1706            for filename in font_filenames:
1707                font_file = open(os.path.join(fonts_directory, filename), 'rb')
1708                font = base64.b64encode(font_file.read())
1709                fonts.append(font)
1710        return fonts
1711
1712class Action(http.Controller):
1713
1714    @http.route('/web/action/load', type='json', auth="user")
1715    def load(self, action_id, additional_context=None):
1716        Actions = request.env['ir.actions.actions']
1717        value = False
1718        try:
1719            action_id = int(action_id)
1720        except ValueError:
1721            try:
1722                action = request.env.ref(action_id)
1723                assert action._name.startswith('ir.actions.')
1724                action_id = action.id
1725            except Exception:
1726                action_id = 0   # force failed read
1727
1728        base_action = Actions.browse([action_id]).sudo().read(['type'])
1729        if base_action:
1730            ctx = dict(request.context)
1731            action_type = base_action[0]['type']
1732            if action_type == 'ir.actions.report':
1733                ctx.update({'bin_size': True})
1734            if additional_context:
1735                ctx.update(additional_context)
1736            request.context = ctx
1737            action = request.env[action_type].sudo().browse([action_id]).read()
1738            if action:
1739                value = clean_action(action[0], env=request.env)
1740        return value
1741
1742    @http.route('/web/action/run', type='json', auth="user")
1743    def run(self, action_id):
1744        action = request.env['ir.actions.server'].browse([action_id])
1745        result = action.run()
1746        return clean_action(result, env=action.env) if result else False
1747
1748class Export(http.Controller):
1749
1750    @http.route('/web/export/formats', type='json', auth="user")
1751    def formats(self):
1752        """ Returns all valid export formats
1753
1754        :returns: for each export format, a pair of identifier and printable name
1755        :rtype: [(str, str)]
1756        """
1757        return [
1758            {'tag': 'xlsx', 'label': 'XLSX', 'error': None if xlsxwriter else "XlsxWriter 0.9.3 required"},
1759            {'tag': 'csv', 'label': 'CSV'},
1760        ]
1761
1762    def fields_get(self, model):
1763        Model = request.env[model]
1764        fields = Model.fields_get()
1765        return fields
1766
1767    @http.route('/web/export/get_fields', type='json', auth="user")
1768    def get_fields(self, model, prefix='', parent_name= '',
1769                   import_compat=True, parent_field_type=None,
1770                   parent_field=None, exclude=None):
1771
1772        fields = self.fields_get(model)
1773        if import_compat:
1774            if parent_field_type in ['many2one', 'many2many']:
1775                rec_name = request.env[model]._rec_name_fallback()
1776                fields = {'id': fields['id'], rec_name: fields[rec_name]}
1777        else:
1778            fields['.id'] = {**fields['id']}
1779
1780        fields['id']['string'] = _('External ID')
1781
1782        if parent_field:
1783            parent_field['string'] = _('External ID')
1784            fields['id'] = parent_field
1785
1786        fields_sequence = sorted(fields.items(),
1787            key=lambda field: odoo.tools.ustr(field[1].get('string', '').lower()))
1788
1789        records = []
1790        for field_name, field in fields_sequence:
1791            if import_compat and not field_name == 'id':
1792                if exclude and field_name in exclude:
1793                    continue
1794                if field.get('readonly'):
1795                    # If none of the field's states unsets readonly, skip the field
1796                    if all(dict(attrs).get('readonly', True)
1797                           for attrs in field.get('states', {}).values()):
1798                        continue
1799            if not field.get('exportable', True):
1800                continue
1801
1802            id = prefix + (prefix and '/'or '') + field_name
1803            val = id
1804            if field_name == 'name' and import_compat and parent_field_type in ['many2one', 'many2many']:
1805                # Add name field when expand m2o and m2m fields in import-compatible mode
1806                val = prefix
1807            name = parent_name + (parent_name and '/' or '') + field['string']
1808            record = {'id': id, 'string': name,
1809                      'value': val, 'children': False,
1810                      'field_type': field.get('type'),
1811                      'required': field.get('required'),
1812                      'relation_field': field.get('relation_field')}
1813            records.append(record)
1814
1815            if len(id.split('/')) < 3 and 'relation' in field:
1816                ref = field.pop('relation')
1817                record['value'] += '/id'
1818                record['params'] = {'model': ref, 'prefix': id, 'name': name, 'parent_field': field}
1819                record['children'] = True
1820
1821        return records
1822
1823    @http.route('/web/export/namelist', type='json', auth="user")
1824    def namelist(self, model, export_id):
1825        # TODO: namelist really has no reason to be in Python (although itertools.groupby helps)
1826        export = request.env['ir.exports'].browse([export_id]).read()[0]
1827        export_fields_list = request.env['ir.exports.line'].browse(export['export_fields']).read()
1828
1829        fields_data = self.fields_info(
1830            model, [f['name'] for f in export_fields_list])
1831
1832        return [
1833            {'name': field['name'], 'label': fields_data[field['name']]}
1834            for field in export_fields_list
1835        ]
1836
1837    def fields_info(self, model, export_fields):
1838        info = {}
1839        fields = self.fields_get(model)
1840        if ".id" in export_fields:
1841            fields['.id'] = fields.get('id', {'string': 'ID'})
1842
1843        # To make fields retrieval more efficient, fetch all sub-fields of a
1844        # given field at the same time. Because the order in the export list is
1845        # arbitrary, this requires ordering all sub-fields of a given field
1846        # together so they can be fetched at the same time
1847        #
1848        # Works the following way:
1849        # * sort the list of fields to export, the default sorting order will
1850        #   put the field itself (if present, for xmlid) and all of its
1851        #   sub-fields right after it
1852        # * then, group on: the first field of the path (which is the same for
1853        #   a field and for its subfields and the length of splitting on the
1854        #   first '/', which basically means grouping the field on one side and
1855        #   all of the subfields on the other. This way, we have the field (for
1856        #   the xmlid) with length 1, and all of the subfields with the same
1857        #   base but a length "flag" of 2
1858        # * if we have a normal field (length 1), just add it to the info
1859        #   mapping (with its string) as-is
1860        # * otherwise, recursively call fields_info via graft_subfields.
1861        #   all graft_subfields does is take the result of fields_info (on the
1862        #   field's model) and prepend the current base (current field), which
1863        #   rebuilds the whole sub-tree for the field
1864        #
1865        # result: because we're not fetching the fields_get for half the
1866        # database models, fetching a namelist with a dozen fields (including
1867        # relational data) falls from ~6s to ~300ms (on the leads model).
1868        # export lists with no sub-fields (e.g. import_compatible lists with
1869        # no o2m) are even more efficient (from the same 6s to ~170ms, as
1870        # there's a single fields_get to execute)
1871        for (base, length), subfields in itertools.groupby(
1872                sorted(export_fields),
1873                lambda field: (field.split('/', 1)[0], len(field.split('/', 1)))):
1874            subfields = list(subfields)
1875            if length == 2:
1876                # subfields is a seq of $base/*rest, and not loaded yet
1877                info.update(self.graft_subfields(
1878                    fields[base]['relation'], base, fields[base]['string'],
1879                    subfields
1880                ))
1881            elif base in fields:
1882                info[base] = fields[base]['string']
1883
1884        return info
1885
1886    def graft_subfields(self, model, prefix, prefix_string, fields):
1887        export_fields = [field.split('/', 1)[1] for field in fields]
1888        return (
1889            (prefix + '/' + k, prefix_string + '/' + v)
1890            for k, v in self.fields_info(model, export_fields).items())
1891
1892class ExportFormat(object):
1893
1894    @property
1895    def content_type(self):
1896        """ Provides the format's content type """
1897        raise NotImplementedError()
1898
1899    def filename(self, base):
1900        """ Creates a valid filename for the format (with extension) from the
1901         provided base name (exension-less)
1902        """
1903        raise NotImplementedError()
1904
1905    def from_data(self, fields, rows):
1906        """ Conversion method from Odoo's export data to whatever the
1907        current export class outputs
1908
1909        :params list fields: a list of fields to export
1910        :params list rows: a list of records to export
1911        :returns:
1912        :rtype: bytes
1913        """
1914        raise NotImplementedError()
1915
1916    def from_group_data(self, fields, groups):
1917        raise NotImplementedError()
1918
1919    def base(self, data, token):
1920        params = json.loads(data)
1921        model, fields, ids, domain, import_compat = \
1922            operator.itemgetter('model', 'fields', 'ids', 'domain', 'import_compat')(params)
1923
1924        Model = request.env[model].with_context(**params.get('context', {}))
1925        if not Model._is_an_ordinary_table():
1926            fields = [field for field in fields if field['name'] != 'id']
1927
1928        field_names = [f['name'] for f in fields]
1929        if import_compat:
1930            columns_headers = field_names
1931        else:
1932            columns_headers = [val['label'].strip() for val in fields]
1933
1934        groupby = params.get('groupby')
1935        if not import_compat and groupby:
1936            groupby_type = [Model._fields[x.split(':')[0]].type for x in groupby]
1937            domain = [('id', 'in', ids)] if ids else domain
1938            groups_data = Model.read_group(domain, [x if x != '.id' else 'id' for x in field_names], groupby, lazy=False)
1939
1940            # read_group(lazy=False) returns a dict only for final groups (with actual data),
1941            # not for intermediary groups. The full group tree must be re-constructed.
1942            tree = GroupsTreeNode(Model, field_names, groupby, groupby_type)
1943            for leaf in groups_data:
1944                tree.insert_leaf(leaf)
1945
1946            response_data = self.from_group_data(fields, tree)
1947        else:
1948            Model = Model.with_context(import_compat=import_compat)
1949            records = Model.browse(ids) if ids else Model.search(domain, offset=0, limit=False, order=False)
1950
1951            export_data = records.export_data(field_names).get('datas',[])
1952            response_data = self.from_data(columns_headers, export_data)
1953
1954        return request.make_response(response_data,
1955            headers=[('Content-Disposition',
1956                            content_disposition(self.filename(model))),
1957                     ('Content-Type', self.content_type)],
1958            cookies={'fileToken': token})
1959
1960class CSVExport(ExportFormat, http.Controller):
1961
1962    @http.route('/web/export/csv', type='http', auth="user")
1963    @serialize_exception
1964    def index(self, data, token):
1965        return self.base(data, token)
1966
1967    @property
1968    def content_type(self):
1969        return 'text/csv;charset=utf8'
1970
1971    def filename(self, base):
1972        return base + '.csv'
1973
1974    def from_group_data(self, fields, groups):
1975        raise UserError(_("Exporting grouped data to csv is not supported."))
1976
1977    def from_data(self, fields, rows):
1978        fp = io.BytesIO()
1979        writer = pycompat.csv_writer(fp, quoting=1)
1980
1981        writer.writerow(fields)
1982
1983        for data in rows:
1984            row = []
1985            for d in data:
1986                # Spreadsheet apps tend to detect formulas on leading =, + and -
1987                if isinstance(d, str) and d.startswith(('=', '-', '+')):
1988                    d = "'" + d
1989
1990                row.append(pycompat.to_text(d))
1991            writer.writerow(row)
1992
1993        return fp.getvalue()
1994
1995class ExcelExport(ExportFormat, http.Controller):
1996
1997    @http.route('/web/export/xlsx', type='http', auth="user")
1998    @serialize_exception
1999    def index(self, data, token):
2000        return self.base(data, token)
2001
2002    @property
2003    def content_type(self):
2004        return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
2005
2006    def filename(self, base):
2007        return base + '.xlsx'
2008
2009    def from_group_data(self, fields, groups):
2010        with GroupExportXlsxWriter(fields, groups.count) as xlsx_writer:
2011            x, y = 1, 0
2012            for group_name, group in groups.children.items():
2013                x, y = xlsx_writer.write_group(x, y, group_name, group)
2014
2015        return xlsx_writer.value
2016
2017    def from_data(self, fields, rows):
2018        with ExportXlsxWriter(fields, len(rows)) as xlsx_writer:
2019            for row_index, row in enumerate(rows):
2020                for cell_index, cell_value in enumerate(row):
2021                    if isinstance(cell_value, (list, tuple)):
2022                        cell_value = pycompat.to_text(cell_value)
2023                    xlsx_writer.write_cell(row_index + 1, cell_index, cell_value)
2024
2025        return xlsx_writer.value
2026
2027
2028class ReportController(http.Controller):
2029
2030    #------------------------------------------------------
2031    # Report controllers
2032    #------------------------------------------------------
2033    @http.route([
2034        '/report/<converter>/<reportname>',
2035        '/report/<converter>/<reportname>/<docids>',
2036    ], type='http', auth='user', website=True)
2037    def report_routes(self, reportname, docids=None, converter=None, **data):
2038        report = request.env['ir.actions.report']._get_report_from_name(reportname)
2039        context = dict(request.env.context)
2040
2041        if docids:
2042            docids = [int(i) for i in docids.split(',')]
2043        if data.get('options'):
2044            data.update(json.loads(data.pop('options')))
2045        if data.get('context'):
2046            # Ignore 'lang' here, because the context in data is the one from the webclient *but* if
2047            # the user explicitely wants to change the lang, this mechanism overwrites it.
2048            data['context'] = json.loads(data['context'])
2049            if data['context'].get('lang') and not data.get('force_context_lang'):
2050                del data['context']['lang']
2051            context.update(data['context'])
2052        if converter == 'html':
2053            html = report.with_context(context)._render_qweb_html(docids, data=data)[0]
2054            return request.make_response(html)
2055        elif converter == 'pdf':
2056            pdf = report.with_context(context)._render_qweb_pdf(docids, data=data)[0]
2057            pdfhttpheaders = [('Content-Type', 'application/pdf'), ('Content-Length', len(pdf))]
2058            return request.make_response(pdf, headers=pdfhttpheaders)
2059        elif converter == 'text':
2060            text = report.with_context(context)._render_qweb_text(docids, data=data)[0]
2061            texthttpheaders = [('Content-Type', 'text/plain'), ('Content-Length', len(text))]
2062            return request.make_response(text, headers=texthttpheaders)
2063        else:
2064            raise werkzeug.exceptions.HTTPException(description='Converter %s not implemented.' % converter)
2065
2066    #------------------------------------------------------
2067    # Misc. route utils
2068    #------------------------------------------------------
2069    @http.route(['/report/barcode', '/report/barcode/<type>/<path:value>'], type='http', auth="public")
2070    def report_barcode(self, type, value, width=600, height=100, humanreadable=0, quiet=1, mask=None):
2071        """Contoller able to render barcode images thanks to reportlab.
2072        Samples:
2073            <img t-att-src="'/report/barcode/QR/%s' % o.name"/>
2074            <img t-att-src="'/report/barcode/?type=%s&amp;value=%s&amp;width=%s&amp;height=%s' %
2075                ('QR', o.name, 200, 200)"/>
2076
2077        :param type: Accepted types: 'Codabar', 'Code11', 'Code128', 'EAN13', 'EAN8', 'Extended39',
2078        'Extended93', 'FIM', 'I2of5', 'MSI', 'POSTNET', 'QR', 'Standard39', 'Standard93',
2079        'UPCA', 'USPS_4State'
2080        :param humanreadable: Accepted values: 0 (default) or 1. 1 will insert the readable value
2081        at the bottom of the output image
2082        :param quiet: Accepted values: 0 (default) or 1. 1 will display white
2083        margins on left and right.
2084        :param mask: The mask code to be used when rendering this QR-code.
2085                     Masks allow adding elements on top of the generated image,
2086                     such as the Swiss cross in the center of QR-bill codes.
2087        """
2088        try:
2089            barcode = request.env['ir.actions.report'].barcode(type, value, width=width,
2090                height=height, humanreadable=humanreadable, quiet=quiet, mask=mask)
2091        except (ValueError, AttributeError):
2092            raise werkzeug.exceptions.HTTPException(description='Cannot convert into barcode.')
2093
2094        return request.make_response(barcode, headers=[('Content-Type', 'image/png')])
2095
2096    @http.route(['/report/download'], type='http', auth="user")
2097    def report_download(self, data, token, context=None):
2098        """This function is used by 'action_manager_report.js' in order to trigger the download of
2099        a pdf/controller report.
2100
2101        :param data: a javascript array JSON.stringified containg report internal url ([0]) and
2102        type [1]
2103        :returns: Response with a filetoken cookie and an attachment header
2104        """
2105        requestcontent = json.loads(data)
2106        url, type = requestcontent[0], requestcontent[1]
2107        try:
2108            if type in ['qweb-pdf', 'qweb-text']:
2109                converter = 'pdf' if type == 'qweb-pdf' else 'text'
2110                extension = 'pdf' if type == 'qweb-pdf' else 'txt'
2111
2112                pattern = '/report/pdf/' if type == 'qweb-pdf' else '/report/text/'
2113                reportname = url.split(pattern)[1].split('?')[0]
2114
2115                docids = None
2116                if '/' in reportname:
2117                    reportname, docids = reportname.split('/')
2118
2119                if docids:
2120                    # Generic report:
2121                    response = self.report_routes(reportname, docids=docids, converter=converter, context=context)
2122                else:
2123                    # Particular report:
2124                    data = dict(url_decode(url.split('?')[1]).items())  # decoding the args represented in JSON
2125                    if 'context' in data:
2126                        context, data_context = json.loads(context or '{}'), json.loads(data.pop('context'))
2127                        context = json.dumps({**context, **data_context})
2128                    response = self.report_routes(reportname, converter=converter, context=context, **data)
2129
2130                report = request.env['ir.actions.report']._get_report_from_name(reportname)
2131                filename = "%s.%s" % (report.name, extension)
2132
2133                if docids:
2134                    ids = [int(x) for x in docids.split(",")]
2135                    obj = request.env[report.model].browse(ids)
2136                    if report.print_report_name and not len(obj) > 1:
2137                        report_name = safe_eval(report.print_report_name, {'object': obj, 'time': time})
2138                        filename = "%s.%s" % (report_name, extension)
2139                response.headers.add('Content-Disposition', content_disposition(filename))
2140                response.set_cookie('fileToken', token)
2141                return response
2142            else:
2143                return
2144        except Exception as e:
2145            se = _serialize_exception(e)
2146            error = {
2147                'code': 200,
2148                'message': "Odoo Server Error",
2149                'data': se
2150            }
2151            return request.make_response(html_escape(json.dumps(error)))
2152
2153    @http.route(['/report/check_wkhtmltopdf'], type='json', auth="user")
2154    def check_wkhtmltopdf(self):
2155        return request.env['ir.actions.report'].get_wkhtmltopdf_state()
2156