diff --git a/.gitignore b/.gitignore index 3ad0a867b8ffd..71412d0ba8240 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,5 @@ setup/win32/static/postgresql*.exe /man/ /share/ /src/ +/odoo.conf +/dev/ diff --git a/addons/auth_signup/controllers/main.py b/addons/auth_signup/controllers/main.py index c2e8a6fc91edf..8588d6073246f 100644 --- a/addons/auth_signup/controllers/main.py +++ b/addons/auth_signup/controllers/main.py @@ -55,7 +55,7 @@ def web_auth_signup(self, *args, **kw): qcontext['error'] = _("Could not create a new account.") response = request.render('auth_signup.signup', qcontext) - response.headers['X-Frame-Options'] = 'DENY' + # response.headers['X-Frame-Options'] = 'DENY' return response @http.route('/web/reset_password', type='http', auth='public', website=True, sitemap=False) @@ -87,7 +87,7 @@ def web_auth_reset_password(self, *args, **kw): qcontext['error'] = str(e) response = request.render('auth_signup.reset_password', qcontext) - response.headers['X-Frame-Options'] = 'DENY' + # response.headers['X-Frame-Options'] = 'DENY' return response def get_auth_signup_config(self): diff --git a/addons/bus/models/bus.py b/addons/bus/models/bus.py index b88a833601bce..2cffbf491a68c 100644 --- a/addons/bus/models/bus.py +++ b/addons/bus/models/bus.py @@ -110,7 +110,7 @@ def poll(self, dbname, channels, last, options=None, timeout=TIMEOUT): current = threading.current_thread() current._daemonic = True # rename the thread to avoid tests waiting for a longpolling - current.setName("openerp.longpolling.request.%s" % current.ident) + current.name = f"openerp.longpolling.request.{current.ident}" registry = odoo.registry(dbname) diff --git a/addons/crm/models/crm_lead.py b/addons/crm/models/crm_lead.py index 025f793e3f2ee..1bb33f03506c4 100644 --- a/addons/crm/models/crm_lead.py +++ b/addons/crm/models/crm_lead.py @@ -1850,7 +1850,7 @@ def _update_automated_probabilities(self): # - avoid blocking the table for too long with a too big transaction transactions_count, transactions_failed_count = 0, 0 cron_update_lead_start_date = datetime.now() - auto_commit = not getattr(threading.currentThread(), 'testing', False) + auto_commit = not getattr(threading.current_thread(), 'testing', False) for probability, probability_lead_ids in probability_leads.items(): for lead_ids_current in tools.split_every(PLS_UPDATE_BATCH_STEP, probability_lead_ids): transactions_count += 1 diff --git a/addons/event/models/event_mail.py b/addons/event/models/event_mail.py index ae812e3907502..843b761b8eee2 100644 --- a/addons/event/models/event_mail.py +++ b/addons/event/models/event_mail.py @@ -179,7 +179,7 @@ def run(self, autocommit=False): self.invalidate_cache() self._warn_template_error(scheduler, e) else: - if autocommit and not getattr(threading.currentThread(), 'testing', False): + if autocommit and not getattr(threading.current_thread(), 'testing', False): self.env.cr.commit() return True diff --git a/addons/http_routing/models/ir_http.py b/addons/http_routing/models/ir_http.py index 96127e5636740..39e6ec129e1f6 100644 --- a/addons/http_routing/models/ir_http.py +++ b/addons/http_routing/models/ir_http.py @@ -17,7 +17,8 @@ import odoo from odoo import api, models, registry, exceptions, tools, http -from odoo.addons.base.models.ir_http import RequestUID, ModelConverter +from odoo.addons.base.models import ir_http +from odoo.addons.base.models.ir_http import RequestUID from odoo.addons.base.models.qweb import QWebException from odoo.http import request from odoo.osv import expression @@ -155,9 +156,9 @@ def url_lang(path_or_uri, lang_code=None): lang_code = pycompat.to_text(lang_code or request.context['lang']) lang_url_code = Lang._lang_code_to_urlcode(lang_code) lang_url_code = lang_url_code if lang_url_code in lang_url_codes else lang_code - if (len(lang_url_codes) > 1 or force_lang) and is_multilang_url(location, lang_url_codes): - ps = location.split(u'/') + loc, sep, qs = location.partition('?') + ps = loc.split(u'/') default_lg = request.env['ir.http']._get_default_lang() if ps[1] in lang_url_codes: # Replace the language only if we explicitly provide a language to url_for @@ -169,7 +170,8 @@ def url_lang(path_or_uri, lang_code=None): # Insert the context language or the provided language elif lang_url_code != default_lg.url_code or force_lang: ps.insert(1, lang_url_code) - location = u'/'.join(ps) + + location = u'/'.join(ps) + sep + qs return location @@ -197,7 +199,7 @@ def url_for(url_from, lang_code=None, no_rewrite=False): and '/static/' not in path and not path.startswith('/web/') )): - new_url = request.env['ir.http'].url_rewrite(path) + new_url, _ = request.env['ir.http'].url_rewrite(path) new_url = new_url if not qs else new_url + '?%s' % qs return url_lang(new_url or url_from, lang_code=lang_code) @@ -229,7 +231,8 @@ def is_multilang_url(local_url, lang_url_codes=None): # Try to match an endpoint in werkzeug's routing table try: - func = request.env['ir.http']._get_endpoint_qargs(path, query_args=query_string) + _, func = request.env['ir.http'].url_rewrite(path, query_args=query_string) + # /page/xxx has no endpoint/func but is multilang return (not func or ( func.routing.get('website', False) @@ -240,7 +243,7 @@ def is_multilang_url(local_url, lang_url_codes=None): return False -class ModelConverter(ModelConverter): +class ModelConverter(ir_http.ModelConverter): def __init__(self, url_map, model=False, domain='[]'): super(ModelConverter, self).__init__(url_map, model) @@ -425,7 +428,7 @@ def _dispatch(cls): # handle // in url if request.httprequest.method == 'GET' and '//' in request.httprequest.path: new_url = request.httprequest.path.replace('//', '/') + '?' + request.httprequest.query_string.decode('utf-8') - return werkzeug.utils.redirect(new_url, 301) + return request.redirect(new_url, code=301) # locate the controller method try: @@ -439,7 +442,7 @@ def _dispatch(cls): # most of the time the browser is loading and inexisting assets or image. A standard 404 is enough. # Earlier check would be difficult since we don't want to break data modules path_components = request.httprequest.path.split('/') - request.is_frontend = len(path_components) < 3 or path_components[2] != 'static' or not '.' in path_components[-1] + request.is_frontend = len(path_components) < 3 or path_components[2] != 'static' or '.' not in path_components[-1] routing_error = e request.is_frontend_multilang = not func or (func and request.is_frontend and func.routing.get('multilang', func.routing['type'] == 'http')) @@ -458,8 +461,6 @@ def _dispatch(cls): # For website routes (only), add website params on `request` if request.is_frontend: - request.redirect = lambda url, code=302: werkzeug.utils.redirect(url_for(url), code) - cls._add_dispatch_parameters(func) path = request.httprequest.path.split('/') @@ -490,6 +491,12 @@ def _dispatch(cls): return redirect elif url_lg: request.uid = None + if request.httprequest.path == '/%s/' % url_lg: + # special case for homepage controller, mimick `_postprocess_args()` redirect + path = request.httprequest.path[:-1] + if request.httprequest.query_string: + path += '?' + request.httprequest.query_string.decode('utf-8') + return request.redirect(path, code=301) path.pop(1) routing_error = None return cls.reroute('/'.join(path) or '/') @@ -512,11 +519,17 @@ def _dispatch(cls): result = super(IrHttp, cls)._dispatch() cook_lang = request.httprequest.cookies.get('frontend_lang') - if request.is_frontend and cook_lang != request.lang.code and hasattr(result, 'set_cookie'): - result.set_cookie('frontend_lang', request.lang.code) + if request.is_frontend and cook_lang != request.lang._get_cached('code') and hasattr(result, 'set_cookie'): + result.set_cookie('frontend_lang', request.lang._get_cached('code')) return result + @classmethod + def _redirect(cls, location, code=303): + if request and request.db and getattr(request, 'is_frontend', False): + location = url_for(location) + return super()._redirect(location, code) + @classmethod def reroute(cls, path): if not hasattr(request, 'rerouting'): @@ -528,8 +541,13 @@ def reroute(cls, path): raise Exception("Rerouting limit exceeded") request.httprequest.environ['PATH_INFO'] = path # void werkzeug cached_property. TODO: find a proper way to do this - for key in ('path', 'full_path', 'url', 'base_url'): + for key in ('full_path', 'url', 'base_url'): request.httprequest.__dict__.pop(key, None) + # since werkzeug 2.0 `path`` became an attribute and is not a cached property anymore + if hasattr(type(request.httprequest), 'path'): # cached property + request.httprequest.__dict__.pop('path', None) + else: # direct attribute + request.httprequest.path = '/' + path.lstrip('/') return cls._dispatch() @@ -553,7 +571,7 @@ def _postprocess_args(cls, arguments, rule): path = '/' + request.lang.url_code + path if request.httprequest.query_string: path += '?' + request.httprequest.query_string.decode('utf-8') - return werkzeug.utils.redirect(path, code=301) + return request.redirect(path, code=301) @classmethod def _get_exception_code_values(cls, exception): @@ -654,28 +672,9 @@ def _handle_exception(cls, exception): return werkzeug.wrappers.Response(html, status=code, content_type='text/html;charset=utf-8') @api.model - @tools.ormcache('path') - def url_rewrite(self, path): + @tools.ormcache('path', 'query_args') + def url_rewrite(self, path, query_args=None): new_url = False - router = http.root.get_db_router(request.db).bind('') - try: - _ = router.match(path, method='POST') - except werkzeug.exceptions.MethodNotAllowed: - _ = router.match(path, method='GET') - except werkzeug.routing.RequestRedirect as e: - # get path from http://{path}?{current query string} - new_url = e.new_url.split('?')[0][7:] - except werkzeug.exceptions.NotFound: - new_url = path - except Exception as e: - raise e - - return new_url or path - - # merge with def url_rewrite in master/14.1 - @api.model - @tools.cache('path', 'query_args') - def _get_endpoint_qargs(self, path, query_args=None): router = http.root.get_db_router(request.db).bind('') endpoint = False try: @@ -683,10 +682,10 @@ def _get_endpoint_qargs(self, path, query_args=None): except werkzeug.exceptions.MethodNotAllowed: endpoint = router.match(path, method='GET', query_args=query_args) except werkzeug.routing.RequestRedirect as e: - new_url = e.new_url[7:] # remove scheme - assert new_url != path - endpoint = self._get_endpoint_qargs(new_url, query_args) + # get path from http://{path}?{current query string} + new_url = e.new_url.split('?')[0][7:] + _, endpoint = self.url_rewrite(new_url, query_args) endpoint = endpoint and [endpoint] except werkzeug.exceptions.NotFound: - pass # endpoint = False - return endpoint and endpoint[0] + new_url = path + return new_url or path, endpoint and endpoint[0] diff --git a/addons/hw_drivers/tools/helpers.py b/addons/hw_drivers/tools/helpers.py index cf4a67b2770cd..796d44bd53b85 100644 --- a/addons/hw_drivers/tools/helpers.py +++ b/addons/hw_drivers/tools/helpers.py @@ -200,7 +200,7 @@ def load_certificate(): db_uuid = read_file_first_line('odoo-db-uuid.conf') enterprise_code = read_file_first_line('odoo-enterprise-code.conf') if db_uuid and enterprise_code: - url = 'https://www.odoo.com/odoo-enterprise/iot/x509' + url = 'https://www.odoochain.com/odoochain-enterprise/iot/x509' data = { 'params': { 'db_uuid': db_uuid, diff --git a/addons/l10n_cn/data/account_tax_group_data.xml b/addons/l10n_cn/data/account_tax_group_data.xml index 47f108bf67d7f..58f3c7a969f2f 100644 --- a/addons/l10n_cn/data/account_tax_group_data.xml +++ b/addons/l10n_cn/data/account_tax_group_data.xml @@ -1,12 +1,24 @@ + + VAT 3% + VAT 6% VAT 9% + + VAT 11% + VAT 13% + + VAT 16% + + + VAT 17% + diff --git a/addons/l10n_cn_city/__manifest__.py b/addons/l10n_cn_city/__manifest__.py index 44f28990261dc..fe884ecbae1b5 100644 --- a/addons/l10n_cn_city/__manifest__.py +++ b/addons/l10n_cn_city/__manifest__.py @@ -14,7 +14,7 @@ City Data/城市数据 """, - 'depends': ['l10n_cn','base_address_city'], + 'depends': ['base_address_city'], 'data': [ 'data/res_city_data.xml', ], diff --git a/addons/link_tracker/models/mail_render_mixin.py b/addons/link_tracker/models/mail_render_mixin.py index 3fa3159f361b0..21ba81f4d90a4 100644 --- a/addons/link_tracker/models/mail_render_mixin.py +++ b/addons/link_tracker/models/mail_render_mixin.py @@ -3,7 +3,8 @@ import re -from werkzeug import urls, utils +from html import unescape +from werkzeug import urls from odoo import api, models, tools @@ -40,7 +41,7 @@ def _shorten_links(self, html, link_tracker_vals, blacklist=None, base_url=None) label = (match[3] or '').strip() if not blacklist or not [s for s in blacklist if s in long_url] and not long_url.startswith(short_schema): - create_vals = dict(link_tracker_vals, url=utils.unescape(long_url), label=utils.unescape(label)) + create_vals = dict(link_tracker_vals, url=unescape(long_url), label=unescape(label)) link = self.env['link.tracker'].create(create_vals) if link.short_url: new_href = href.replace(long_url, link.short_url) @@ -69,7 +70,7 @@ def _shorten_links_text(self, content, link_tracker_vals, blacklist=None, base_u if blacklist and any(item in parsed.path for item in blacklist): continue - create_vals = dict(link_tracker_vals, url= utils.unescape(original_url)) + create_vals = dict(link_tracker_vals, url=unescape(original_url)) link = self.env['link.tracker'].create(create_vals) if link.short_url: content = content.replace(original_url, link.short_url, 1) diff --git a/addons/mail/models/mail_mail.py b/addons/mail/models/mail_mail.py index 80d1ece80f38f..3e042b0879238 100644 --- a/addons/mail/models/mail_mail.py +++ b/addons/mail/models/mail_mail.py @@ -141,7 +141,7 @@ def process_email_queue(self, ids=None): res = None try: # auto-commit except in testing mode - auto_commit = not getattr(threading.currentThread(), 'testing', False) + auto_commit = not getattr(threading.current_thread(), 'testing', False) res = self.browse(ids).send(auto_commit=auto_commit) except Exception: _logger.exception("Failed processing mail queue") diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py index 70db999886326..9cf9e3b309582 100644 --- a/addons/mail/models/mail_thread.py +++ b/addons/mail/models/mail_thread.py @@ -2346,7 +2346,7 @@ def _notify_record_by_email(self, message, recipients_data, msg_vals=False, # 2. do not send emails immediately if the registry is not loaded, # to prevent sending email during a simple update of the database # using the command-line. - test_mode = getattr(threading.currentThread(), 'testing', False) + test_mode = getattr(threading.current_thread(), 'testing', False) if force_send and len(emails) < recipients_max and (not self.pool._init or test_mode): # unless asked specifically, send emails after the transaction to # avoid side effects due to emails being sent while the transaction fails diff --git a/addons/mass_mailing/models/mailing.py b/addons/mass_mailing/models/mailing.py index c052028f484cf..615a94c4d62d7 100644 --- a/addons/mass_mailing/models/mailing.py +++ b/addons/mass_mailing/models/mailing.py @@ -615,7 +615,7 @@ def action_send_mail(self, res_ids=None): extra_context = mailing._get_mass_mailing_context() composer = composer.with_context(active_ids=res_ids, **extra_context) # auto-commit except in testing mode - auto_commit = not getattr(threading.currentThread(), 'testing', False) + auto_commit = not getattr(threading.current_thread(), 'testing', False) composer.send_mail(auto_commit=auto_commit) mailing.write({ 'state': 'done', diff --git a/addons/pad/models/pad.py b/addons/pad/models/pad.py index 03db10759ac79..bba8cee013f6d 100644 --- a/addons/pad/models/pad.py +++ b/addons/pad/models/pad.py @@ -88,6 +88,7 @@ def pad_get_content(self, url): path = len(split_url) == 2 and split_url[1] try: content = myPad.getHtml(path).get('html', '') + # todo pad跨域和不显示问题 except IOError: _logger.warning('Http Error: the credentials might be absent for url: "%s". Falling back.' % url) try: diff --git a/addons/portal/controllers/portal.py b/addons/portal/controllers/portal.py index 47394d900b36b..8ac4befc69733 100644 --- a/addons/portal/controllers/portal.py +++ b/addons/portal/controllers/portal.py @@ -214,7 +214,7 @@ def account(self, redirect=None, **post): }) response = request.render("portal.portal_my_details", values) - response.headers['X-Frame-Options'] = 'DENY' + # response.headers['X-Frame-Options'] = 'DENY' return response @route('/my/security', type='http', auth='user', website=True, methods=['GET', 'POST']) diff --git a/addons/sale_timesheet/models/product.py b/addons/sale_timesheet/models/product.py index f47a5dd9b8645..a37f6643f3411 100644 --- a/addons/sale_timesheet/models/product.py +++ b/addons/sale_timesheet/models/product.py @@ -98,7 +98,7 @@ def unlink(self): def write(self, vals): # timesheet product can't be archived - test_mode = getattr(threading.currentThread(), 'testing', False) or self.env.registry.in_test_mode() + test_mode = getattr(threading.current_thread(), 'testing', False) or self.env.registry.in_test_mode() if not test_mode and 'active' in vals and not vals['active']: time_product = self.env.ref('sale_timesheet.time_product') if time_product.product_tmpl_id in self: @@ -143,7 +143,7 @@ def unlink(self): def write(self, vals): # timesheet product can't be archived - test_mode = getattr(threading.currentThread(), 'testing', False) or self.env.registry.in_test_mode() + test_mode = getattr(threading.current_thread(), 'testing', False) or self.env.registry.in_test_mode() if not test_mode and 'active' in vals and not vals['active']: time_product = self.env.ref('sale_timesheet.time_product') if time_product in self: diff --git a/addons/sms/models/sms_sms.py b/addons/sms/models/sms_sms.py index a1c197a6cb159..1991d04c0a62f 100644 --- a/addons/sms/models/sms_sms.py +++ b/addons/sms/models/sms_sms.py @@ -55,7 +55,7 @@ def send(self, delete_all=False, auto_commit=False, raise_exception=False): for batch_ids in self._split_batch(): self.browse(batch_ids)._send(delete_all=delete_all, raise_exception=raise_exception) # auto-commit if asked except in testing mode - if auto_commit is True and not getattr(threading.currentThread(), 'testing', False): + if auto_commit is True and not getattr(threading.current_thread(), 'testing', False): self._cr.commit() def cancel(self): @@ -81,7 +81,7 @@ def _process_queue(self, ids=None): res = None try: # auto-commit except in testing mode - auto_commit = not getattr(threading.currentThread(), 'testing', False) + auto_commit = not getattr(threading.current_thread(), 'testing', False) res = self.browse(ids).send(delete_all=False, auto_commit=auto_commit, raise_exception=False) except Exception: _logger.exception("Failed processing SMS queue") diff --git a/addons/stock/__manifest__.py b/addons/stock/__manifest__.py index 79612099e7750..58b9ab7032ae6 100644 --- a/addons/stock/__manifest__.py +++ b/addons/stock/__manifest__.py @@ -11,13 +11,13 @@ 'category': 'Inventory/Inventory', 'sequence': 25, 'demo': [ - 'data/stock_demo_pre.xml', - 'data/procurement_demo.xml', - 'data/stock_demo.xml', - 'data/stock_orderpoint_demo.xml', - 'data/stock_demo2.xml', - 'data/stock_location_demo_cpu1.xml', - 'data/stock_location_demo_cpu3.xml', + # 'data/stock_demo_pre.xml', + # 'data/procurement_demo.xml', + # 'data/stock_demo.xml', + # 'data/stock_orderpoint_demo.xml', + # 'data/stock_demo2.xml', + # 'data/stock_location_demo_cpu1.xml', + # 'data/stock_location_demo_cpu3.xml', ], 'data': [ 'security/stock_security.xml', diff --git a/addons/stock_sms/models/stock_picking.py b/addons/stock_sms/models/stock_picking.py index 846af19271fe5..554a160fa56d9 100644 --- a/addons/stock_sms/models/stock_picking.py +++ b/addons/stock_sms/models/stock_picking.py @@ -23,7 +23,7 @@ def _check_warn_sms(self): is_delivery = picking.company_id.stock_move_sms_validation \ and picking.picking_type_id.code == 'outgoing' \ and (picking.partner_id.mobile or picking.partner_id.phone) - if is_delivery and not getattr(threading.currentThread(), 'testing', False) \ + if is_delivery and not getattr(threading.current_thread(), 'testing', False) \ and not self.env.registry.in_test_mode() \ and not picking.company_id.has_received_warning_stock_sms \ and picking.company_id.stock_move_sms_validation: @@ -52,7 +52,7 @@ def _sms_get_number_fields(self): def _send_confirmation_email(self): super(Picking, self)._send_confirmation_email() - if not self.env.context.get('skip_sms') and not getattr(threading.currentThread(), 'testing', False) and not self.env.registry.in_test_mode(): + if not self.env.context.get('skip_sms') and not getattr(threading.current_thread(), 'testing', False) and not self.env.registry.in_test_mode(): pickings = self.filtered(lambda p: p.company_id.stock_move_sms_validation and p.picking_type_id.code == 'outgoing' and (p.partner_id.mobile or p.partner_id.phone)) for picking in pickings: # Sudo as the user has not always the right to read this sms template. diff --git a/addons/web/controllers/__init__.py b/addons/web/controllers/__init__.py index 48cc37266c608..d38dcc630d8bc 100644 --- a/addons/web/controllers/__init__.py +++ b/addons/web/controllers/__init__.py @@ -1,4 +1,6 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from . import main, pivot +from . import main +from . import pivot +from . import profiling diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py index bcac8f45e1e67..db4a1169ef408 100644 --- a/addons/web/controllers/main.py +++ b/addons/web/controllers/main.py @@ -18,7 +18,10 @@ import re import sys import tempfile +import unicodedata +from collections import OrderedDict, defaultdict +import babel.messages.pofile import werkzeug import werkzeug.exceptions import werkzeug.utils @@ -889,16 +892,16 @@ class Home(http.Controller): @http.route('/', type='http', auth="none") def index(self, s_action=None, db=None, **kw): - return http.local_redirect('/web', query=request.params, keep_hash=True) + return request.redirect_query('/web', query=request.params) # ideally, this route should be `auth="user"` but that don't work in non-monodb mode. @http.route('/web', type='http', auth="none") def web_client(self, s_action=None, **kw): ensure_db() if not request.session.uid: - return werkzeug.utils.redirect('/web/login', 303) + return request.redirect('/web/login', 303) if kw.get('redirect'): - return werkzeug.utils.redirect(kw.get('redirect'), 303) + return request.redirect(kw.get('redirect'), 303) request.uid = request.session.uid try: @@ -907,7 +910,7 @@ def web_client(self, s_action=None, **kw): response.headers['X-Frame-Options'] = 'DENY' return response except AccessError: - return werkzeug.utils.redirect('/web/login?error=access') + return request.redirect('/web/login?error=access') @http.route('/web/webclient/load_menus/', type='http', auth='user', methods=['GET']) def web_load_menus(self, unique): @@ -916,7 +919,7 @@ def web_load_menus(self, unique): :param unique: this parameters is not used, but mandatory: it is used by the HTTP stack to make a unique request :return: the menus (including the images in Base64) """ - menus = request.env["ir.ui.menu"].load_menus(request.session.debug) + menus = request.env["ir.ui.menu"].load_web_menus(request.session.debug) body = json.dumps(menus, default=ustr) response = request.make_response(body, [ # this method must specify a content-type application/json instead of using the default text/html set because @@ -934,7 +937,7 @@ def web_login(self, redirect=None, **kw): ensure_db() request.params['login_success'] = False if request.httprequest.method == 'GET' and redirect and request.session.uid: - return http.redirect_with_hash(redirect) + return request.redirect(redirect) if not request.uid: request.uid = odoo.SUPERUSER_ID @@ -950,7 +953,7 @@ def web_login(self, redirect=None, **kw): try: uid = request.session.authenticate(request.session.db, request.params['login'], request.params['password']) request.params['login_success'] = True - return http.redirect_with_hash(self._login_redirect(uid, redirect=redirect)) + return request.redirect(self._login_redirect(uid, redirect=redirect)) except odoo.exceptions.AccessDenied as e: request.uid = old_uid if e.args == odoo.exceptions.AccessDenied().args: @@ -980,7 +983,17 @@ def switch_to_admin(self): request.env['res.users'].clear_caches() request.session.session_token = security.compute_session_token(request.session, request.env) - return http.local_redirect(self._login_redirect(uid), keep_hash=True) + return request.redirect(self._login_redirect(uid)) + + @http.route('/web/health', type='http', auth='none', save_session=False) + def health(self): + data = json.dumps({ + 'status': 'pass', + }) + headers = [('Content-Type', 'application/json'), + ('Cache-Control', 'no-store')] + return request.make_response(data, headers) + class WebClient(http.Controller): diff --git a/addons/web/controllers/profiling.py b/addons/web/controllers/profiling.py new file mode 100644 index 0000000000000..b320ee0cfba4e --- /dev/null +++ b/addons/web/controllers/profiling.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +import json + +from odoo.exceptions import UserError +from odoo.http import Controller, request, Response, route + +class Profiling(Controller): + + @route('/web/set_profiling', type='http', auth='public', sitemap=False) + def profile(self, profile=None, collectors=None, **params): + if collectors is not None: + collectors = collectors.split(',') + else: + collectors = ['sql', 'traces_async'] + profile = profile and profile != '0' + try: + state = request.env['ir.profile'].set_profiling(profile, collectors=collectors, params=params) + return json.dumps(state) + except UserError as e: + return Response(response='error: %s' % e, status=500) + + @route(['/web/speedscope', '/web/speedscope/'], type='http', sitemap=False, auth='user') + def speedscope(self, profile=None): + # don't server speedscope index if profiling is not enabled + if not request.env['ir.profile']._enabled_until(): + return request.not_found() + icp = request.env['ir.config_parameter'] + context = { + 'profile': profile, + 'url_root': request.httprequest.url_root, + 'cdn': icp.sudo().get_param('speedscope_cdn', "https://cdn.jsdelivr.net/npm/speedscope@1.13.0/dist/release/") + } + return request.render('web.view_speedscope_index', context) diff --git a/addons/web/static/src/js/fields/upgrade_fields.js b/addons/web/static/src/js/fields/upgrade_fields.js index 36af39567acf9..d6bc186ff75e6 100644 --- a/addons/web/static/src/js/fields/upgrade_fields.js +++ b/addons/web/static/src/js/fields/upgrade_fields.js @@ -43,7 +43,7 @@ var AbstractFieldUpgrade = { args: [[["share", "=", false]]], }) .then(function (data) { - framework.redirect("https://www.odoo.com/odoo-enterprise/upgrade?num_users=" + data); + framework.redirect("https://www.odoochain.com/odoochain-enterprise/upgrade?num_users=" + data); }); }, /** diff --git a/addons/website/controllers/__init__.py b/addons/website/controllers/__init__.py index 5ee7aaab8f264..12c98dba76637 100644 --- a/addons/website/controllers/__init__.py +++ b/addons/website/controllers/__init__.py @@ -2,4 +2,5 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import backend +from . import form from . import main diff --git a/addons/website/controllers/form.py b/addons/website/controllers/form.py new file mode 100644 index 0000000000000..c7f802efaecb3 --- /dev/null +++ b/addons/website/controllers/form.py @@ -0,0 +1,283 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import base64 +import json + +from psycopg2 import IntegrityError +from werkzeug.exceptions import BadRequest + +from odoo import http, SUPERUSER_ID, _ +from odoo.http import request +from odoo.tools import plaintext2html +from odoo.exceptions import ValidationError, UserError +from odoo.addons.base.models.ir_qweb_fields import nl2br + + +class WebsiteForm(http.Controller): + + @http.route('/website/form', type='http', auth="public", methods=['POST'], multilang=False) + def website_form_empty(self, **kwargs): + # This is a workaround to don't add language prefix to
+ return "" + + # Check and insert values from the form on the model + @http.route('/website/form/', type='http', auth="public", methods=['POST'], website=True, csrf=False) + def website_form(self, model_name, **kwargs): + # Partial CSRF check, only performed when session is authenticated, as there + # is no real risk for unauthenticated sessions here. It's a common case for + # embedded forms now: SameSite policy rejects the cookies, so the session + # is lost, and the CSRF check fails, breaking the post for no good reason. + csrf_token = request.params.pop('csrf_token', None) + if request.session.uid and not request.validate_csrf(csrf_token): + raise BadRequest('Session expired (invalid CSRF token)') + + try: + # The except clause below should not let what has been done inside + # here be committed. It should not either roll back everything in + # this controller method. Instead, we use a savepoint to roll back + # what has been done inside the try clause. + with request.env.cr.savepoint(): + if request.env['ir.http']._verify_request_recaptcha_token('website_form'): + return self._handle_website_form(model_name, **kwargs) + error = _("Suspicious activity detected by Google reCaptcha.") + except (ValidationError, UserError) as e: + error = e.args[0] + return json.dumps({ + 'error': error, + }) + + def _handle_website_form(self, model_name, **kwargs): + model_record = request.env['ir.model'].sudo().search([('model', '=', model_name), ('website_form_access', '=', True)]) + if not model_record: + return json.dumps({ + 'error': _("The form's specified model does not exist") + }) + + try: + data = self.extract_data(model_record, request.params) + # If we encounter an issue while extracting data + except ValidationError as e: + # I couldn't find a cleaner way to pass data to an exception + return json.dumps({'error_fields' : e.args[0]}) + + try: + id_record = self.insert_record(request, model_record, data['record'], data['custom'], data.get('meta')) + if id_record: + self.insert_attachment(model_record, id_record, data['attachments']) + # in case of an email, we want to send it immediately instead of waiting + # for the email queue to process + if model_name == 'mail.mail': + request.env[model_name].sudo().browse(id_record).send() + + # Some fields have additional SQL constraints that we can't check generically + # Ex: crm.lead.probability which is a float between 0 and 1 + # TODO: How to get the name of the erroneous field ? + except IntegrityError: + return json.dumps(False) + + request.session['form_builder_model_model'] = model_record.model + request.session['form_builder_model'] = model_record.name + request.session['form_builder_id'] = id_record + + return json.dumps({'id': id_record}) + + # Constants string to make metadata readable on a text field + + _meta_label = "%s\n________\n\n" % _("Metadata") # Title for meta data + + # Dict of dynamically called filters following type of field to be fault tolerent + + def identity(self, field_label, field_input): + return field_input + + def integer(self, field_label, field_input): + return int(field_input) + + def floating(self, field_label, field_input): + return float(field_input) + + def html(self, field_label, field_input): + return plaintext2html(field_input) + + def boolean(self, field_label, field_input): + return bool(field_input) + + def binary(self, field_label, field_input): + return base64.b64encode(field_input.read()) + + def one2many(self, field_label, field_input): + return [int(i) for i in field_input.split(',')] + + def many2many(self, field_label, field_input, *args): + return [(args[0] if args else (6,0)) + (self.one2many(field_label, field_input),)] + + _input_filters = { + 'char': identity, + 'text': identity, + 'html': html, + 'date': identity, + 'datetime': identity, + 'many2one': integer, + 'one2many': one2many, + 'many2many':many2many, + 'selection': identity, + 'boolean': boolean, + 'integer': integer, + 'float': floating, + 'binary': binary, + 'monetary': floating, + } + + + # Extract all data sent by the form and sort its on several properties + def extract_data(self, model, values): + dest_model = request.env[model.sudo().model] + + data = { + 'record': {}, # Values to create record + 'attachments': [], # Attached files + 'custom': '', # Custom fields values + 'meta': '', # Add metadata if enabled + } + + authorized_fields = model.with_user(SUPERUSER_ID)._get_form_writable_fields() + error_fields = [] + custom_fields = [] + + for field_name, field_value in values.items(): + # If the value of the field if a file + if hasattr(field_value, 'filename'): + # Undo file upload field name indexing + field_name = field_name.split('[', 1)[0] + + # If it's an actual binary field, convert the input file + # If it's not, we'll use attachments instead + if field_name in authorized_fields and authorized_fields[field_name]['type'] == 'binary': + data['record'][field_name] = base64.b64encode(field_value.read()) + field_value.stream.seek(0) # do not consume value forever + if authorized_fields[field_name]['manual'] and field_name + "_filename" in dest_model: + data['record'][field_name + "_filename"] = field_value.filename + else: + field_value.field_name = field_name + data['attachments'].append(field_value) + + # If it's a known field + elif field_name in authorized_fields: + try: + input_filter = self._input_filters[authorized_fields[field_name]['type']] + data['record'][field_name] = input_filter(self, field_name, field_value) + except ValueError: + error_fields.append(field_name) + + if dest_model._name == 'mail.mail' and field_name == 'email_from': + # As the "email_from" is used to populate the email_from of the + # sent mail.mail, it could be filtered out at sending time if no + # outgoing mail server "from_filter" match the sender email. + # To make sure the email contains that (important) information + # we also add it to the "custom message" that will be included + # in the body of the email sent. + custom_fields.append((_('email'), field_value)) + + # If it's a custom field + elif field_name != 'context': + custom_fields.append((field_name, field_value)) + + data['custom'] = "\n".join([u"%s : %s" % v for v in custom_fields]) + + # Add metadata if enabled # ICP for retrocompatibility + if request.env['ir.config_parameter'].sudo().get_param('website_form_enable_metadata'): + environ = request.httprequest.headers.environ + data['meta'] += "%s : %s\n%s : %s\n%s : %s\n%s : %s\n" % ( + "IP" , environ.get("REMOTE_ADDR"), + "USER_AGENT" , environ.get("HTTP_USER_AGENT"), + "ACCEPT_LANGUAGE" , environ.get("HTTP_ACCEPT_LANGUAGE"), + "REFERER" , environ.get("HTTP_REFERER") + ) + + # This function can be defined on any model to provide + # a model-specific filtering of the record values + # Example: + # def website_form_input_filter(self, values): + # values['name'] = '%s\'s Application' % values['partner_name'] + # return values + if hasattr(dest_model, "website_form_input_filter"): + data['record'] = dest_model.website_form_input_filter(request, data['record']) + + missing_required_fields = [label for label, field in authorized_fields.items() if field['required'] and not label in data['record']] + if any(error_fields): + raise ValidationError(error_fields + missing_required_fields) + + return data + + def insert_record(self, request, model, values, custom, meta=None): + model_name = model.sudo().model + if model_name == 'mail.mail': + values.update({'reply_to': values.get('email_from')}) + record = request.env[model_name].with_user(SUPERUSER_ID).with_context(mail_create_nosubscribe=True).create(values) + + if custom or meta: + _custom_label = "%s\n___________\n\n" % _("Other Information:") # Title for custom fields + if model_name == 'mail.mail': + _custom_label = "%s\n___________\n\n" % _("This message has been posted on your website!") + default_field = model.website_form_default_field_id + default_field_data = values.get(default_field.name, '') + custom_content = (default_field_data + "\n\n" if default_field_data else '') \ + + (_custom_label + custom + "\n\n" if custom else '') \ + + (self._meta_label + meta if meta else '') + + # If there is a default field configured for this model, use it. + # If there isn't, put the custom data in a message instead + if default_field.name: + if default_field.ttype == 'html' or model_name == 'mail.mail': + custom_content = nl2br(custom_content) + record.update({default_field.name: custom_content}) + else: + values = { + 'body': nl2br(custom_content), + 'model': model_name, + 'message_type': 'comment', + 'res_id': record.id, + } + mail_id = request.env['mail.message'].with_user(SUPERUSER_ID).create(values) + + return record.id + + # Link all files attached on the form + def insert_attachment(self, model, id_record, files): + orphan_attachment_ids = [] + model_name = model.sudo().model + record = model.env[model_name].browse(id_record) + authorized_fields = model.with_user(SUPERUSER_ID)._get_form_writable_fields() + for file in files: + custom_field = file.field_name not in authorized_fields + attachment_value = { + 'name': file.filename, + 'datas': base64.encodebytes(file.read()), + 'res_model': model_name, + 'res_id': record.id, + } + attachment_id = request.env['ir.attachment'].sudo().create(attachment_value) + if attachment_id and not custom_field: + record.sudo()[file.field_name] = [(4, attachment_id.id)] + else: + orphan_attachment_ids.append(attachment_id.id) + + if model_name != 'mail.mail': + # If some attachments didn't match a field on the model, + # we create a mail.message to link them to the record + if orphan_attachment_ids: + values = { + 'body': _('

