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&value=%s&width=%s&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