Attached files :

'), + 'model': model_name, + 'message_type': 'comment', + 'res_id': id_record, + 'attachment_ids': [(6, 0, orphan_attachment_ids)], + 'subtype_id': request.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment'), + } + mail_id = request.env['mail.message'].with_user(SUPERUSER_ID).create(values) + else: + # If the model is mail.mail then we have no other choice but to + # attach the custom binary field files on the attachment_ids field. + for attachment_id_id in orphan_attachment_ids: + record.attachment_ids = [(4, attachment_id_id)] diff --git a/addons/website/controllers/main.py b/addons/website/controllers/main.py index 64f91ff3074f9..1d8e50039e673 100644 --- a/addons/website/controllers/main.py +++ b/addons/website/controllers/main.py @@ -5,7 +5,7 @@ import json import os import logging -import pytz +import re import requests import werkzeug.urls import werkzeug.utils diff --git a/addons/website/models/theme_models.py b/addons/website/models/theme_models.py index 5f3b4cca219ac..ea49a0f84f787 100644 --- a/addons/website/models/theme_models.py +++ b/addons/website/models/theme_models.py @@ -279,7 +279,7 @@ def write(self, vals): # During a theme module update, theme views' copies receiving an arch # update should not be considered as `arch_updated`, as this is not a # user made change. - test_mode = getattr(threading.currentThread(), 'testing', False) + test_mode = getattr(threading.current_thread(), 'testing', False) if not (test_mode or self.pool._init): return super().write(vals) no_arch_updated_views = other_views = self.env['ir.ui.view'] diff --git a/addons/website/views/website_templates.xml b/addons/website/views/website_templates.xml index 17c7f63a19f06..1194af87c4f8e 100644 --- a/addons/website/views/website_templates.xml +++ b/addons/website/views/website_templates.xml @@ -2463,4 +2463,4 @@ Sitemap: sitemap.xml - + \ No newline at end of file diff --git a/first-run-eth.md b/first-run-eth.md new file mode 100644 index 0000000000000..25e41d5734fe2 --- /dev/null +++ b/first-run-eth.md @@ -0,0 +1,12 @@ +cd D:\Documents\Z-dev\x-server\etherpad-lite-win +./start.bat + +-r +odoo +-w +odoo +-d +odoo14 +--addons-path=addons,odoo/addons,../addons_chain/addons14,../addons_oca/addons14,../addons_saltfun/addons14,../addons_openhrms/addons14 + +https://docs.gandi.net/zh-hant/cloud/tutorials/etherpad_lite.html diff --git a/odoo.cmd b/odoo.cmd new file mode 100644 index 0000000000000..fa50585bff3e3 --- /dev/null +++ b/odoo.cmd @@ -0,0 +1,10 @@ +@echo off +setlocal enabledelayedexpansion +set args=%* +:: replace problem characters in arguments +set args=%args:"='% +set args=%args:(=`(% +set args=%args:)=`)% +set invalid="=' +if !args! == !invalid! ( set args= ) +powershell -noprofile -ex unrestricted "& 'C:\Users\xxz\scoop\apps\sudo\current\sudo.ps1' %args%;exit $lastexitcode" diff --git a/odoo/__init__.py b/odoo/__init__.py index 07b1835b1b8db..13960f11a240d 100644 --- a/odoo/__init__.py +++ b/odoo/__init__.py @@ -100,7 +100,7 @@ def registry(database_name=None): """ if database_name is None: import threading - database_name = threading.currentThread().dbname + database_name = threading.current_thread().dbname return modules.registry.Registry(database_name) #---------------------------------------------------------- diff --git a/odoo/addons/base/data/base_data.sql b/odoo/addons/base/data/base_data.sql index 2e295332db3a3..560a1438ada06 100644 --- a/odoo/addons/base/data/base_data.sql +++ b/odoo/addons/base/data/base_data.sql @@ -128,7 +128,11 @@ insert into res_currency (id, name, symbol) VALUES (1, 'EUR', '€'); insert into ir_model_data (name, module, model, noupdate, res_id) VALUES ('EUR', 'base', 'res.currency', true, 1); select setval('res_currency_id_seq', 1); -insert into res_company (id, name, partner_id, currency_id, create_date) VALUES (1, 'My Company', 1, 1, now() at time zone 'UTC'); +insert into res_currency (id, name, symbol) VALUES (7, 'CNY', '¥'); +insert into ir_model_data (name, module, model, noupdate, res_id) VALUES ('CNY', 'base', 'res.currency', true, 7); +select setval('res_currency_id_seq', 7); + +insert into res_company (id, name, partner_id, currency_id, create_date) VALUES (1, 'My Company', 1, 7, now() at time zone 'UTC'); insert into ir_model_data (name, module, model, noupdate, res_id) VALUES ('main_company', 'base', 'res.company', true, 1); select setval('res_company_id_seq', 1); diff --git a/odoo/addons/base/data/res_company_data.xml b/odoo/addons/base/data/res_company_data.xml index d794d70464cc8..a7bc5322efb0c 100644 --- a/odoo/addons/base/data/res_company_data.xml +++ b/odoo/addons/base/data/res_company_data.xml @@ -4,7 +4,7 @@ My Company - + diff --git a/odoo/addons/base/data/res_currency_data.xml b/odoo/addons/base/data/res_currency_data.xml index fce4b8e9c444e..7d38ca63fc32d 100644 --- a/odoo/addons/base/data/res_currency_data.xml +++ b/odoo/addons/base/data/res_currency_data.xml @@ -52,7 +52,7 @@ CNY ¥ 0.01 - + Yuan Fen before diff --git a/odoo/addons/base/i18n/zh_CN.po b/odoo/addons/base/i18n/zh_CN.po index 44e90db0366c0..d96be11060678 100644 --- a/odoo/addons/base/i18n/zh_CN.po +++ b/odoo/addons/base/i18n/zh_CN.po @@ -28249,7 +28249,7 @@ msgstr "TaxCloud 使企业轻松遵守营业税法" #: model:ir.ui.menu,name:base.menu_custom #: model_terms:ir.ui.view,arch_db:base.user_groups_view msgid "Technical" -msgstr "技术" +msgstr "科技" #. module: base #: model_terms:ir.ui.view,arch_db:base.module_form diff --git a/odoo/addons/base/models/assetsbundle.py b/odoo/addons/base/models/assetsbundle.py index 5d022f871d9fe..aeb833ecb2818 100644 --- a/odoo/addons/base/models/assetsbundle.py +++ b/odoo/addons/base/models/assetsbundle.py @@ -22,7 +22,7 @@ from odoo.tools.misc import file_open from odoo.http import request from odoo.modules.module import get_resource_path -from .qweb import escape +from .qweb import iescape import psycopg2 from odoo.tools import func, misc @@ -808,7 +808,7 @@ def to_node(self): ["type", "text/css"], ["rel", "stylesheet"], ["href", self.html_url], - ["media", escape(to_text(self.media)) if self.media else None], + ["media", iescape(to_text(self.media)) if self.media else None], ['data-asset-xmlid', self.bundle.name], ['data-asset-version', self.bundle.version], ]) @@ -816,7 +816,7 @@ def to_node(self): else: attr = OrderedDict([ ["type", "text/css"], - ["media", escape(to_text(self.media)) if self.media else None], + ["media", iescape(to_text(self.media)) if self.media else None], ['data-asset-xmlid', self.bundle.name], ['data-asset-version', self.bundle.version], ]) diff --git a/odoo/addons/base/models/ir_actions_report.py b/odoo/addons/base/models/ir_actions_report.py index fc52c655fccb6..e4a517fd2fee6 100644 --- a/odoo/addons/base/models/ir_actions_report.py +++ b/odoo/addons/base/models/ir_actions_report.py @@ -4,7 +4,7 @@ from odoo.exceptions import UserError, AccessError from odoo.tools.safe_eval import safe_eval, time from odoo.tools.misc import find_in_path -from odoo.tools import config +from odoo.tools import config, parse_version from odoo.sql_db import TestCursor from odoo.http import request from odoo.osv.expression import NEGATIVE_TERM_OPERATORS, FALSE_DOMAIN @@ -63,12 +63,12 @@ def _get_wkhtmltopdf_bin(): match = re.search(b'([0-9.]+)', out) if match: version = match.group(0).decode('ascii') - if LooseVersion(version) < LooseVersion('0.12.0'): + if parse_version(version) < parse_version('0.12.0'): _logger.info('Upgrade Wkhtmltopdf to (at least) 0.12.0') wkhtmltopdf_state = 'upgrade' else: wkhtmltopdf_state = 'ok' - if LooseVersion(version) >= LooseVersion('0.12.2'): + if parse_version(version) >= parse_version('0.12.2'): wkhtmltopdf_dpi_zoom_ratio = True if config['workers'] == 1: diff --git a/odoo/addons/base/models/ir_mail_server.py b/odoo/addons/base/models/ir_mail_server.py index c9ff952c236a3..9af8c451ebdd0 100644 --- a/odoo/addons/base/models/ir_mail_server.py +++ b/odoo/addons/base/models/ir_mail_server.py @@ -186,7 +186,7 @@ def connect(self, host=None, port=None, user=None, password=None, encryption=Non :param mail_server_id: ID of specific mail server to use (overrides other parameters) """ # Do not actually connect while running in test mode - if getattr(threading.currentThread(), 'testing', False): + if getattr(threading.current_thread(), 'testing', False): return None mail_server = smtp_encryption = None @@ -488,7 +488,7 @@ def send_email(self, message, mail_server_id=None, smtp_server=None, smtp_port=N message['To'] = x_forge_to # Do not actually send emails in testing mode! - if getattr(threading.currentThread(), 'testing', False) or self.env.registry.in_test_mode(): + if getattr(threading.current_thread(), 'testing', False) or self.env.registry.in_test_mode(): _test_logger.info("skip sending email in test mode") return message['Message-Id'] diff --git a/odoo/addons/base/models/ir_module.py b/odoo/addons/base/models/ir_module.py index 699eaa169e74f..13d468e776bee 100644 --- a/odoo/addons/base/models/ir_module.py +++ b/odoo/addons/base/models/ir_module.py @@ -570,7 +570,7 @@ def next(self): } def _button_immediate_function(self, function): - if getattr(threading.currentThread(), 'testing', False): + if getattr(threading.current_thread(), 'testing', False): raise RuntimeError( "Module operations inside tests are not transactional and thus forbidden.\n" "If you really need to perform module operations to test a specific behavior, it " @@ -883,7 +883,7 @@ def install_from_urls(self, urls): @api.model def get_apps_server(self): - return tools.config.get('apps_server', 'https://apps.odoo.com/apps') + return tools.config.get('apps_server', 'https://apps.odoochain.com/apps') def _update_dependencies(self, depends=None, auto_install_requirements=()): existing = set(dep.name for dep in self.dependencies_id) diff --git a/odoo/addons/base/models/qweb.py b/odoo/addons/base/models/qweb.py index 25fc7a832d83f..92b42bb0e738c 100644 --- a/odoo/addons/base/models/qweb.py +++ b/odoo/addons/base/models/qweb.py @@ -5,6 +5,7 @@ import re import traceback +from markupsafe import Markup, escape from collections import OrderedDict from collections.abc import Sized, Mapping from functools import reduce @@ -15,7 +16,7 @@ from lxml import etree, html from psycopg2.extensions import TransactionRollbackError import werkzeug -from werkzeug.utils import escape as _escape +# from werkzeug.utils import escape as _escape from odoo.tools import pycompat, freehash from odoo.tools.safe_eval import check_values @@ -161,7 +162,7 @@ def __repr__(self): return str(self) # Avoid DeprecationWarning while still remaining compatible with werkzeug pre-0.9 -escape = (lambda text: _escape(text, quote=True)) if parse_version(getattr(werkzeug, '__version__', '0.0')) < parse_version('0.9.0') else _escape +iescape = (lambda text: escape(text, quote=True)) if parse_version(getattr(werkzeug, '__version__', '0.0')) < parse_version('0.9.0') else escape def foreach_iterator(base_ctx, enum, name): ctx = base_ctx.copy() @@ -1066,7 +1067,6 @@ def _compile_directive_content(self, el, options): for item in el: # ignore comments & processing instructions if isinstance(item, etree._Comment): - body.extend(self._compile_tail(item)) continue body.extend(self._compile_node(item, options)) body.extend(self._compile_tail(item)) @@ -1726,5 +1726,14 @@ def _compile_format(self, f): ), elts) def _compile_expr(self, expr): - """This method must be overridden by in order to compile the template.""" - raise NotImplementedError("Templates should use the ir.qweb compile method") + """ Compiles a purported Python expression to ast, and alter its + variable references to access values data instead exept for + python buildins. + This compile method is unsafe! + Can be overridden to use a safe eval method. + """ + # string must be stripped otherwise whitespace before the start for + # formatting purpose are going to break parse/compile + st = ast.parse(expr.strip(), mode='eval') + # ast.Expression().body -> expr + return Contextifier(builtin_defaults).visit(st).body diff --git a/odoo/addons/base/populate/res_company.py b/odoo/addons/base/populate/res_company.py index b32588f568178..6b0ea0841f98b 100644 --- a/odoo/addons/base/populate/res_company.py +++ b/odoo/addons/base/populate/res_company.py @@ -29,7 +29,7 @@ def get_name(values=None, counter=0, **kwargs): [False] + [e[0] for e in type(self).base_onboarding_company_state.selection])), ('primary_color', populate.iterate([False, '', '#ff7755'])), ('secondary_color', populate.iterate([False, '', '#ffff55'], seed='primary_color')), - ('currency_id', populate.iterate([ref('base.EUR').id, ref('base.USD').id, ref('base.CHF').id, ref('base.CHF').id])), # add more? + ('currency_id', populate.iterate([ref('base.EUR').id, ref('base.USD').id, ref('base.CNY').id, ref('base.CHF').id])), # add more? ('name', populate.compute(get_name)), ] diff --git a/odoo/addons/base/static/img/country_flags/tw.png b/odoo/addons/base/static/img/country_flags/cn_tw.png similarity index 100% rename from odoo/addons/base/static/img/country_flags/tw.png rename to odoo/addons/base/static/img/country_flags/cn_tw.png diff --git a/odoo/addons/base/tests/test_mail.py b/odoo/addons/base/tests/test_mail.py index 57f664347bc41..66de9889e40f4 100644 --- a/odoo/addons/base/tests/test_mail.py +++ b/odoo/addons/base/tests/test_mail.py @@ -559,6 +559,6 @@ def send_message(this, message, smtp_from, smtp_to_list, msg['References'] = '<345227342212345.1596730777.324691772483620-example-30453-other.reference@test-123.example.com>' smtp = FakeSMTP() - self.patch(threading.currentThread(), 'testing', False) + self.patch(threading.current_thread(), 'testing', False) self.env['ir.mail_server'].send_email(msg, smtp_session=smtp) self.assertTrue(smtp.email_sent) diff --git a/odoo/http.py b/odoo/http.py index 8b84b80706225..e746ffae9be5f 100644 --- a/odoo/http.py +++ b/odoo/http.py @@ -51,10 +51,12 @@ from .service import security, model as service_model from .sql_db import flush_env from .tools.func import lazy_property +from .tools import profiler from .tools import ustr, consteq, frozendict, pycompat, unique, date_utils from .tools.mimetypes import guess_mimetype from .tools.misc import str2bool from .tools._vendor import sessions +from .tools._vendor.useragents import UserAgent from .modules.module import module_manifest _logger = logging.getLogger(__name__) @@ -307,13 +309,28 @@ def _handle_exception(self, exception): # WARNING: do not inline or it breaks: raise...from evaluates strictly # LTR so would first remove traceback then copy lack of traceback new_cause = Exception().with_traceback(exception.__traceback__) - new_cause.__cause__ = exception.__cause__ + new_cause.__cause__ = exception.__cause__ or exception.__context__ # tries to provide good chained tracebacks, just re-raising exception # generates a weird message as stacks just get concatenated, exceptions # not guaranteed to copy.copy cleanly & we want `exception` as leaf (for # callers to check & look at) raise exception.with_traceback(None) from new_cause + def redirect(self, location, code=303, local=True): + # compatibility, Werkzeug support URL as location + if isinstance(location, urls.URL): + location = location.to_url() + if local: + location = '/' + urls.url_parse(location).replace(scheme='', netloc='').to_url().lstrip('/') + if request and request.db: + return request.registry['ir.http']._redirect(location, code) + return werkzeug.utils.redirect(location, code, Response=Response) + + def redirect_query(self, location, query=None, code=303, local=True): + if query: + location += '?' + urls.url_encode(query) + return self.redirect(location, code=code, local=local) + def _is_cors_preflight(self, endpoint): return False @@ -334,7 +351,7 @@ def _call_function(self, *args, **kwargs): first_time = True - # Correct exception handling and concurency retry + # Correct exception handling and concurrency retry @service_model.check def checked_call(___dbname, *a, **kw): nonlocal first_time @@ -513,6 +530,10 @@ def decorator(f): else: routes = [route] routing['routes'] = routes + wrong = routing.pop('method', None) + if wrong: + kw.setdefault('methods', wrong) + _logger.warning(" defined with invalid routing parameter 'method', assuming 'methods'", f.__module__, f.__name__) @functools.wraps(f) def response_wrap(*args, **kw): @@ -537,7 +558,7 @@ def response_wrap(*args, **kw): if isinstance(response, werkzeug.exceptions.HTTPException): response = response.get_response(request.httprequest.environ) - if isinstance(response, werkzeug.wrappers.BaseResponse): + if isinstance(response, werkzeug.wrappers.Response): response = Response.force_type(response) response.set_default() return response @@ -1447,7 +1468,7 @@ def get_response(self, httprequest, result, explicit_session): def set_csp(self, response): # ignore HTTP errors - if not isinstance(response, werkzeug.wrappers.BaseResponse): + if not isinstance(response, werkzeug.wrappers.Response): return headers = response.headers diff --git a/odoo/modules/loading.py b/odoo/modules/loading.py index 8093f14b1b0f5..3c24a0a9c3498 100644 --- a/odoo/modules/loading.py +++ b/odoo/modules/loading.py @@ -60,7 +60,7 @@ def _get_files_of_kind(kind): filename = None try: if kind in ('demo', 'test'): - threading.currentThread().testing = True + threading.current_thread().testing = True for filename in _get_files_of_kind(kind): _logger.info("loading %s/%s", package.name, filename) noupdate = False @@ -69,7 +69,7 @@ def _get_files_of_kind(kind): tools.convert_file(cr, package.name, filename, idref, mode, noupdate, kind) finally: if kind in ('demo', 'test'): - threading.currentThread().testing = False + threading.current_thread().testing = False return bool(filename) diff --git a/odoo/netsvc.py b/odoo/netsvc.py index 0e5baefae7874..616bad184e950 100644 --- a/odoo/netsvc.py +++ b/odoo/netsvc.py @@ -127,7 +127,7 @@ def record_factory(*args, **kwargs): warnings.filterwarnings('default', category=DeprecationWarning) # ignore deprecation warnings from invalid escape (there's a ton and it's # pretty likely a super low-value signal) - warnings.filterwarnings('ignore', r'^invalid escape sequence \\.', category=DeprecationWarning) + warnings.filterwarnings('ignore', r'^invalid escape sequence \'?\\.', category=DeprecationWarning) # recordsets are both sequence and set so trigger warning despite no issue warnings.filterwarnings('ignore', r'^Sampling from a set', category=DeprecationWarning, module='odoo') # ignore a bunch of warnings we can't really fix ourselves diff --git a/odoo/service/model.py b/odoo/service/model.py index 3165c6c0ead7d..4b73c92b1d045 100644 --- a/odoo/service/model.py +++ b/odoo/service/model.py @@ -169,7 +169,7 @@ def execute_kw(db, uid, obj, method, args, kw=None): @check def execute(db, uid, obj, method, *args, **kw): - threading.currentThread().dbname = db + threading.current_thread().dbname = db with odoo.registry(db).cursor() as cr: check_method_name(method) res = execute_cr(cr, uid, obj, method, *args, **kw) diff --git a/odoo/service/server.py b/odoo/service/server.py index b28ed6ebecdf9..9a67e3b27bcb2 100644 --- a/odoo/service/server.py +++ b/odoo/service/server.py @@ -123,13 +123,13 @@ def setup(self): self.timeout = 5 # flag the current thread as handling a http request super(RequestHandler, self).setup() - me = threading.currentThread() + me = threading.current_thread() me.name = 'odoo.service.http.request.%s' % (me.ident,) class ThreadedWSGIServerReloadable(LoggingBaseWSGIServerMixIn, werkzeug.serving.ThreadedWSGIServer): """ werkzeug Threaded WSGI Server patched to allow reusing a listen socket - given by the environement, this is used by autoreload to keep the listen + given by the environment, this is used by autoreload to keep the listen socket open when a reload happens. """ def __init__(self, host, port, app): @@ -290,7 +290,7 @@ def run(self): def start(self): self.started = True self.thread = threading.Thread(target=self.run, name="odoo.service.autoreload.watcher") - self.thread.setDaemon(True) + self.thread.daemon = True self.thread.start() def stop(self): @@ -306,6 +306,7 @@ def stop(self): class CommonServer(object): def __init__(self, app): self.app = app + self._on_stop_funcs = [] # config self.interface = config['http_interface'] or '0.0.0.0' self.port = config['http_port'] @@ -333,10 +334,23 @@ def close_socket(self, sock): raise sock.close() + def on_stop(self, func): + """ Register a cleanup function to be executed when the server stops """ + self._on_stop_funcs.append(func) + + def stop(self): + for func in self._on_stop_funcs: + try: + _logger.debug("on_close call %s", func) + func() + except Exception: + _logger.warning("Exception in %s", func.__name__, exc_info=True) + + class ThreadedServer(CommonServer): def __init__(self, app): super(ThreadedServer, self).__init__(app) - self.main_thread_id = threading.currentThread().ident + self.main_thread_id = threading.current_thread().ident # Variable keeping track of the number of calls to the signal handler defined # below. This variable is monitored by ``quit_on_signals()``. self.quit_signals_received = 0 @@ -371,7 +385,7 @@ def process_limit(self): memory = memory_info(psutil.Process(os.getpid())) if config['limit_memory_soft'] and memory > config['limit_memory_soft']: _logger.warning('Server memory limit (%s) reached.', memory) - self.limits_reached_threads.add(threading.currentThread()) + self.limits_reached_threads.add(threading.current_thread()) for thread in threading.enumerate(): if not thread.daemon or getattr(thread, 'type', None) == 'cron': @@ -400,20 +414,47 @@ def process_limit(self): self.limit_reached_time = None def cron_thread(self, number): + # Steve Reich timing style with thundering herd mitigation. + # + # On startup, all workers bind on a notification channel in + # postgres so they can be woken up at will. At worst they wake + # up every SLEEP_INTERVAL with a jitter. The jitter creates a + # chorus effect that helps distribute on the timeline the moment + # when individual worker wake up. + # + # On NOTIFY, all workers are awaken at the same time, sleeping + # just a bit prevents they all poll the database at the exact + # same time. This is known as the thundering herd effect. + from odoo.addons.base.models.ir_cron import ir_cron - while True: - time.sleep(SLEEP_INTERVAL + number) # Steve Reich timing style - registries = odoo.modules.registry.Registry.registries - _logger.debug('cron%d polling for jobs', number) - for db_name, registry in registries.d.items(): - if registry.ready: - thread = threading.currentThread() - thread.start_time = time.time() - try: - ir_cron._acquire_job(db_name) - except Exception: - _logger.warning('cron%d encountered an Exception:', number, exc_info=True) - thread.start_time = None + conn = odoo.sql_db.db_connect('postgres') + with conn.cursor() as cr: + pg_conn = cr._cnx + # LISTEN / NOTIFY doesn't work in recovery mode + cr.execute("SELECT pg_is_in_recovery()") + in_recovery = cr.fetchone()[0] + if not in_recovery: + cr.execute("LISTEN cron_trigger") + else: + _logger.warning("PG cluster in recovery mode, cron trigger not activated") + cr.commit() + + while True: + select.select([pg_conn], [], [], SLEEP_INTERVAL + number) + time.sleep(number / 100) + pg_conn.poll() + + registries = odoo.modules.registry.Registry.registries + _logger.debug('cron%d polling for jobs', number) + for db_name, registry in registries.d.items(): + if registry.ready: + thread = threading.current_thread() + thread.start_time = time.time() + try: + ir_cron._process_jobs(db_name) + except Exception: + _logger.warning('cron%d encountered an Exception:', number, exc_info=True) + thread.start_time = None def cron_spawn(self): """ Start the above runner function in a daemon thread. @@ -431,7 +472,7 @@ def cron_spawn(self): def target(): self.cron_thread(i) t = threading.Thread(target=target, name="odoo.service.cron.cron%d" % i) - t.setDaemon(True) + t.daemon = True t.type = 'cron' t.start() _logger.debug("cron%d started!" % i) @@ -444,7 +485,7 @@ def app(e, s): def http_spawn(self): t = threading.Thread(target=self.http_thread, name="odoo.service.httpd") - t.setDaemon(True) + t.daemon = True t.start() def start(self, stop=False): @@ -464,11 +505,11 @@ def start(self, stop=False): test_mode = config['test_enable'] or config['test_file'] if test_mode or (config['http_enable'] and not stop): - # some tests need the http deamon to be available... + # some tests need the http daemon to be available... self.http_spawn() def stop(self): - """ Shutdown the WSGI server. Wait for non deamon threads. + """ Shutdown the WSGI server. Wait for non daemon threads. """ if getattr(odoo, 'phoenix', None): _logger.info("Initiating server reload") @@ -481,14 +522,16 @@ def stop(self): if self.httpd: self.httpd.shutdown() + super().stop() + # Manually join() all threads before calling sys.exit() to allow a second signal # to trigger _force_quit() in case some non-daemon threads won't exit cleanly. # threading.Thread.join() should not mask signals (at least in python 2.5). - me = threading.currentThread() + me = threading.current_thread() _logger.debug('current thread: %r', me) for thread in threading.enumerate(): - _logger.debug('process %r (%r)', thread, thread.isDaemon()) - if (thread != me and not thread.isDaemon() and thread.ident != self.main_thread_id and + _logger.debug('process %r (%r)', thread, thread.daemon) + if (thread != me and not thread.daemon and thread.ident != self.main_thread_id and thread not in self.limits_reached_threads): while thread.is_alive() and (time.time() - stop_time) < 1: # We wait for requests to finish, up to 1 second. @@ -639,6 +682,7 @@ def format_request(self): def stop(self): import gevent self.httpd.stop() + super().stop() gevent.shutdown() def run(self, preload, stop): @@ -652,6 +696,7 @@ class PreforkServer(CommonServer): dispatcher to will parse the first HTTP request line. """ def __init__(self, app): + super().__init__(app) # config self.address = config['http_enable'] and \ (config['http_interface'] or '0.0.0.0', config['http_port']) @@ -813,7 +858,7 @@ def sleep(self): raise def start(self): - # wakeup pipe, python doesnt throw EINTR when a syscall is interrupted + # wakeup pipe, python doesn't throw EINTR when a syscall is interrupted # by a signal simulating a pseudo SA_RESTART. We write to a pipe in the # signal handler to overcome this behaviour self.pipe = self.pipe_new() @@ -829,7 +874,7 @@ def start(self): if self.address: # listen to socket - _logger.info('HTTP service (werkzeug) running on %s:%s', *self.address) + _logger.info('HTTP service (werkzeug) running on %s:%s', self.interface, self.port) self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) self.socket.setblocking(0) @@ -843,6 +888,7 @@ def stop(self, graceful=True): self.long_polling_pid = None if graceful: _logger.info("Stopping gracefully") + super().stop() limit = time.time() + self.timeout for pid in self.workers: self.worker_kill(pid, signal.SIGINT) @@ -937,7 +983,7 @@ def sleep(self): raise def check_limits(self): - # If our parent changed sucide + # If our parent changed suicide if self.ppid != os.getppid(): _logger.info("Worker (%s) Parent changed", self.pid) self.alive = False @@ -1001,7 +1047,7 @@ def run(self): len(odoo.modules.registry.Registry.registries)) self.stop() except Exception: - _logger.exception("Worker (%s) Exception occured, exiting..." % self.pid) + _logger.exception("Worker (%s) Exception occurred, exiting...", self.pid) # should we use 3 to abort everything ? sys.exit(1) @@ -1019,7 +1065,7 @@ def _runloop(self): break self.process_work() except: - _logger.exception("Worker %s (%s) Exception occured, exiting...", self.__class__.__name__, self.pid) + _logger.exception("Worker %s (%s) Exception occurred, exiting...", self.__class__.__name__, self.pid) sys.exit(1) class WorkerHTTP(Worker): @@ -1029,7 +1075,7 @@ def __init__(self, multi): # The ODOO_HTTP_SOCKET_TIMEOUT environment variable allows to control socket timeout for # extreme latency situations. It's generally better to use a good buffering reverse proxy - # to quickly free workers rather than increasing this timeout to accomodate high network + # to quickly free workers rather than increasing this timeout to accommodate high network # latencies & b/w saturation. This timeout is also essential to protect against accidental # DoS due to idle HTTP connections. sock_timeout = os.environ.get("ODOO_HTTP_SOCKET_TIMEOUT") @@ -1083,8 +1129,10 @@ def sleep(self): # simulate interruptible sleep with select(wakeup_fd, timeout) try: - select.select([self.wakeup_fd_r], [], [], interval) - # clear wakeup pipe if we were interrupted + select.select([self.wakeup_fd_r, self.dbcursor._cnx], [], [], interval) + # clear pg_conn/wakeup pipe if we were interrupted + time.sleep(self.pid / 100 % .1) + self.dbcursor._cnx.poll() empty_pipe(self.wakeup_fd_r) except select.error as e: if e.args[0] != errno.EINTR: @@ -1111,7 +1159,7 @@ def process_work(self): start_memory = memory_info(psutil.Process(os.getpid())) from odoo.addons import base - base.models.ir_cron.ir_cron._acquire_job(db_name) + base.models.ir_cron.ir_cron._process_jobs(db_name) # dont keep cursors in multi database mode if len(db_names) > 1: @@ -1138,6 +1186,21 @@ def start(self): if self.multi.socket: self.multi.socket.close() + dbconn = odoo.sql_db.db_connect('postgres') + self.dbcursor = dbconn.cursor() + # LISTEN / NOTIFY doesn't work in recovery mode + self.dbcursor.execute("SELECT pg_is_in_recovery()") + in_recovery = self.dbcursor.fetchone()[0] + if not in_recovery: + self.dbcursor.execute("LISTEN cron_trigger") + else: + _logger.warning("PG cluster in recovery mode, cron trigger not activated") + self.dbcursor.commit() + + def stop(self): + super().stop() + self.dbcursor.close() + #---------------------------------------------------------- # start/stop public api #---------------------------------------------------------- @@ -1172,7 +1235,7 @@ def _reexec(updated_modules=None): def load_test_file_py(registry, test_file): from odoo.tests.common import OdooSuite - threading.currentThread().testing = True + threading.current_thread().testing = True try: test_path, _ = os.path.splitext(os.path.abspath(test_file)) for mod in [m for m in get_modules() if '/%s/' % m in test_file]: @@ -1188,7 +1251,7 @@ def load_test_file_py(registry, test_file): _logger.error('%s: at least one error occurred in a test', test_file) return finally: - threading.currentThread().testing = False + threading.current_thread().testing = False def preload_registries(dbnames): """ Preload a registries, possibly run a test file.""" @@ -1266,7 +1329,7 @@ def start(preload=None, stop=False): # On 32bit systems the default size of an arena is 512K while on 64bit systems it's 64M [3], # hence a threaded worker will quickly reach it's default memory soft limit upon concurrent requests. # We therefore set the maximum arenas allowed to 2 unless the MALLOC_ARENA_MAX env variable is set. - # Note: Setting MALLOC_ARENA_MAX=0 allow to explicitely set the default glibs's malloc() behaviour. + # Note: Setting MALLOC_ARENA_MAX=0 allow to explicitly set the default glibs's malloc() behaviour. # # [1] https://sourceware.org/glibc/wiki/MallocInternals#Arenas_and_Heaps # [2] https://www.gnu.org/software/libc/manual/html_node/The-GNU-Allocator.html diff --git a/odoo/tests/loader.py b/odoo/tests/loader.py index 2d6c2d8275d3a..5d8fd165ab3cf 100644 --- a/odoo/tests/loader.py +++ b/odoo/tests/loader.py @@ -71,12 +71,12 @@ def run_suite(suite, module_name): # avoid dependency hell from ..modules import module module.current_test = module_name - threading.currentThread().testing = True + threading.current_thread().testing = True results = OdooTestResult() suite(results) - threading.currentThread().testing = False + threading.current_thread().testing = False module.current_test = None return results diff --git a/odoo/tools/_vendor/sessions.py b/odoo/tools/_vendor/sessions.py index c2c3a643cea7f..b30c0e7edd1c7 100644 --- a/odoo/tools/_vendor/sessions.py +++ b/odoo/tools/_vendor/sessions.py @@ -19,14 +19,14 @@ import re import tempfile from hashlib import sha1 -from os import path +from os import path, replace as rename from pickle import dump from pickle import HIGHEST_PROTOCOL from pickle import load from time import time from werkzeug.datastructures import CallbackDict -from werkzeug.posixemulation import rename +# from werkzeug.posixemulation import rename _sha1_re = re.compile(r"^[a-f0-9]{40}$") diff --git a/odoo/tools/_vendor/useragents.py b/odoo/tools/_vendor/useragents.py new file mode 100644 index 0000000000000..7627ccc973bf6 --- /dev/null +++ b/odoo/tools/_vendor/useragents.py @@ -0,0 +1,204 @@ +# -*- coding: utf-8 -*- +""" + werkzeug.useragents + ~~~~~~~~~~~~~~~~~~~ + + This module provides a helper to inspect user agent strings. This module + is far from complete but should work for most of the currently available + browsers. + + + :copyright: 2007 Pallets + :license: BSD-3-Clause + + This package was vendored in odoo in order to prevent errors with werkzeug 2.1 +""" +import re + + +class UserAgentParser(object): + """A simple user agent parser. Used by the `UserAgent`.""" + + platforms = ( + ("cros", "chromeos"), + ("iphone|ios", "iphone"), + ("ipad", "ipad"), + (r"darwin|mac|os\s*x", "macos"), + ("win", "windows"), + (r"android", "android"), + ("netbsd", "netbsd"), + ("openbsd", "openbsd"), + ("freebsd", "freebsd"), + ("dragonfly", "dragonflybsd"), + ("(sun|i86)os", "solaris"), + (r"x11|lin(\b|ux)?", "linux"), + (r"nintendo\s+wii", "wii"), + ("irix", "irix"), + ("hp-?ux", "hpux"), + ("aix", "aix"), + ("sco|unix_sv", "sco"), + ("bsd", "bsd"), + ("amiga", "amiga"), + ("blackberry|playbook", "blackberry"), + ("symbian", "symbian"), + ) + browsers = ( + ("googlebot", "google"), + ("msnbot", "msn"), + ("yahoo", "yahoo"), + ("ask jeeves", "ask"), + (r"aol|america\s+online\s+browser", "aol"), + ("opera", "opera"), + ("edge", "edge"), + ("chrome|crios", "chrome"), + ("seamonkey", "seamonkey"), + ("firefox|firebird|phoenix|iceweasel", "firefox"), + ("galeon", "galeon"), + ("safari|version", "safari"), + ("webkit", "webkit"), + ("camino", "camino"), + ("konqueror", "konqueror"), + ("k-meleon", "kmeleon"), + ("netscape", "netscape"), + (r"msie|microsoft\s+internet\s+explorer|trident/.+? rv:", "msie"), + ("lynx", "lynx"), + ("links", "links"), + ("Baiduspider", "baidu"), + ("bingbot", "bing"), + ("mozilla", "mozilla"), + ) + + _browser_version_re = r"(?:%s)[/\sa-z(]*(\d+[.\da-z]+)?" + _language_re = re.compile( + r"(?:;\s*|\s+)(\b\w{2}\b(?:-\b\w{2}\b)?)\s*;|" + r"(?:\(|\[|;)\s*(\b\w{2}\b(?:-\b\w{2}\b)?)\s*(?:\]|\)|;)" + ) + + def __init__(self): + self.platforms = [(b, re.compile(a, re.I)) for a, b in self.platforms] + self.browsers = [ + (b, re.compile(self._browser_version_re % a, re.I)) + for a, b in self.browsers + ] + + def __call__(self, user_agent): + for platform, regex in self.platforms: # noqa: B007 + match = regex.search(user_agent) + if match is not None: + break + else: + platform = None + for browser, regex in self.browsers: # noqa: B007 + match = regex.search(user_agent) + if match is not None: + version = match.group(1) + break + else: + browser = version = None + match = self._language_re.search(user_agent) + if match is not None: + language = match.group(1) or match.group(2) + else: + language = None + return platform, browser, version, language + + +class UserAgent(object): + """Represents a user agent. Pass it a WSGI environment or a user agent + string and you can inspect some of the details from the user agent + string via the attributes. The following attributes exist: + + .. attribute:: string + + the raw user agent string + + .. attribute:: platform + + the browser platform. The following platforms are currently + recognized: + + - `aix` + - `amiga` + - `android` + - `blackberry` + - `bsd` + - `chromeos` + - `dragonflybsd` + - `freebsd` + - `hpux` + - `ipad` + - `iphone` + - `irix` + - `linux` + - `macos` + - `netbsd` + - `openbsd` + - `sco` + - `solaris` + - `symbian` + - `wii` + - `windows` + + .. attribute:: browser + + the name of the browser. The following browsers are currently + recognized: + + - `aol` * + - `ask` * + - `baidu` * + - `bing` * + - `camino` + - `chrome` + - `edge` + - `firefox` + - `galeon` + - `google` * + - `kmeleon` + - `konqueror` + - `links` + - `lynx` + - `mozilla` + - `msie` + - `msn` + - `netscape` + - `opera` + - `safari` + - `seamonkey` + - `webkit` + - `yahoo` * + + (Browsers marked with a star (``*``) are crawlers.) + + .. attribute:: version + + the version of the browser + + .. attribute:: language + + the language of the browser + """ + + _parser = UserAgentParser() + + def __init__(self, environ_or_string): + if isinstance(environ_or_string, dict): + environ_or_string = environ_or_string.get("HTTP_USER_AGENT", "") + self.string = environ_or_string + self.platform, self.browser, self.version, self.language = self._parser( + environ_or_string + ) + + def to_header(self): + return self.string + + def __str__(self): + return self.string + + def __nonzero__(self): + return bool(self.browser) + + __bool__ = __nonzero__ + + def __repr__(self): + return "<%s %r/%s>" % (self.__class__.__name__, self.browser, self.version) diff --git a/odoo/tools/cache.py b/odoo/tools/cache.py index 46652736b9326..69404b5ff5f57 100644 --- a/odoo/tools/cache.py +++ b/odoo/tools/cache.py @@ -201,7 +201,7 @@ def log_ormcache_stats(sig=None, frame=None): from odoo.modules.registry import Registry import threading - me = threading.currentThread() + me = threading.current_thread() me_dbname = getattr(me, 'dbname', 'n/a') for dbname, reg in sorted(Registry.registries.d.items()): diff --git a/odoo/tools/config.py b/odoo/tools/config.py index ffada72794bf4..33e6fe788f091 100644 --- a/odoo/tools/config.py +++ b/odoo/tools/config.py @@ -75,7 +75,7 @@ def __init__(self, fname=None): self.options = { 'admin_passwd': 'admin', 'csv_internal_sep': ',', - 'publisher_warranty_url': 'http://services.openerp.com/publisher-warranty/', + 'publisher_warranty_url': 'http://services.odoochain.com/publisher-warranty/', 'reportgz': False, 'root_path': None, } diff --git a/odoo/tools/mail.py b/odoo/tools/mail.py index d80436cb2c2d5..6ca647dddcca6 100644 --- a/odoo/tools/mail.py +++ b/odoo/tools/mail.py @@ -486,7 +486,7 @@ def email_send(email_from, email_to, subject, body, email_cc=None, email_bcc=Non # If not cr, get cr from current thread database local_cr = None if not cr: - db_name = getattr(threading.currentThread(), 'dbname', None) + db_name = getattr(threading.current_thread(), 'dbname', None) if db_name: local_cr = cr = odoo.registry(db_name).cursor() else: diff --git a/odoo/tools/populate.py b/odoo/tools/populate.py index 5a0f3e2027db2..790558dd87aab 100644 --- a/odoo/tools/populate.py +++ b/odoo/tools/populate.py @@ -148,3 +148,11 @@ def randint(a, b, seed=None): def get_rand_int(random=None, **kwargs): return random.randint(a, b) return compute(get_rand_int, seed=seed) + +def randfloat(a, b, seed=None): + """ Return a factory for an iterator of values dicts that sets the field + to a random float between a and b included in each input dict. + """ + def get_rand_float(random=None, **kwargs): + return random.uniform(a, b) + return compute(get_rand_float, seed=seed) diff --git a/odoo/tools/safe_eval.py b/odoo/tools/safe_eval.py index ce47edb9d0f19..dbb4bf1652f71 100644 --- a/odoo/tools/safe_eval.py +++ b/odoo/tools/safe_eval.py @@ -86,6 +86,8 @@ def to_opcodes(opnames, _opmap=opmap): # specialised comparisons 'IS_OP', 'CONTAINS_OP', 'DICT_MERGE', 'DICT_UPDATE', + # Basically used in any "generator literal" + 'GEN_START', # added in 3.10 but already removed from 3.11. ])) - _BLACKLIST _SAFE_OPCODES = _EXPR_OPCODES.union(to_opcodes([ diff --git a/odoo/tools/translate.py b/odoo/tools/translate.py index a6735cbaf3dc9..20c6668234ac9 100644 --- a/odoo/tools/translate.py +++ b/odoo/tools/translate.py @@ -371,7 +371,7 @@ class GettextAlias(object): def _get_db(self): # find current DB based on thread/worker db name (see netsvc) - db_name = getattr(threading.currentThread(), 'dbname', None) + db_name = getattr(threading.current_thread(), 'dbname', None) if db_name: return odoo.sql_db.db_connect(db_name) diff --git a/requirements.txt b/requirements.txt index b46ed6a337061..a2e6110a0aabc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,22 +1,22 @@ -Babel==2.6.0 +Babel==2.10.3 chardet==3.0.4 -decorator==4.3.0 +decorator==4.4.2 docutils==0.14 ebaysdk==2.1.5 freezegun==0.3.11; python_version < '3.8' freezegun==0.3.15; python_version >= '3.8' gevent==1.1.2 ; sys_platform != 'win32' and python_version < '3.7' gevent==1.5.0 ; python_version == '3.7' -gevent==20.9.0 ; python_version >= '3.8' +gevent==21.12.0 ; python_version >= '3.8' gevent==1.4.0 ; sys_platform == 'win32' and python_version < '3.7' greenlet==0.4.10 ; python_version < '3.7' greenlet==0.4.15 ; python_version == '3.7' -greenlet==0.4.17 ; python_version > '3.7' -idna==2.6 +greenlet>=1.1.0 ; python_version > '3.7' +idna==2.10 Jinja2==2.10.1; python_version < '3.8' # bullseye version, focal patched 2.10 Jinja2==2.11.2; python_version >= '3.8' -libsass==0.17.0 +libsass==0.21.0 lxml==3.7.1 ; sys_platform != 'win32' and python_version < '3.7' lxml==4.3.2 ; sys_platform != 'win32' and python_version == '3.7' lxml==4.6.1 ; sys_platform != 'win32' and python_version > '3.7' @@ -24,32 +24,62 @@ lxml ; sys_platform == 'win32' Mako==1.0.7 MarkupSafe==1.1.0 num2words==0.5.6 -ofxparse==0.19 -passlib==1.7.1 +ofxparse==0.19; python_version <= '3.9' +ofxparse==0.21; python_version > '3.9' # (Jammy) +passlib==1.7.4 +# https://passlib.readthedocs.io/en/stable/history/ Pillow==5.4.1 ; python_version <= '3.7' and sys_platform != 'win32' Pillow==6.1.0 ; python_version <= '3.7' and sys_platform == 'win32' -Pillow==8.1.1 ; python_version > '3.7' +Pillow==9.0.1 ; python_version > '3.7' polib==1.1.0 -psutil==5.6.6 +psutil==5.9.0 psycopg2==2.7.7; sys_platform != 'win32' and python_version < '3.8' -psycopg2==2.8.5; sys_platform == 'win32' or python_version >= '3.8' +psycopg2==2.9.3; sys_platform == 'win32' or python_version >= '3.8' pydot==1.4.1 python-ldap==3.1.0; sys_platform != 'win32' PyPDF2==1.26.0 pyserial==3.4 -python-dateutil==2.7.3 +python-dateutil>=2.8.1 pytz==2019.1 pyusb==1.0.2 -qrcode==6.1 +qrcode==7.3.1 reportlab==3.5.13; python_version < '3.8' -reportlab==3.5.55; python_version >= '3.8' -requests==2.21.0 +reportlab==3.5.67; python_version >= '3.8' +requests==2.23.0 zeep==3.2.0 python-stdnum==1.8 vobject==0.9.6.1 -Werkzeug==0.16.1 +Werkzeug==0.16.1 ; python_version <= '3.9' +Werkzeug==2.0.2 ; python_version > '3.9' # (Jammy) XlsxWriter==1.1.2 xlwt==1.3.* xlrd==1.1.0; python_version < '3.8' xlrd==1.2.0; python_version >= '3.8' pypiwin32 ; sys_platform == 'win32' +python-imap==1.0.0 +phonenumbers==8.12.14 +#qcloudsms_py +#jingtrang +#python_slugify +#aliyun-python-sdk-core-v3 +web3==5.30.0 +py-solc-x==1.1.1 +debugpy==1.6.2 +ruamel-yaml +pathlib +paramiko==2.8.1 +cachetools==4.2.2 +graphene==3.1 +graphql-server==3.0.0b5 +python-json-logger==2.0.4 +opencv-python +cerberus==1.3.4 +pyquerystring +parse-accept-language +apispec>=4.0.0 +pyecharts>=1.9.1 +colour +firebase_admin +jxmlease +pdfminer.six +statsd \ No newline at end of file