mirror of
https://github.com/bringout/oca-ocb-core.git
synced 2026-04-19 23:32:01 +02:00
19.0 vanilla
This commit is contained in:
parent
0a7ae8db93
commit
991d2234ca
416 changed files with 646602 additions and 300844 deletions
|
|
@ -1,80 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# ruff: noqa: E402, F401
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
""" OpenERP core library."""
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# odoo must be a namespace package for odoo.addons to become one too
|
||||
# https://packaging.python.org/guides/packaging-namespace-packages/
|
||||
# ----------------------------------------------------------
|
||||
import pkgutil
|
||||
import os.path
|
||||
__path__ = [
|
||||
os.path.abspath(path)
|
||||
for path in pkgutil.extend_path(__path__, __name__)
|
||||
]
|
||||
|
||||
import sys
|
||||
MIN_PY_VERSION = (3, 10)
|
||||
MAX_PY_VERSION = (3, 13)
|
||||
assert sys.version_info > MIN_PY_VERSION, f"Outdated python version detected, Odoo requires Python >= {'.'.join(map(str, MIN_PY_VERSION))} to run."
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# Shortcuts
|
||||
# ----------------------------------------------------------
|
||||
# The hard-coded super-user id (a.k.a. administrator, or root user).
|
||||
SUPERUSER_ID = 1
|
||||
|
||||
|
||||
def registry(database_name=None):
|
||||
"""
|
||||
Return the model registry for the given database, or the database mentioned
|
||||
on the current thread. If the registry does not exist yet, it is created on
|
||||
the fly.
|
||||
"""
|
||||
import warnings # noqa: PLC0415
|
||||
warnings.warn("Use directly odoo.modules.registry.Registry", DeprecationWarning, 2)
|
||||
if database_name is None:
|
||||
import threading
|
||||
database_name = threading.current_thread().dbname
|
||||
return modules.registry.Registry(database_name)
|
||||
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# Import tools to patch code and libraries
|
||||
# required to do as early as possible for evented and timezone
|
||||
# ----------------------------------------------------------
|
||||
from . import _monkeypatches
|
||||
_monkeypatches.patch_all()
|
||||
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# Imports
|
||||
# ----------------------------------------------------------
|
||||
from . import upgrade # this namespace must be imported first
|
||||
from . import addons
|
||||
from . import conf
|
||||
from . import loglevels
|
||||
from . import modules
|
||||
from . import netsvc
|
||||
from . import osv
|
||||
from . import release
|
||||
from . import service
|
||||
from . import sql_db
|
||||
from . import tools
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# Model classes, fields, api decorators, and translations
|
||||
# ----------------------------------------------------------
|
||||
from . import models
|
||||
from . import fields
|
||||
from . import api
|
||||
from odoo.tools.translate import _, _lt
|
||||
from odoo.fields import Command
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# Other imports, which may require stuff from above
|
||||
# ----------------------------------------------------------
|
||||
from . import cli
|
||||
from . import http
|
||||
|
|
@ -1,39 +1,67 @@
|
|||
# ruff: noqa: F401, PLC0415
|
||||
# ignore import not at top of the file
|
||||
"""Lazy module monkeypatcher
|
||||
|
||||
Submodules should be named after the module (stdlib or third-party) they need
|
||||
to patch, and should define a `patch_module` function.
|
||||
|
||||
This function will be called either immediately if the module to patch is
|
||||
already imported when the monkey patcher runs, or right after that module is
|
||||
imported otherwise.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import pkgutil
|
||||
import sys
|
||||
import time
|
||||
from .evented import patch_evented
|
||||
from types import ModuleType, SimpleNamespace
|
||||
|
||||
|
||||
def set_timezone_utc():
|
||||
class PatchImportHook:
|
||||
"""Register hooks that are run on import."""
|
||||
|
||||
def __init__(self):
|
||||
self.hooks = set()
|
||||
|
||||
def add_hook(self, fullname: str) -> None:
|
||||
"""Register a hook after a module is loaded.
|
||||
If already loaded, run hook immediately."""
|
||||
self.hooks.add(fullname)
|
||||
if fullname in sys.modules:
|
||||
patch_module(fullname)
|
||||
|
||||
def find_spec(self, fullname, path=None, target=None):
|
||||
if fullname not in self.hooks:
|
||||
return None # let python use another import hook to import this fullname
|
||||
|
||||
# skip all finders before this one
|
||||
idx = sys.meta_path.index(self)
|
||||
for finder in sys.meta_path[idx + 1:]:
|
||||
spec = finder.find_spec(fullname, path, target)
|
||||
if spec is not None:
|
||||
# we found a spec, change the loader
|
||||
|
||||
def exec_module(module: ModuleType, exec_module=spec.loader.exec_module) -> None:
|
||||
exec_module(module)
|
||||
patch_module(module.__name__)
|
||||
|
||||
spec.loader = SimpleNamespace(create_module=spec.loader.create_module, exec_module=exec_module)
|
||||
return spec
|
||||
raise ImportError(f"Could not load the module {fullname!r} to patch")
|
||||
|
||||
|
||||
HOOK_IMPORT = PatchImportHook()
|
||||
sys.meta_path.insert(0, HOOK_IMPORT)
|
||||
|
||||
|
||||
def patch_init() -> None:
|
||||
os.environ['TZ'] = 'UTC' # Set the timezone
|
||||
if hasattr(time, 'tzset'):
|
||||
time.tzset()
|
||||
|
||||
for submodule in pkgutil.iter_modules(__path__):
|
||||
HOOK_IMPORT.add_hook(submodule.name)
|
||||
|
||||
def patch_all():
|
||||
patch_evented()
|
||||
set_timezone_utc()
|
||||
|
||||
from .codecs import patch_codecs
|
||||
patch_codecs()
|
||||
from .email import patch_email
|
||||
patch_email()
|
||||
from .mimetypes import patch_mimetypes
|
||||
patch_mimetypes()
|
||||
from .pytz import patch_pytz
|
||||
patch_pytz()
|
||||
from .literal_eval import patch_literal_eval
|
||||
patch_literal_eval()
|
||||
from .lxml import patch_lxml
|
||||
patch_lxml()
|
||||
from .num2words import patch_num2words
|
||||
patch_num2words()
|
||||
from .stdnum import patch_stdnum
|
||||
patch_stdnum()
|
||||
from .urllib3 import patch_urllib3
|
||||
patch_urllib3()
|
||||
from .werkzeug_urls import patch_werkzeug
|
||||
patch_werkzeug()
|
||||
from .zeep import patch_zeep
|
||||
patch_zeep()
|
||||
def patch_module(name: str) -> None:
|
||||
module = importlib.import_module(f'.{name}', __name__)
|
||||
module.patch_module()
|
||||
|
|
|
|||
|
|
@ -28,5 +28,5 @@ def literal_eval(expr):
|
|||
return orig_literal_eval(expr)
|
||||
|
||||
|
||||
def patch_literal_eval():
|
||||
def patch_module():
|
||||
ast.literal_eval = literal_eval
|
||||
9
odoo-bringout-oca-ocb-base/odoo/_monkeypatches/bs4.py
Normal file
9
odoo-bringout-oca-ocb-base/odoo/_monkeypatches/bs4.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import bs4
|
||||
import warnings
|
||||
|
||||
|
||||
def patch_module():
|
||||
if hasattr(bs4, 'XMLParsedAsHTMLWarning'):
|
||||
# ofxparse use an html parser to parse ofx xml files and triggers a
|
||||
# warning since bs4 4.11.0 https://github.com/jseutter/ofxparse/issues/170
|
||||
warnings.filterwarnings('ignore', category=bs4.XMLParsedAsHTMLWarning)
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
import codecs
|
||||
import encodings.aliases
|
||||
import re
|
||||
|
||||
import babel.core
|
||||
|
||||
|
||||
def patch_codecs():
|
||||
# ---------------------------------------------------------
|
||||
# some charset are known by Python under a different name
|
||||
# ---------------------------------------------------------
|
||||
|
||||
encodings.aliases.aliases['874'] = 'cp874'
|
||||
encodings.aliases.aliases['windows_874'] = 'cp874'
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# alias hebrew iso-8859-8-i and iso-8859-8-e on iso-8859-8
|
||||
# https://bugs.python.org/issue18624
|
||||
# ---------------------------------------------------------
|
||||
|
||||
iso8859_8 = codecs.lookup('iso8859_8')
|
||||
iso8859_8ie_re = re.compile(r'iso[-_]?8859[-_]8[-_]?[ei]', re.IGNORECASE)
|
||||
codecs.register(lambda charset: iso8859_8 if iso8859_8ie_re.match(charset) else None)
|
||||
|
||||
# To remove when corrected in Babel
|
||||
babel.core.LOCALE_ALIASES['nb'] = 'nb_NO'
|
||||
13
odoo-bringout-oca-ocb-base/odoo/_monkeypatches/csv.py
Normal file
13
odoo-bringout-oca-ocb-base/odoo/_monkeypatches/csv.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import csv
|
||||
|
||||
|
||||
def patch_module():
|
||||
""" The default limit for CSV fields in the module is 128KiB,
|
||||
which is not quite sufficient to import images to store
|
||||
in attachment. 500MiB is a bit overkill, but better safe
|
||||
than sorry I guess
|
||||
"""
|
||||
class UNIX_LINE_TERMINATOR(csv.excel):
|
||||
lineterminator = '\n'
|
||||
csv.field_size_limit(500 * 1024 * 1024)
|
||||
csv.register_dialect("UNIX", UNIX_LINE_TERMINATOR)
|
||||
29
odoo-bringout-oca-ocb-base/odoo/_monkeypatches/docutils.py
Normal file
29
odoo-bringout-oca-ocb-base/odoo/_monkeypatches/docutils.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"""
|
||||
The docstrings can use many more roles and directives than the one
|
||||
present natively in docutils. That's because we use Sphinx to render
|
||||
them in the documentation, and Sphinx defines the "Python Domain", a set
|
||||
of additional rules and directive to understand the python language.
|
||||
|
||||
It is not desirable to add a dependency on Sphinx in community, as it is
|
||||
a *too big* dependency.
|
||||
|
||||
The following code adds a bunch of dummy elements for the missing roles
|
||||
and directives, so docutils is able to parse them with no warning.
|
||||
"""
|
||||
|
||||
import docutils.nodes
|
||||
import docutils.parsers.rst.directives.admonitions
|
||||
|
||||
|
||||
def _role_literal(name, rawtext, text, lineno, inliner, options=None, content=None):
|
||||
literal = docutils.nodes.literal(rawtext, text)
|
||||
return [literal], []
|
||||
|
||||
|
||||
def patch_module():
|
||||
for role in ('attr', 'class', 'func', 'meth', 'ref', 'const', 'samp', 'term'):
|
||||
docutils.parsers.rst.roles.register_local_role(role, _role_literal)
|
||||
|
||||
for directive in ('attribute', 'deprecated'):
|
||||
docutils.parsers.rst.directives.register_directive(
|
||||
directive, docutils.parsers.rst.directives.admonitions.Note)
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
from email._policybase import _PolicyBase
|
||||
|
||||
|
||||
def patch_email():
|
||||
def patch_module():
|
||||
def policy_clone(self, **kwargs):
|
||||
for arg in kwargs:
|
||||
if arg.startswith("_") or "__" in arg:
|
||||
|
|
|
|||
31
odoo-bringout-oca-ocb-base/odoo/_monkeypatches/locale.py
Normal file
31
odoo-bringout-oca-ocb-base/odoo/_monkeypatches/locale.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import locale
|
||||
import time
|
||||
import datetime
|
||||
|
||||
|
||||
def patch_module():
|
||||
if not hasattr(locale, 'D_FMT'):
|
||||
locale.D_FMT = 1
|
||||
|
||||
if not hasattr(locale, 'T_FMT'):
|
||||
locale.T_FMT = 2
|
||||
|
||||
if not hasattr(locale, 'nl_langinfo'):
|
||||
def nl_langinfo(param):
|
||||
if param == locale.D_FMT:
|
||||
val = time.strptime('30/12/2004', '%d/%m/%Y')
|
||||
dt = datetime.datetime(*val[:-2])
|
||||
format_date = dt.strftime('%x')
|
||||
for x, y in [('30', '%d'), ('12', '%m'), ('2004', '%Y'), ('04', '%Y')]:
|
||||
format_date = format_date.replace(x, y)
|
||||
return format_date
|
||||
if param == locale.T_FMT:
|
||||
val = time.strptime('13:24:56', '%H:%M:%S')
|
||||
dt = datetime.datetime(*val[:-2])
|
||||
format_time = dt.strftime('%X')
|
||||
for x, y in [('13', '%H'), ('24', '%M'), ('56', '%S')]:
|
||||
format_time = format_time.replace(x, y)
|
||||
return format_time
|
||||
locale.nl_langinfo = nl_langinfo
|
||||
|
|
@ -6,7 +6,7 @@ from importlib.metadata import version
|
|||
from odoo.tools import parse_version
|
||||
|
||||
|
||||
def patch_lxml():
|
||||
def patch_module():
|
||||
# between these versions having a couple data urls in a style attribute
|
||||
# or style node removes the attribute or node erroneously
|
||||
if parse_version("4.6.0") <= parse_version(version('lxml')) < parse_version("5.2.0"):
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import mimetypes
|
||||
|
||||
|
||||
def patch_mimetypes():
|
||||
def patch_module():
|
||||
# if extension is already knows, the new definition will remplace the existing one
|
||||
# Add potentially missing (older ubuntu) font mime types
|
||||
mimetypes.add_type('application/font-woff', '.woff')
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from collections import OrderedDict
|
|||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from math import floor
|
||||
|
||||
from odoo import MIN_PY_VERSION
|
||||
from odoo.release import MIN_PY_VERSION
|
||||
|
||||
# The following section of the code is used to monkey patch
|
||||
# the Arabic class of num2words package as there are some problems
|
||||
|
|
@ -145,7 +145,7 @@ class Num2Word_Base:
|
|||
|
||||
def to_cardinal_float(self, value):
|
||||
try:
|
||||
float(value) == value
|
||||
_ = float(value) == value
|
||||
except (ValueError, TypeError, AssertionError, AttributeError):
|
||||
raise TypeError(self.errmsg_nonnum % value)
|
||||
|
||||
|
|
@ -971,7 +971,7 @@ class NumberToWords_BG(Num2Word_Base):
|
|||
return ret_minus + ''.join(ret)
|
||||
|
||||
|
||||
def patch_num2words():
|
||||
def patch_module():
|
||||
try:
|
||||
import num2words # noqa: PLC0415
|
||||
except ImportError:
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ _tz_mapping = {
|
|||
original_pytz_timezone = pytz.timezone
|
||||
|
||||
|
||||
def patch_pytz():
|
||||
def patch_module():
|
||||
def timezone(name):
|
||||
if name not in pytz.all_timezones_set and name in _tz_mapping:
|
||||
name = _tz_mapping[name]
|
||||
|
|
|
|||
6
odoo-bringout-oca-ocb-base/odoo/_monkeypatches/re.py
Normal file
6
odoo-bringout-oca-ocb-base/odoo/_monkeypatches/re.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import re
|
||||
|
||||
|
||||
def patch_module():
|
||||
""" Default is 512, a little too small for odoo """
|
||||
re._MAXCACHE = 4096
|
||||
|
|
@ -1,16 +1,30 @@
|
|||
"""
|
||||
Running mode flags (gevent, prefork)
|
||||
"""Patcher for any change not strictly related to an stdlib module
|
||||
|
||||
This should be imported as early as possible.
|
||||
It will initialize the `odoo.evented` variable.
|
||||
"""
|
||||
import odoo
|
||||
|
||||
import codecs
|
||||
import encodings.aliases
|
||||
import re
|
||||
import sys
|
||||
|
||||
import babel.core
|
||||
|
||||
import odoo
|
||||
|
||||
|
||||
def patch_module():
|
||||
patch_evented()
|
||||
patch_codecs()
|
||||
|
||||
|
||||
odoo.evented = False
|
||||
|
||||
|
||||
def patch_evented():
|
||||
"""Running mode flags (gevent, prefork)
|
||||
|
||||
This should be executed early. It will initialize the `odoo.evented` variable.
|
||||
"""
|
||||
if odoo.evented or not (len(sys.argv) > 1 and sys.argv[1] == 'gevent'):
|
||||
return
|
||||
sys.argv.remove('gevent')
|
||||
|
|
@ -35,5 +49,27 @@ def patch_evented():
|
|||
else:
|
||||
raise psycopg2.OperationalError(
|
||||
"Bad result from poll: %r" % state)
|
||||
|
||||
psycopg2.extensions.set_wait_callback(gevent_wait_callback)
|
||||
odoo.evented = True
|
||||
|
||||
|
||||
def patch_codecs():
|
||||
# ---------------------------------------------------------
|
||||
# some charset are known by Python under a different name
|
||||
# ---------------------------------------------------------
|
||||
|
||||
encodings.aliases.aliases['874'] = 'cp874'
|
||||
encodings.aliases.aliases['windows_874'] = 'cp874'
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# alias hebrew iso-8859-8-i and iso-8859-8-e on iso-8859-8
|
||||
# https://bugs.python.org/issue18624
|
||||
# ---------------------------------------------------------
|
||||
|
||||
iso8859_8 = codecs.lookup('iso8859_8')
|
||||
iso8859_8ie_re = re.compile(r'iso[-_]?8859[-_]8[-_]?[ei]', re.IGNORECASE)
|
||||
codecs.register(lambda charset: iso8859_8 if iso8859_8ie_re.match(charset) else None)
|
||||
|
||||
# To remove when corrected in Babel
|
||||
babel.core.LOCALE_ALIASES['nb'] = 'nb_NO'
|
||||
|
|
@ -48,7 +48,7 @@ def new_get_soap_client(wsdlurl, timeout=30):
|
|||
return _soap_clients[(wsdlurl, timeout)]
|
||||
|
||||
|
||||
def patch_stdnum():
|
||||
def patch_module():
|
||||
try:
|
||||
from stdnum import util
|
||||
except ImportError:
|
||||
|
|
|
|||
|
|
@ -8,5 +8,5 @@ def pool_init(self, *args, **kwargs):
|
|||
self.pool_classes_by_scheme = {**self.pool_classes_by_scheme}
|
||||
|
||||
|
||||
def patch_urllib3():
|
||||
def patch_module():
|
||||
PoolManager.__init__ = pool_init
|
||||
|
|
|
|||
|
|
@ -1040,8 +1040,8 @@ def url_join(
|
|||
return url_unparse((scheme, netloc, path, query, fragment))
|
||||
|
||||
|
||||
def patch_werkzeug():
|
||||
from ..tools.json import scriptsafe # noqa: PLC0415
|
||||
def patch_module():
|
||||
from odoo.tools.json import scriptsafe
|
||||
Request.json_module = Response.json_module = scriptsafe
|
||||
|
||||
FileStorage.save = lambda self, dst, buffer_size=(1 << 20): copyfileobj(self.stream, dst, buffer_size)
|
||||
20
odoo-bringout-oca-ocb-base/odoo/_monkeypatches/xlrd.py
Normal file
20
odoo-bringout-oca-ocb-base/odoo/_monkeypatches/xlrd.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
def patch_module():
|
||||
try:
|
||||
from xlrd import xlsx # noqa: PLC0415
|
||||
except ImportError:
|
||||
xlsx = None
|
||||
else:
|
||||
from lxml import etree # noqa: PLC0415
|
||||
# xlrd.xlsx supports defusedxml, defusedxml's etree interface is broken
|
||||
# (missing ElementTree and thus ElementTree.iter) which causes a fallback to
|
||||
# Element.getiterator(), triggering a warning before 3.9 and an error from 3.9.
|
||||
#
|
||||
# Historically we had defusedxml installed because zeep had a hard dep on
|
||||
# it. They have dropped it as of 4.1.0 which we now require (since 18.0),
|
||||
# but keep this patch for now as Odoo might get updated in a legacy env
|
||||
# which still has defused.
|
||||
#
|
||||
# Directly instruct xlsx to use lxml as we have a hard dependency on that.
|
||||
xlsx.ET = etree
|
||||
xlsx.ET_has_iterparse = True
|
||||
xlsx.Element_has_iter = True
|
||||
22
odoo-bringout-oca-ocb-base/odoo/_monkeypatches/xlsxwriter.py
Normal file
22
odoo-bringout-oca-ocb-base/odoo/_monkeypatches/xlsxwriter.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
"""
|
||||
Patch xlsxwriter to add some sanitization to respect the excel sheet name
|
||||
restrictions as the sheet name is often translatable, can not control the input
|
||||
"""
|
||||
import re
|
||||
|
||||
import xlsxwriter
|
||||
|
||||
|
||||
class PatchedXlsxWorkbook(xlsxwriter.Workbook):
|
||||
def add_worksheet(self, name=None, worksheet_class=None):
|
||||
if name:
|
||||
# invalid Excel character: []:*?/\
|
||||
name = re.sub(r'[\[\]:*?/\\]', '', name)
|
||||
|
||||
# maximum size is 31 characters
|
||||
name = name[:31]
|
||||
return super().add_worksheet(name, worksheet_class=worksheet_class)
|
||||
|
||||
|
||||
def patch_module():
|
||||
xlsxwriter.Workbook = PatchedXlsxWorkbook
|
||||
21
odoo-bringout-oca-ocb-base/odoo/_monkeypatches/xlwt.py
Normal file
21
odoo-bringout-oca-ocb-base/odoo/_monkeypatches/xlwt.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
"""
|
||||
Patch xlwt to add some sanitization to respect the excel sheet name
|
||||
restrictions as the sheet name is often translatable, can not control the input
|
||||
"""
|
||||
import re
|
||||
|
||||
import xlwt
|
||||
|
||||
|
||||
class PatchedWorkbook(xlwt.Workbook):
|
||||
def add_sheet(self, name, cell_overwrite_ok=False):
|
||||
# invalid Excel character: []:*?/\
|
||||
name = re.sub(r'[\[\]:*?/\\]', '', name)
|
||||
|
||||
# maximum size is 31 characters
|
||||
name = name[:31]
|
||||
return super().add_sheet(name, cell_overwrite_ok=cell_overwrite_ok)
|
||||
|
||||
|
||||
def patch_module():
|
||||
xlwt.Workbook = PatchedWorkbook
|
||||
|
|
@ -2,7 +2,7 @@ from zeep.xsd import visitor
|
|||
from zeep.xsd.const import xsd_ns
|
||||
|
||||
|
||||
def patch_zeep():
|
||||
def patch_module():
|
||||
# see https://github.com/mvantellingen/python-zeep/issues/1185
|
||||
if visitor.tags.notation.localname != 'notation':
|
||||
visitor.tags.notation = xsd_ns('notation')
|
||||
|
|
|
|||
1
odoo-bringout-oca-ocb-base/odoo/addons/.gitkeep
Normal file
1
odoo-bringout-oca-ocb-base/odoo/addons/.gitkeep
Normal file
|
|
@ -0,0 +1 @@
|
|||
(python namespace)
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import controllers
|
||||
from . import models
|
||||
from . import report
|
||||
from . import wizard
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ The kernel of Odoo, needed for all installation.
|
|||
'data/ir_demo_data.xml',
|
||||
'security/base_groups.xml',
|
||||
'security/base_security.xml',
|
||||
'wizard/wizard_ir_model_menu_create_views.xml',
|
||||
'views/base_menus.xml',
|
||||
'views/decimal_precision_views.xml',
|
||||
'views/res_config_views.xml',
|
||||
|
|
@ -67,7 +68,9 @@ The kernel of Odoo, needed for all installation.
|
|||
'views/res_bank_views.xml',
|
||||
'views/res_country_views.xml',
|
||||
'views/res_currency_views.xml',
|
||||
'views/res_groups_views.xml',
|
||||
'views/res_users_views.xml',
|
||||
'views/res_users_apikeys_views.xml',
|
||||
'views/res_device_views.xml',
|
||||
'views/res_users_identitycheck_views.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
|
|
@ -91,5 +94,6 @@ The kernel of Odoo, needed for all installation.
|
|||
'installable': True,
|
||||
'auto_install': True,
|
||||
'post_init_hook': 'post_init',
|
||||
'author': 'Odoo S.A.',
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
from . import rpc
|
||||
|
|
@ -1,178 +0,0 @@
|
|||
import re
|
||||
import sys
|
||||
import traceback
|
||||
import xmlrpc.client
|
||||
from datetime import date, datetime
|
||||
|
||||
from collections import defaultdict
|
||||
from markupsafe import Markup
|
||||
|
||||
import odoo
|
||||
from odoo.http import Controller, route, dispatch_rpc, request, Response
|
||||
from odoo.fields import Date, Datetime, Command
|
||||
from odoo.tools import lazy
|
||||
from odoo.tools.misc import frozendict
|
||||
|
||||
# ==========================================================
|
||||
# XML-RPC helpers
|
||||
# ==========================================================
|
||||
|
||||
# XML-RPC fault codes. Some care must be taken when changing these: the
|
||||
# constants are also defined client-side and must remain in sync.
|
||||
# User code must use the exceptions defined in ``odoo.exceptions`` (not
|
||||
# create directly ``xmlrpc.client.Fault`` objects).
|
||||
RPC_FAULT_CODE_CLIENT_ERROR = 1 # indistinguishable from app. error.
|
||||
RPC_FAULT_CODE_APPLICATION_ERROR = 1
|
||||
RPC_FAULT_CODE_WARNING = 2
|
||||
RPC_FAULT_CODE_ACCESS_DENIED = 3
|
||||
RPC_FAULT_CODE_ACCESS_ERROR = 4
|
||||
|
||||
# 0 to 31, excluding tab, newline, and carriage return
|
||||
CONTROL_CHARACTERS = dict.fromkeys(set(range(32)) - {9, 10, 13})
|
||||
|
||||
|
||||
def xmlrpc_handle_exception_int(e):
|
||||
if isinstance(e, odoo.exceptions.RedirectWarning):
|
||||
fault = xmlrpc.client.Fault(RPC_FAULT_CODE_WARNING, str(e))
|
||||
elif isinstance(e, odoo.exceptions.AccessError):
|
||||
fault = xmlrpc.client.Fault(RPC_FAULT_CODE_ACCESS_ERROR, str(e))
|
||||
elif isinstance(e, odoo.exceptions.AccessDenied):
|
||||
fault = xmlrpc.client.Fault(RPC_FAULT_CODE_ACCESS_DENIED, str(e))
|
||||
elif isinstance(e, odoo.exceptions.UserError):
|
||||
fault = xmlrpc.client.Fault(RPC_FAULT_CODE_WARNING, str(e))
|
||||
else:
|
||||
info = sys.exc_info()
|
||||
formatted_info = "".join(traceback.format_exception(*info))
|
||||
fault = xmlrpc.client.Fault(RPC_FAULT_CODE_APPLICATION_ERROR, formatted_info)
|
||||
|
||||
return dumps(fault)
|
||||
|
||||
|
||||
def xmlrpc_handle_exception_string(e):
|
||||
if isinstance(e, odoo.exceptions.RedirectWarning):
|
||||
fault = xmlrpc.client.Fault('warning -- Warning\n\n' + str(e), '')
|
||||
elif isinstance(e, odoo.exceptions.MissingError):
|
||||
fault = xmlrpc.client.Fault('warning -- MissingError\n\n' + str(e), '')
|
||||
elif isinstance(e, odoo.exceptions.AccessError):
|
||||
fault = xmlrpc.client.Fault('warning -- AccessError\n\n' + str(e), '')
|
||||
elif isinstance(e, odoo.exceptions.AccessDenied):
|
||||
fault = xmlrpc.client.Fault('AccessDenied', str(e))
|
||||
elif isinstance(e, odoo.exceptions.UserError):
|
||||
fault = xmlrpc.client.Fault('warning -- UserError\n\n' + str(e), '')
|
||||
#InternalError
|
||||
else:
|
||||
info = sys.exc_info()
|
||||
formatted_info = "".join(traceback.format_exception(*info))
|
||||
fault = xmlrpc.client.Fault(odoo.tools.exception_to_unicode(e), formatted_info)
|
||||
|
||||
return dumps(fault)
|
||||
|
||||
|
||||
class OdooMarshaller(xmlrpc.client.Marshaller):
|
||||
dispatch = dict(xmlrpc.client.Marshaller.dispatch)
|
||||
|
||||
def dump_frozen_dict(self, value, write):
|
||||
value = dict(value)
|
||||
self.dump_struct(value, write)
|
||||
|
||||
# By default, in xmlrpc, bytes are converted to xmlrpc.client.Binary object.
|
||||
# Historically, odoo is sending binary as base64 string.
|
||||
# In python 3, base64.b64{de,en}code() methods now works on bytes.
|
||||
def dump_bytes(self, value, write):
|
||||
self.dump_unicode(value.decode(), write)
|
||||
|
||||
def dump_datetime(self, value, write):
|
||||
# override to marshall as a string for backwards compatibility
|
||||
value = Datetime.to_string(value)
|
||||
self.dump_unicode(value, write)
|
||||
|
||||
# convert date objects to strings in iso8061 format.
|
||||
def dump_date(self, value, write):
|
||||
value = Date.to_string(value)
|
||||
self.dump_unicode(value, write)
|
||||
|
||||
def dump_lazy(self, value, write):
|
||||
v = value._value
|
||||
return self.dispatch[type(v)](self, v, write)
|
||||
|
||||
def dump_unicode(self, value, write):
|
||||
# XML 1.0 disallows control characters, remove them otherwise they break clients
|
||||
return super().dump_unicode(value.translate(CONTROL_CHARACTERS), write)
|
||||
|
||||
dispatch[frozendict] = dump_frozen_dict
|
||||
dispatch[bytes] = dump_bytes
|
||||
dispatch[datetime] = dump_datetime
|
||||
dispatch[date] = dump_date
|
||||
dispatch[lazy] = dump_lazy
|
||||
dispatch[str] = dump_unicode
|
||||
dispatch[Command] = dispatch[int]
|
||||
dispatch[defaultdict] = dispatch[dict]
|
||||
dispatch[Markup] = lambda self, value, write: self.dispatch[str](self, str(value), write)
|
||||
|
||||
|
||||
def dumps(params: list | tuple | xmlrpc.client.Fault) -> str:
|
||||
response = OdooMarshaller(allow_none=False).dumps(params)
|
||||
return f"""\
|
||||
<?xml version="1.0"?>
|
||||
<methodResponse>
|
||||
{response}
|
||||
</methodResponse>
|
||||
"""
|
||||
|
||||
# ==========================================================
|
||||
# RPC Controller
|
||||
# ==========================================================
|
||||
|
||||
|
||||
def _check_request():
|
||||
if request.db:
|
||||
request.env.cr.close()
|
||||
|
||||
class RPC(Controller):
|
||||
"""Handle RPC connections."""
|
||||
|
||||
def _xmlrpc(self, service):
|
||||
"""Common method to handle an XML-RPC request."""
|
||||
_check_request()
|
||||
data = request.httprequest.get_data()
|
||||
params, method = xmlrpc.client.loads(data, use_datetime=True)
|
||||
result = dispatch_rpc(service, method, params)
|
||||
return dumps((result,))
|
||||
|
||||
@route("/xmlrpc/<service>", auth="none", methods=["POST"], csrf=False, save_session=False)
|
||||
def xmlrpc_1(self, service):
|
||||
"""XML-RPC service that returns faultCode as strings.
|
||||
|
||||
This entrypoint is historical and non-compliant, but kept for
|
||||
backwards-compatibility.
|
||||
"""
|
||||
_check_request()
|
||||
try:
|
||||
response = self._xmlrpc(service)
|
||||
except Exception as error:
|
||||
error.error_response = Response(
|
||||
response=xmlrpc_handle_exception_string(error),
|
||||
mimetype='text/xml',
|
||||
)
|
||||
raise
|
||||
return Response(response=response, mimetype='text/xml')
|
||||
|
||||
@route("/xmlrpc/2/<service>", auth="none", methods=["POST"], csrf=False, save_session=False)
|
||||
def xmlrpc_2(self, service):
|
||||
"""XML-RPC service that returns faultCode as int."""
|
||||
_check_request()
|
||||
try:
|
||||
response = self._xmlrpc(service)
|
||||
except Exception as error:
|
||||
error.error_response = Response(
|
||||
response=xmlrpc_handle_exception_int(error),
|
||||
mimetype='text/xml',
|
||||
)
|
||||
raise
|
||||
return Response(response=response, mimetype='text/xml')
|
||||
|
||||
@route('/jsonrpc', type='json', auth="none", save_session=False)
|
||||
def jsonrpc(self, service, method, args):
|
||||
""" Method used by client APIs to contact OpenERP. """
|
||||
_check_request()
|
||||
return dispatch_rpc(service, method, args)
|
||||
|
|
@ -3,13 +3,13 @@
|
|||
<data>
|
||||
<record model="ir.module.category" id="module_category_hidden">
|
||||
<field name="name">Technical</field>
|
||||
<field name="sequence">60</field>
|
||||
<field name="sequence">70</field>
|
||||
<field name="visible" eval="0" />
|
||||
</record>
|
||||
|
||||
<record model="ir.module.category" id="module_category_accounting">
|
||||
<field name="name">Accounting</field>
|
||||
<field name="sequence">15</field>
|
||||
<field name="sequence">20</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.module.category" id="module_category_accounting_localizations">
|
||||
|
|
@ -30,12 +30,6 @@
|
|||
<field name="visible" eval="0" />
|
||||
</record>
|
||||
|
||||
<record model="ir.module.category" id="module_category_user_type">
|
||||
<field name="name">User types</field>
|
||||
<field name="description">Helps you manage users.</field>
|
||||
<field name="sequence">9</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.module.category" id="module_category_accounting_accounting">
|
||||
<field name="name">Invoicing</field>
|
||||
<field name="sequence">4</field>
|
||||
|
|
@ -56,14 +50,19 @@
|
|||
<field name="sequence">40</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.module.category" id="module_category_manufacturing">
|
||||
<field name="name">Manufacturing</field>
|
||||
<field name="sequence">30</field>
|
||||
<record model="ir.module.category" id="module_category_supply_chain">
|
||||
<field name="name">Supply Chain</field>
|
||||
<field name="sequence">25</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.module.category" id="module_category_shipping_connectors">
|
||||
<field name="name">Shipping Connectors</field>
|
||||
<field name="sequence">50</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.module.category" id="module_category_website">
|
||||
<field name="name">Website</field>
|
||||
<field name="sequence">35</field>
|
||||
<field name="sequence">10</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.module.category" id="module_category_theme">
|
||||
|
|
@ -78,6 +77,14 @@
|
|||
<field name="parent_id" eval="False"/>
|
||||
</record>
|
||||
|
||||
<record model="ir.module.category" id="base.module_category_human_resources_referrals">
|
||||
<field name="name">Referrals</field>
|
||||
<field name="description">Helps you manage referrals
|
||||
User : Access to referral, share job, gain points, buy rewards
|
||||
Administrator : edit rewards and more</field>
|
||||
<field name="sequence">11</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.module.category" id="module_category_human_resources_appraisals">
|
||||
<field name="name">Appraisals</field>
|
||||
<field name="description">A user without any rights on Appraisals will be able to see the application, create and manage appraisals for himself and the people he's manager of.</field>
|
||||
|
|
@ -92,7 +99,7 @@
|
|||
|
||||
<record model="ir.module.category" id="module_category_services">
|
||||
<field name="name">Services</field>
|
||||
<field name="sequence">10</field>
|
||||
<field name="sequence">15</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.module.category" id="module_category_services_helpdesk">
|
||||
|
|
@ -111,14 +118,14 @@
|
|||
<field name="parent_id" ref="module_category_services"/>
|
||||
</record>
|
||||
|
||||
<record model="ir.module.category" id="module_category_inventory">
|
||||
<field name="name">Inventory</field>
|
||||
<field name="sequence">25</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.module.category" id="module_category_productivity">
|
||||
<field name="name">Productivity</field>
|
||||
<field name="sequence">50</field>
|
||||
<field name="sequence">30</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.module.category" id="module_category_esg">
|
||||
<field name="name">ESG</field>
|
||||
<field name="sequence">52</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.module.category" id="module_category_customizations">
|
||||
|
|
@ -126,56 +133,20 @@
|
|||
<field name="sequence">55</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.module.category" id="module_category_internet_of_things_(iot)">
|
||||
<field name="name">Internet of Things (IoT)</field>
|
||||
<field name="sequence">60</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.module.category" id="module_category_administration_administration">
|
||||
<field name="name">Administration</field>
|
||||
<field name="parent_id" ref="module_category_administration"/>
|
||||
</record>
|
||||
|
||||
<record model="ir.module.category" id="module_category_usability">
|
||||
<field name="name">Extra Rights</field>
|
||||
<field name="sequence">101</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.module.category" id="module_category_extra">
|
||||
<field name="name">Other Extra Rights</field>
|
||||
<field name="sequence">102</field>
|
||||
</record>
|
||||
|
||||
<!-- add applications to base groups -->
|
||||
<record model="res.groups" id="group_erp_manager">
|
||||
<field name="category_id" ref="module_category_administration_administration"/>
|
||||
</record>
|
||||
<record model="res.groups" id="group_system">
|
||||
<field name="category_id" ref="module_category_administration_administration"/>
|
||||
</record>
|
||||
|
||||
<record model="res.groups" id="group_user">
|
||||
<field name="category_id" ref="module_category_user_type"/>
|
||||
</record>
|
||||
|
||||
<record model="res.groups" id="group_multi_company">
|
||||
<field name="category_id" ref="module_category_usability"/>
|
||||
</record>
|
||||
|
||||
<record model="res.groups" id="group_multi_currency">
|
||||
<field name="category_id" ref="module_category_usability"/>
|
||||
</record>
|
||||
|
||||
<record model="res.groups" id="group_no_one">
|
||||
<field name="category_id" ref="module_category_usability"/>
|
||||
</record>
|
||||
|
||||
<record id="group_portal" model="res.groups">
|
||||
<field name="category_id" ref="module_category_user_type"/>
|
||||
</record>
|
||||
|
||||
<record id="group_public" model="res.groups">
|
||||
<field name="category_id" ref="module_category_user_type"/>
|
||||
</record>
|
||||
|
||||
<record id="group_partner_manager" model="res.groups">
|
||||
<field name="category_id" ref="module_category_usability"/>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@
|
|||
<record model="ir.module.module" id="base.module_mrp_plm">
|
||||
<field name="name">mrp_plm</field>
|
||||
<field name="shortdesc">Product Lifecycle Management (PLM)</field>
|
||||
<field name="category_id" ref="base.module_category_manufacturing_manufacturing"/>
|
||||
<field name="category_id" ref="base.module_category_supply_chain"/>
|
||||
<field name="sequence">155</field>
|
||||
<field name="application" eval="True"/>
|
||||
<field name="summary">PLM, ECOs, Versions</field>
|
||||
|
|
@ -132,7 +132,7 @@
|
|||
<field name="name">quality_control</field>
|
||||
<field name="shortdesc">Quality</field>
|
||||
<field name="sequence">120</field>
|
||||
<field name="category_id" ref="base.module_category_manufacturing_manufacturing"/>
|
||||
<field name="category_id" ref="base.module_category_supply_chain"/>
|
||||
<field name="application" eval="True"/>
|
||||
<field name="summary">Quality Alerts, Control Points</field>
|
||||
<field name="license">OEEL-1</field>
|
||||
|
|
@ -202,7 +202,7 @@
|
|||
<field name="name">stock_barcode</field>
|
||||
<field name="shortdesc">Barcode</field>
|
||||
<field name="sequence">255</field>
|
||||
<field name="category_id" ref="base.module_category_inventory_inventory"/>
|
||||
<field name="category_id" ref="base.module_category_supply_chain"/>
|
||||
<field name="application" eval="True"/>
|
||||
<field name="summary">Barcode scanner for warehouses</field>
|
||||
<field name="license">OEEL-1</field>
|
||||
|
|
@ -214,7 +214,7 @@
|
|||
|
||||
<record model="ir.module.module" id="base.module_voip">
|
||||
<field name="name">voip</field>
|
||||
<field name="shortdesc">VoIP</field>
|
||||
<field name="shortdesc">Phone</field>
|
||||
<field name="sequence">280</field>
|
||||
<field name="category_id" ref="base.module_category_sales_sales"/>
|
||||
<field name="application" eval="True"/>
|
||||
|
|
@ -258,7 +258,7 @@
|
|||
<field name="name">mrp_workorder</field>
|
||||
<field name="sequence">16</field>
|
||||
<field name="shortdesc">MRP II</field>
|
||||
<field name="category_id" ref="base.module_category_manufacturing_manufacturing"/>
|
||||
<field name="category_id" ref="base.module_category_supply_chain"/>
|
||||
<field name="application" eval="True"/>
|
||||
<field name="summary">Work Orders, Planning, Routing</field>
|
||||
<field name="license">OEEL-1</field>
|
||||
|
|
|
|||
|
|
@ -638,7 +638,7 @@ state_id_pb,id,"Papua Barat","PB"
|
|||
state_id_pd,id,"Papua Barat Daya","PD"
|
||||
state_id_ps,id,"Papua Selatan","PS"
|
||||
state_id_pt,id,"Papua Tengah","PT"
|
||||
state_id_pp,id,"Papua Pegunungan","PE"
|
||||
state_id_pe,id,"Papua Pegunungan","PE"
|
||||
state_id_ri,id,"Riau","RI"
|
||||
state_id_sr,id,"Sulawesi Barat","SR"
|
||||
state_id_sn,id,"Sulawesi Selatan","SN"
|
||||
|
|
@ -1047,16 +1047,7 @@ state_tr_79,tr,"Kilis","79"
|
|||
state_tr_80,tr,"Osmaniye","80"
|
||||
state_tr_81,tr,"Düzce","81"
|
||||
state_vn_VN-44,vn,"An Giang","VN-44"
|
||||
state_vn_VN-57,vn,"Bình Dương","VN-57"
|
||||
state_vn_VN-31,vn,"Bình Định","VN-31"
|
||||
state_vn_VN-54,vn,"Bắc Giang","VN-54"
|
||||
state_vn_VN-53,vn,"Bắc Kạn","VN-53"
|
||||
state_vn_VN-55,vn,"Bạc Liêu","VN-55"
|
||||
state_vn_VN-56,vn,"Bắc Ninh","VN-56"
|
||||
state_vn_VN-58,vn,"Bình Phước","VN-58"
|
||||
state_vn_VN-43,vn,"Bà Rịa - Vũng Tàu","VN-43"
|
||||
state_vn_VN-40,vn,"Bình Thuận","VN-40"
|
||||
state_vn_VN-50,vn,"Bến Tre","VN-50"
|
||||
state_vn_VN-04,vn,"Cao Bằng","VN-04"
|
||||
state_vn_VN-59,vn,"Cà Mau","VN-59"
|
||||
state_vn_VN-CT,vn,"TP Cần Thơ","VN-CT"
|
||||
|
|
@ -1064,51 +1055,31 @@ state_vn_VN-71,vn,"Điện Biên","VN-71"
|
|||
state_vn_VN-33,vn,"Đắk Lắk","VN-33"
|
||||
state_vn_VN-DN,vn,"TP Đà Nẵng","VN-DN"
|
||||
state_vn_VN-39,vn,"Đồng Nai","VN-39"
|
||||
state_vn_VN-72,vn,"Đắk Nông","VN-72"
|
||||
state_vn_VN-45,vn,"Đồng Tháp","VN-45"
|
||||
state_vn_VN-30,vn,"Gia Lai","VN-30"
|
||||
state_vn_VN-14,vn,"Hòa Bình","VN-14"
|
||||
state_vn_VN-SG,vn,"TP Hồ Chí Minh","VN-SG"
|
||||
state_vn_VN-61,vn,"Hải Dương","VN-61"
|
||||
state_vn_VN-73,vn,"Hậu Giang","VN-73"
|
||||
state_vn_VN-03,vn,"Hà Giang","VN-03"
|
||||
state_vn_VN-HN,vn,"Hà Nội","VN-HN"
|
||||
state_vn_VN-63,vn,"Hà Nam","VN-63"
|
||||
state_vn_VN-HP,vn,"TP Hải Phòng","VN-HP"
|
||||
state_vn_VN-23,vn,"Hà Tĩnh","VN-23"
|
||||
state_vn_VN-66,vn,"Hưng Yên","VN-66"
|
||||
state_vn_VN-47,vn,"Kiên Giang","VN-47"
|
||||
state_vn_VN-34,vn,"Khánh Hòa","VN-34"
|
||||
state_vn_VN-28,vn,"Kon Tum","VN-28"
|
||||
state_vn_VN-41,vn,"Long An","VN-41"
|
||||
state_vn_VN-02,vn,"Lào Cai","VN-02"
|
||||
state_vn_VN-01,vn,"Lai Châu","VN-01"
|
||||
state_vn_VN-35,vn,"Lâm Đồng","VN-35"
|
||||
state_vn_VN-09,vn,"Lạng Sơn","VN-09"
|
||||
state_vn_VN-22,vn,"Nghệ An","VN-22"
|
||||
state_vn_VN-18,vn,"Ninh Bình","VN-18"
|
||||
state_vn_VN-67,vn,"Nam Định","VN-67"
|
||||
state_vn_VN-36,vn,"Ninh Thuận","VN-36"
|
||||
state_vn_VN-68,vn,"Phú Thọ","VN-68"
|
||||
state_vn_VN-32,vn,"Phú Yên","VN-32"
|
||||
state_vn_VN-24,vn,"Quảng Bình","VN-24"
|
||||
state_vn_VN-13,vn,"Quảng Ninh","VN-13"
|
||||
state_vn_VN-27,vn,"Quảng Nam","VN-27"
|
||||
state_vn_VN-29,vn,"Quảng Ngãi","VN-29"
|
||||
state_vn_VN-25,vn,"Quảng Trị","VN-25"
|
||||
state_vn_VN-05,vn,"Sơn La","VN-05"
|
||||
state_vn_VN-52,vn,"Sóc Trăng","VN-52"
|
||||
state_vn_VN-20,vn,"Thái Bình","VN-20"
|
||||
state_vn_VN-46,vn,"Tiền Giang","VN-46"
|
||||
state_vn_VN-21,vn,"Thanh Hóa","VN-21"
|
||||
state_vn_VN-69,vn,"Thái Nguyên","VN-69"
|
||||
state_vn_VN-37,vn,"Tây Ninh","VN-37"
|
||||
state_vn_VN-07,vn,"Tuyên Quang","VN-07"
|
||||
state_vn_VN-26,vn,"Thừa Thiên - Huế","VN-26"
|
||||
state_vn_VN-51,vn,"Trà Vinh","VN-51"
|
||||
state_vn_VN-49,vn,"Vĩnh Long","VN-49"
|
||||
state_vn_VN-70,vn,"Vĩnh Phúc","VN-70"
|
||||
state_vn_VN-06,vn,"Yên Bái","VN-06"
|
||||
state_SJ,cr,"San José","1"
|
||||
state_A,cr,"Alajuela","2"
|
||||
state_H,cr,"Heredia","4"
|
||||
|
|
@ -1765,6 +1736,141 @@ state_kr_47,kr,"경상북도","KR-47"
|
|||
state_kr_48,kr,"경상남도","KR-48"
|
||||
state_kr_49,kr,"제주특별자치도","KR-49"
|
||||
state_kr_50,kr,"세종특별자치시","KR-50"
|
||||
state_pl_ds,pl,"dolnośląskie","DŚ"
|
||||
state_pl_kp,pl,"kujawsko-pomorskie","KP"
|
||||
state_pl_lb,pl,"lubelskie","LB"
|
||||
state_pl_ls,pl,"lubuskie","LS"
|
||||
state_pl_ld,pl,"łódzkie","ŁD"
|
||||
state_pl_mp,pl,"małopolskie","MP"
|
||||
state_pl_mz,pl,"mazowieckie","MZ"
|
||||
state_pl_op,pl,"opolskie","OP"
|
||||
state_pl_pk,pl,"podkarpackie","PK"
|
||||
state_pl_pl,pl,"podlaskie","PL"
|
||||
state_pl_pm,pl,"pomorskie","PM"
|
||||
state_pl_sl,pl,"śląskie","ŚL"
|
||||
state_pl_sk,pl,"świętokrzyskie","ŚK"
|
||||
state_pl_wm,pl,"warmińsko-mazurskie","WM"
|
||||
state_pl_wp,pl,"wielkopolskie","WP"
|
||||
state_pl_zp,pl,"zachodniopomorskie","ZP"
|
||||
state_pk_ajk,pk,"Azad Jammu and Kashmir","AJK"
|
||||
state_pk_ba,pk,"Balochistan","BA"
|
||||
state_pk_gb,pk,"Gilgit-Baltistan","GB"
|
||||
state_pk_is,pk,"Islamabad Capital Territory","IS/ICT"
|
||||
state_pk_kp,pk,"Khyber Pakhtunkhwa","KP/KPK"
|
||||
state_pk_pb,pk,"Punjab","PB"
|
||||
state_pk_sd,pk,"Sindh","SD"
|
||||
state_iq_01,iq,"Al Anbar","IQ-AN"
|
||||
state_iq_01_ar,iq,"الأنبار","IQ-AN-AR"
|
||||
state_iq_02,iq,"Al Basrah","IQ-BA"
|
||||
state_iq_02_ar,iq,"البصرة","IQ-BA-AR"
|
||||
state_iq_03,iq,"Al Muthanna","IQ-MU"
|
||||
state_iq_03_ar,iq,"المثنى","IQ-MU-AR"
|
||||
state_iq_04,iq,"Al Qādisiyyah","IQ-QA"
|
||||
state_iq_04_ar,iq,"القادسية","IQ-QA-AR"
|
||||
state_iq_05,iq,"Sulaymaniyah","IQ-SU"
|
||||
state_iq_05_ar,iq,"السليمانية","IQ-SU-AR"
|
||||
state_iq_06,iq,"Babil","IQ-BB"
|
||||
state_iq_06_ar,iq,"بابل","IQ-BB-AR"
|
||||
state_iq_07,iq,"Baghdad","IQ-BG"
|
||||
state_iq_07_ar,iq,"بغداد","IQ-BG-AR"
|
||||
state_iq_08,iq,"Duhok","IQ-DA"
|
||||
state_iq_08_ar,iq,"دهوك","IQ-DA-AR"
|
||||
state_iq_09,iq,"Dhi Qar","IQ-DQ"
|
||||
state_iq_09_ar,iq,"ذي قار","IQ-DQ-AR"
|
||||
state_iq_10,iq,"Diyala","IQ-DI"
|
||||
state_iq_10_ar,iq,"ديالى","IQ-DI-AR"
|
||||
state_iq_11,iq,"Erbil","IQ-AR"
|
||||
state_iq_11_ar,iq,"أربيل","IQ-AR-AR"
|
||||
state_iq_12,iq,"Karbala'","IQ-KA"
|
||||
state_iq_12_ar,iq,"كربلاء","IQ-KA-AR"
|
||||
state_iq_13,iq,"Kirkuk","IQ-KI"
|
||||
state_iq_13_ar,iq,"كركوك","IQ-KI-AR"
|
||||
state_iq_14,iq,"Maysan","IQ-MA"
|
||||
state_iq_14_ar,iq,"ميسان","IQ-MA-AR"
|
||||
state_iq_15,iq,"Ninawa","IQ-NI"
|
||||
state_iq_15_ar,iq,"نينوى","IQ-NI-AR"
|
||||
state_iq_16,iq,"Wasit","IQ-WA"
|
||||
state_iq_16_ar,iq,"واسط","IQ-WA-AR"
|
||||
state_iq_17,iq,"Najaf","IQ-NA"
|
||||
state_iq_17_ar,iq,"النجف","IQ-NA-AR"
|
||||
state_iq_18,iq,"Salah Al Din","IQ-SD"
|
||||
state_iq_18_ar,iq,"صلاح الدين","IQ-SD-AR"
|
||||
state_bd_a,bd,"Barishal","BD-A"
|
||||
state_bd_b,bd,"Chattogram","BD-B"
|
||||
state_bd_c,bd,"Dhaka","BD-C"
|
||||
state_bd_d,bd,"Khulna","BD-D"
|
||||
state_bd_e,bd,"Rajshahi","BD-E"
|
||||
state_bd_f,bd,"Rangpur","BD-F"
|
||||
state_bd_g,bd,"Sylhet","BD-G"
|
||||
state_bd_h,bd,"Mymensingh","BD-H"
|
||||
state_at_1,at,"Burgenland","1"
|
||||
state_at_2,at,"Kärnten","2"
|
||||
state_at_3,at,"Niederösterreich","3"
|
||||
state_at_4,at,"Oberösterreich","4"
|
||||
state_at_5,at,"Salzburg","5"
|
||||
state_at_6,at,"Steiermark","6"
|
||||
state_at_7,at,"Tirol","7"
|
||||
state_at_8,at,"Vorarlberg","8"
|
||||
state_at_9,at,"Wien","9"
|
||||
state_tw_chh,tw,彰化縣,CHH
|
||||
state_tw_cic,tw,嘉義市,CIC
|
||||
state_tw_cih,tw,嘉義縣,CIH
|
||||
state_tw_hch,tw,新竹縣,HCH
|
||||
state_tw_hct,tw,新竹市,HCT
|
||||
state_tw_hlh,tw,花蓮縣,HLH
|
||||
state_tw_ilh,tw,宜蘭縣,ILH
|
||||
state_tw_khc,tw,高雄市,KHC
|
||||
state_tw_klc,tw,基隆市,KLC
|
||||
state_tw_kmc,tw,金門縣,KMC
|
||||
state_tw_lcc,tw,連江縣,LCC
|
||||
state_tw_mlh,tw,苗栗縣,MLH
|
||||
state_tw_ntc,tw,南投縣,NTC
|
||||
state_tw_ntpc,tw,新北市,NTPC
|
||||
state_tw_phc,tw,澎湖縣,PHC
|
||||
state_tw_pth,tw,屏東縣,PTH
|
||||
state_tw_tcc,tw,台中市,TCC
|
||||
state_tw_tnh,tw,台南市,TNH
|
||||
state_tw_tpc,tw,台北市,TPC
|
||||
state_tw_tth,tw,台東縣,TTH
|
||||
state_tw_tyc,tw,桃園市,TYC
|
||||
state_tw_ylh,tw,雲林縣,YLH
|
||||
state_ng_ab,ng,"Abia","NG-AB"
|
||||
state_ng_ad,ng,"Adamawa","NG-AD"
|
||||
state_ng_ak,ng,"Akwa Ibom","NG-AK"
|
||||
state_ng_an,ng,"Anambra","NG-AN"
|
||||
state_ng_ba,ng,"Bauchi","NG-BA"
|
||||
state_ng_by,ng,"Bayelsa","NG-BY"
|
||||
state_ng_be,ng,"Benue","NG-BE"
|
||||
state_ng_bo,ng,"Borno","NG-BO"
|
||||
state_ng_cr,ng,"Cross River","NG-CR"
|
||||
state_ng_de,ng,"Delta","NG-DE"
|
||||
state_ng_eb,ng,"Ebonyi","NG-EB"
|
||||
state_ng_ed,ng,"Edo","NG-ED"
|
||||
state_ng_ek,ng,"Ekiti","NG-EK"
|
||||
state_ng_en,ng,"Enugu","NG-EN"
|
||||
state_ng_go,ng,"Gombe","NG-GO"
|
||||
state_ng_im,ng,"Imo","NG-IM"
|
||||
state_ng_ji,ng,"Jigawa","NG-JI"
|
||||
state_ng_ka,ng,"Kaduna","NG-KD"
|
||||
state_ng_kn,ng,"Kano","NG-KN"
|
||||
state_ng_kt,ng,"Katsina","NG-KT"
|
||||
state_ng_ke,ng,"Kebbi","NG-KE"
|
||||
state_ng_ko,ng,"Kogi","NG-KO"
|
||||
state_ng_kw,ng,"Kwara","NG-KW"
|
||||
state_ng_la,ng,"Lagos","NG-LA"
|
||||
state_ng_na,ng,"Nasarawa","NG-NA"
|
||||
state_ng_ni,ng,"Niger","NG-NI"
|
||||
state_ng_og,ng,"Ogun","NG-OG"
|
||||
state_ng_on,ng,"Ondo","NG-ON"
|
||||
state_ng_os,ng,"Osun","NG-OS"
|
||||
state_ng_oy,ng,"Oyo","NG-OY"
|
||||
state_ng_pl,ng,"Plateau","NG-PL"
|
||||
state_ng_ri,ng,"Rivers","NG-RI"
|
||||
state_ng_so,ng,"Sokoto","NG-SO"
|
||||
state_ng_ta,ng,"Taraba","NG-TA"
|
||||
state_ng_yo,ng,"Yobe","NG-YO"
|
||||
state_ng_za,ng,"Zamfara","NG-ZA"
|
||||
state_ng_fc,ng,"FCT","NG-FC"
|
||||
state_be_1,be,"Antwerp","VAN"
|
||||
state_be_2,be,"Limburg","VLI"
|
||||
state_be_3,be,"East Flanders","VOV"
|
||||
|
|
@ -1775,6 +1881,14 @@ state_be_7,be,"Hainaut","WHT"
|
|||
state_be_8,be,"Liège","WLG"
|
||||
state_be_9,be,"Luxembourg","WLX"
|
||||
state_be_10,be,"Namur","WNA"
|
||||
state_so_1,so,"Banaadir","BN"
|
||||
state_so_2,so,"Galmudug","GM"
|
||||
state_so_3,so,"Somaliland","SL"
|
||||
state_so_4,so,"Puntland","PL"
|
||||
state_so_5,so,"Jubaland","JL"
|
||||
state_so_6,so,"Hirshabelle","HS"
|
||||
state_so_7,so,"Koonfur Galbeed","KG"
|
||||
state_so_8,so,"Khatumo","SSC"
|
||||
state_bn_b,bn,"Brunei-Muara","B"
|
||||
state_bn_k,bn,"Belait","K"
|
||||
state_bn_t,bn,"Tutong","T"
|
||||
|
|
|
|||
|
|
|
@ -1,93 +1,93 @@
|
|||
"id","name","code","iso_code","direction","grouping","decimal_point","thousands_sep","date_format","time_format","short_time_format","week_start"
|
||||
"base.lang_en","English (US)","en_US","en","Left-to-Right","[3,0]",".",",","%m/%d/%Y","%H:%M:%S","%H:%M","7"
|
||||
"base.lang_am_ET","Amharic / አምሃርኛ","am_ET","am_ET","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%I:%M:%S","%I:%M","7"
|
||||
"base.lang_ar","Arabic / الْعَرَبيّة","ar_001","ar","Right-to-Left","[3,0]",".",",","%d %b, %Y","%I:%M:%S %p","%I:%M","6"
|
||||
"base.lang_ar_SY","Arabic (Syria) / الْعَرَبيّة","ar_SY","ar_SY","Right-to-Left","[3,0]",".",",","%d %b, %Y","%I:%M:%S %p","%I:%M","6"
|
||||
"base.lang_az","Azerbaijani / Azərbaycanca","az_AZ","az","Left-to-Right","[3,0]",","," ","%d.%m.%Y","%H:%M:%S","%H:%M","1"
|
||||
"base.lang_eu_ES","Basque / Euskara","eu_ES","eu_ES","Left-to-Right","[]",",",,"%a, %Y.eko %bren %da","%H:%M:%S","%H:%M","1"
|
||||
"base.lang_be","Belarusian / Беларуская мова","be_BY","be","Left-to-Right","[3,0]",","," ","%d.%m.%Y","%H:%M:%S","%H:%M","1"
|
||||
"base.lang_bn_IN","Bengali / বাংলা","bn_IN","bn_IN","Left-to-Right","[]",",",,"%A %d %b %Y","%I:%M:%S","%I:%M","1"
|
||||
"base.lang_bs_BA","Bosnian / bosanski jezik","bs_BA","bs","Left-to-Right","[3,0]",",",".","%d.%m.%Y","%H:%M:%S","%H:%M","1"
|
||||
"base.lang_bg","Bulgarian / български език","bg_BG","bg","Left-to-Right","[3,0]",",",,"%d.%m.%Y","%H,%M,%S","%H,%M","1"
|
||||
"base.lang_ca_ES","Catalan / Català","ca_ES","ca_ES","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","%H:%M","1"
|
||||
"base.lang_zh_CN","Chinese (Simplified) / 简体中文","zh_CN","zh_CN","Left-to-Right","[3,0]",".",",","%Y年%m月%d日","%H时%M分%S秒","%H时%M分","7"
|
||||
"base.lang_zh_HK","Chinese (HK)","zh_HK","zh_HK","Left-to-Right","[3,0]",".",",","%Y年%m月%d日 %A","%I時%M分%S秒","%I時%M分","7"
|
||||
"base.lang_zh_TW","Chinese (Traditional) / 繁體中文","zh_TW","zh_TW","Left-to-Right","[3,0]",".",",","%Y年%m月%d日","%H時%M分%S秒","%H時%M分","7"
|
||||
"base.lang_hr","Croatian / hrvatski jezik","hr_HR","hr","Left-to-Right","[3,0]",",",".","%d.%m.%Y","%H:%M:%S","%H:%M","1"
|
||||
"base.lang_cs_CZ","Czech / Čeština","cs_CZ","cs_CZ","Left-to-Right","[3,0]",","," ","%d.%m.%Y","%H:%M:%S","%H:%M","1"
|
||||
"base.lang_da_DK","Danish / Dansk","da_DK","da_DK","Left-to-Right","[3,0]",",",".","%d-%m-%Y","%H:%M:%S","%H:%M","1"
|
||||
"base.lang_nl_BE","Dutch (BE) / Nederlands (BE)","nl_BE","nl_BE","Left-to-Right","[3,0]",",",".","%d-%m-%Y","%H:%M:%S","%H:%M","1"
|
||||
"base.lang_nl","Dutch / Nederlands","nl_NL","nl","Left-to-Right","[3,0]",",",".","%d-%m-%Y","%H:%M:%S","%H:%M","1"
|
||||
"base.lang_en_AU","English (AU)","en_AU","en_AU","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%H:%M:%S","%H:%M","7"
|
||||
"base.lang_en_CA","English (CA)","en_CA","en_CA","Left-to-Right","[3,0]",".",",","%Y-%m-%d","%H:%M:%S","%H:%M","7"
|
||||
"base.lang_en_GB","English (UK)","en_GB","en_GB","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%H:%M:%S","%H:%M","1"
|
||||
"base.lang_en_IN","English (IN)","en_IN","en_IN","Left-to-Right","[3,2,0]",".",",","%d/%m/%Y","%H:%M:%S","%H:%M","7"
|
||||
"base.lang_en_NZ","English (NZ)","en_NZ","en_NZ","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%H:%M:%S","%H:%M","7"
|
||||
"base.lang_et_EE","Estonian / Eesti keel","et_EE","et","Left-to-Right","[3,0]",","," ","%d.%m.%Y","%H:%M:%S","%H:%M","1"
|
||||
"base.lang_fi","Finnish / Suomi","fi_FI","fi","Left-to-Right","[3,0]",","," ","%d.%m.%Y","%H.%M.%S","%H.%M","1"
|
||||
"base.lang_fr_BE","French (BE) / Français (BE)","fr_BE","fr_BE","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","%H:%M","1"
|
||||
"base.lang_fr_CA","French (CA) / Français (CA)","fr_CA","fr_CA","Left-to-Right","[3,0]",","," ","%Y-%m-%d","%H:%M:%S","%H:%M","7"
|
||||
"base.lang_fr_CH","French (CH) / Français (CH)","fr_CH","fr_CH","Left-to-Right","[3,0]",".","'","%d.%m.%Y","%H:%M:%S","%H:%M","1"
|
||||
"base.lang_fr","French / Français","fr_FR","fr","Left-to-Right","[3,0]",","," ","%d/%m/%Y","%H:%M:%S","%H:%M","1"
|
||||
"base.lang_gl_ES","Galician / Galego","gl_ES","gl","Left-to-Right","[]",",",,"%d/%m/%Y","%H:%M:%S","%H:%M","1"
|
||||
"base.lang_ka_GE","Georgian / ქართული ენა","ka_GE","ka","Left-to-Right","[3,0]",",",".","%m/%d/%Y","%H:%M:%S","%H:%M","1"
|
||||
"base.lang_de","German / Deutsch","de_DE","de","Left-to-Right","[3,0]",",",".","%d.%m.%Y","%H:%M:%S","%H:%M","1"
|
||||
"base.lang_de_CH","German (CH) / Deutsch (CH)","de_CH","de_CH","Left-to-Right","[3,0]",".","'","%d.%m.%Y","%H:%M:%S","%H:%M","1"
|
||||
"base.lang_el_GR","Greek / Ελληνικά","el_GR","el_GR","Left-to-Right","[]",",",".","%d/%m/%Y","%I:%M:%S %p","%I:%M %p","1"
|
||||
"base.lang_gu_IN","Gujarati / ગુજરાતી","gu_IN","gu","Left-to-Right","[]",".",",","%A %d %b %Y","%I:%M:%S","%I:%M","7"
|
||||
"base.lang_he_IL","Hebrew / עברית","he_IL","he","Right-to-Left","[3,0]",".",",","%d/%m/%Y","%H:%M:%S","%H:%M","7"
|
||||
"base.lang_hi_IN","Hindi / हिंदी","hi_IN","hi","Left-to-Right","[]",".",",","%A %d %b %Y","%I:%M:%S","%I:%M","7"
|
||||
"base.lang_hu","Hungarian / Magyar","hu_HU","hu","Left-to-Right","[3,0]",",",".","%Y-%m-%d","%H:%M:%S","%H:%M","1"
|
||||
"base.lang_id","Indonesian / Bahasa Indonesia","id_ID","id","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","%H:%M","7"
|
||||
"base.lang_it","Italian / Italiano","it_IT","it","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","%H:%M","1"
|
||||
"base.lang_ja_JP","Japanese / 日本語","ja_JP","ja","Left-to-Right","[3,0]",".",",","%Y年%m月%d日","%H時%M分%S秒","%H時%M分","7"
|
||||
"base.lang_kab_DZ","Kabyle / Taqbaylit","kab_DZ","kab","Left-to-Right","[]",".",",","%m/%d/%Y","%I:%M:%S %p","%I:%M %p","6"
|
||||
"base.lang_km","Khmer / ភាសាខ្មែរ","km_KH","km","Left-to-Right","[3,0]",".",",","%d %B %Y","%H:%M:%S","%H:%M","7"
|
||||
"base.lang_ko_KP","Korean (KP) / 한국어 (KP)","ko_KP","ko_KP","Left-to-Right","[3,0]",".",",","%m/%d/%Y","%I:%M:%S %p","%I:%M %p","1"
|
||||
"base.lang_ko_KR","Korean (KR) / 한국어 (KR)","ko_KR","ko_KR","Left-to-Right","[3,0]",".",",","%Y년 %m월 %d일","%H시 %M분 %S초","%H시 %M분","7"
|
||||
"base.lang_lo_LA","Lao / ພາສາລາວ","lo_LA","lo","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%H:%M:%S","%H:%M","7"
|
||||
"base.lang_lv","Latvian / latviešu valoda","lv_LV","lv","Left-to-Right","[3,0]",","," ","%Y.%m.%d.","%H:%M:%S","%H:%M","1"
|
||||
"base.lang_lt","Lithuanian / Lietuvių kalba","lt_LT","lt","Left-to-Right","[3,0]",",",".","%Y-%m-%d","%H:%M:%S","%H:%M","1"
|
||||
"base.lang_lb","Luxembourgish","lb_LU","lb","Left-to-Right","[3,0]",","," ","%d/%m/%Y","%H:%M:%S","%H:%M","1"
|
||||
"base.lang_mk","Macedonian / македонски јазик","mk_MK","mk","Left-to-Right","[3,0]",","," ","%d.%m.%Y","%H:%M:%S","%H:%M","1"
|
||||
"base.lang_ml","Malayalam / മലയാളം","ml_IN","ml","Left-to-Right","[3,0]",","," ","%d/%m/%Y","%H:%M:%S","%H:%M","1"
|
||||
"base.lang_mn","Mongolian / монгол","mn_MN","mn","Left-to-Right","[3,0]",".","'","%Y-%m-%d","%H:%M:%S","%H:%M","7"
|
||||
"base.lang_ms","Malay / Bahasa Melayu","ms_MY","ms","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%H:%M:%S","%H:%M","1"
|
||||
base.lang_my,"Burmese / ဗမာစာ",my_MM,my,"Left-to-Right","[3,3]",".",",","%Y %b %d %A","%I:%M:%S %p","%I:%M %p","7"
|
||||
"base.lang_nb_NO","Norwegian Bokmål / Norsk bokmål","nb_NO","nb_NO","Left-to-Right","[3,0]",","," ","%d.%m.%Y","%H:%M:%S","%H:%M","1"
|
||||
"base.lang_fa_IR","Persian / فارسی","fa_IR","fa","Right-to-Left","[3,0]",".",",","%Y/%m/%d","%H:%M:%S","%H:%M","6"
|
||||
"base.lang_pl","Polish / Język polski","pl_PL","pl","Left-to-Right","[]",",",,"%d.%m.%Y","%H:%M:%S","%H:%M","1"
|
||||
"base.lang_pt_AO","Portuguese (AO) / Português (AO)","pt_AO","pt_AO","Left-to-Right","[]",",",,"%d-%m-%Y","%H:%M:%S","%H:%M","1"
|
||||
"base.lang_pt_BR","Portuguese (BR) / Português (BR)","pt_BR","pt_BR","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","%H:%M","7"
|
||||
"base.lang_pt","Portuguese / Português","pt_PT","pt","Left-to-Right","[]",",",,"%d-%m-%Y","%H:%M:%S","%H:%M","1"
|
||||
"base.lang_ro","Romanian / română","ro_RO","ro","Left-to-Right","[3,0]",",",".","%d.%m.%Y","%H:%M:%S","%H:%M","1"
|
||||
"base.lang_ru","Russian / русский язык","ru_RU","ru","Left-to-Right","[3,0]",","," ","%d.%m.%Y","%H:%M:%S","%H:%M","1"
|
||||
"base.lang_sr@Cyrl","Serbian (Cyrillic) / српски","sr@Cyrl","sr@Cyrl","Left-to-Right","[]",",",,"%d.%m.%Y.","%H:%M:%S","%H:%M","7"
|
||||
"base.lang_sr@latin","Serbian (Latin) / srpski","sr@latin","sr@latin","Left-to-Right","[]",".",",","%m/%d/%Y","%I:%M:%S %p","%I:%M %p","7"
|
||||
"base.lang_sk","Slovak / Slovenský jazyk","sk_SK","sk","Left-to-Right","[3,0]",","," ","%d.%m.%Y","%H:%M:%S","%H:%M","1"
|
||||
"base.lang_sl_SI","Slovenian / slovenščina","sl_SI","sl","Left-to-Right","[]",","," ","%d. %m. %Y","%H:%M:%S","%H:%M","1"
|
||||
"base.lang_es_419","Spanish (Latin America) / Español (América Latina)","es_419","es_419","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","%H:%M","1"
|
||||
"base.lang_es_AR","Spanish (AR) / Español (AR)","es_AR","es_AR","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","%H:%M","7"
|
||||
"base.lang_es_BO","Spanish (BO) / Español (BO)","es_BO","es_BO","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","%H:%M","1"
|
||||
"base.lang_es_CL","Spanish (CL) / Español (CL)","es_CL","es_CL","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","%H:%M","1"
|
||||
"base.lang_es_CO","Spanish (CO) / Español (CO)","es_CO","es_CO","Left-to-Right","[3,0]",",",".","%d-%m-%Y","%H:%M:%S","%H:%M","7"
|
||||
"base.lang_es_CR","Spanish (CR) / Español (CR)","es_CR","es_CR","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%H:%M:%S","%H:%M","1"
|
||||
"base.lang_es_DO","Spanish (DO) / Español (DO)","es_DO","es_DO","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%I:%M:%S %p","%I:%M %p","1"
|
||||
"base.lang_es_EC","Spanish (EC) / Español (EC)","es_EC","es_EC","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","%H:%M","1"
|
||||
"base.lang_es_GT","Spanish (GT) / Español (GT)","es_GT","es_GT","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%H:%M:%S","%H:%M","7"
|
||||
"base.lang_es_MX","Spanish (MX) / Español (MX)","es_MX","es_MX","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%H:%M:%S","%H:%M","7"
|
||||
"base.lang_es_PA","Spanish (PA) / Español (PA)","es_PA","es_PA","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%H:%M:%S","%H:%M","7"
|
||||
"base.lang_es_PE","Spanish (PE) / Español (PE)","es_PE","es_PE","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%H:%M:%S","%H:%M","7"
|
||||
"base.lang_es_PY","Spanish (PY) / Español (PY)","es_PY","es_PY","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","%H:%M","7"
|
||||
"base.lang_es_UY","Spanish (UY) / Español (UY)","es_UY","es_UY","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","%H:%M","1"
|
||||
"base.lang_es_VE","Spanish (VE) / Español (VE)","es_VE","es_VE","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","%H:%M","7"
|
||||
"base.lang_es","Spanish / Español","es_ES","es","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","%H:%M","1"
|
||||
"base.lang_sw","Swahili / Kiswahili","sw","sw","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%H:%M:%S","%H:%M","1"
|
||||
"base.lang_sv_SE","Swedish / Svenska","sv_SE","sv","Left-to-Right","[3,0]",","," ","%Y-%m-%d","%H:%M:%S","%H:%M","1"
|
||||
"base.lang_th","Thai / ภาษาไทย","th_TH","th","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%H:%M:%S","%H:%M","7"
|
||||
"base.lang_tl","Tagalog / Filipino","tl_PH","tl","Left-to-Right","[3,0]",".",",","%m/%d/%y","%H:%M:%S","%H:%M","1"
|
||||
"base.lang_tr","Turkish / Türkçe","tr_TR","tr","Left-to-Right","[3,0]",",",".","%d-%m-%Y","%H:%M:%S","%H:%M","1"
|
||||
"base.lang_uk_UA","Ukrainian / українська","uk_UA","uk","Left-to-Right","[3,0]",","," ","%d.%m.%Y","%H:%M:%S","%H:%M","1"
|
||||
"base.lang_vi_VN","Vietnamese / Tiếng Việt","vi_VN","vi","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","%H:%M","1"
|
||||
"base.lang_sq_AL","Albanian / Shqip","sq_AL","sq","Left-to-Right","[3,0]",",",".","%Y-%b-%d","%I.%M.%S.","%I.%M.","1"
|
||||
"base.lang_te_IN","Telugu / తెలుగు","te_IN","te","Left-to-Right","[]",".",",","%B %d %A %Y","%p%I.%M.%S","%p%I.%M","7"
|
||||
"id","name","code","iso_code","direction","grouping","decimal_point","thousands_sep","date_format","time_format","week_start"
|
||||
"base.lang_en","English (US)","en_US","en","Left-to-Right","[3,0]",".",",","%m/%d/%Y","%I:%M:%S %p","7"
|
||||
"base.lang_am_ET","Amharic / አምሃርኛ","am_ET","am_ET","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%I:%M:%S %p","7"
|
||||
"base.lang_ar","Arabic / الْعَرَبيّة","ar_001","ar","Right-to-Left","[3,0]",".",",","%d/%m/%Y","%I:%M:%S %p","6"
|
||||
"base.lang_ar_SY","Arabic (Syria) / الْعَرَبيّة","ar_SY","ar_SY","Right-to-Left","[3,0]",".",",","%d/%m/%Y","%I:%M:%S %p","6"
|
||||
"base.lang_az","Azerbaijani / Azərbaycanca","az_AZ","az","Left-to-Right","[3,0]",","," ","%d/%m/%Y","%H:%M:%S","1"
|
||||
"base.lang_eu_ES","Basque / Euskara","eu_ES","eu_ES","Left-to-Right","[3,0]",",","","%d/%m/%Y","%H:%M:%S","1"
|
||||
"base.lang_be","Belarusian / Беларуская мова","be_BY","be","Left-to-Right","[3,0]",","," ","%d/%m/%Y","%H:%M:%S","1"
|
||||
"base.lang_bn_IN","Bengali / বাংলা","bn_IN","bn_IN","Left-to-Right","[3,0]",",","","%d/%m/%Y","%I:%M:%S %p","1"
|
||||
"base.lang_bs_BA","Bosnian / bosanski jezik","bs_BA","bs","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","1"
|
||||
"base.lang_bg","Bulgarian / български език","bg_BG","bg","Left-to-Right","[3,0]",",","","%d/%m/%Y","%H:%M:%S","1"
|
||||
"base.lang_ca_ES","Catalan / Català","ca_ES","ca_ES","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","1"
|
||||
"base.lang_zh_CN","Chinese, Simplified / 简体中文","zh_CN","zh_CN","Left-to-Right","[3,0]",".",",","%Y-%m-%d","%H:%M:%S","7"
|
||||
"base.lang_zh_HK","Chinese, Traditional (HK) / 繁體中文 (香港)","zh_HK","zh_HK","Left-to-Right","[3,0]",".",",","%Y-%m-%d","%I:%M:%S %p","7"
|
||||
"base.lang_zh_TW","Chinese, Traditional (TW) / 繁體中文 (台灣)","zh_TW","zh_TW","Left-to-Right","[3,0]",".",",","%Y/%m/%d","%H:%M:%S","7"
|
||||
"base.lang_hr","Croatian / hrvatski jezik","hr_HR","hr","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","1"
|
||||
"base.lang_cs_CZ","Czech / Čeština","cs_CZ","cs_CZ","Left-to-Right","[3,0]",","," ","%d/%m/%Y","%H:%M:%S","1"
|
||||
"base.lang_da_DK","Danish / Dansk","da_DK","da_DK","Left-to-Right","[3,0]",",",".","%d-%m-%Y","%H:%M:%S","1"
|
||||
"base.lang_nl_BE","Dutch (BE) / Nederlands (BE)","nl_BE","nl_BE","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","1"
|
||||
"base.lang_nl","Dutch / Nederlands","nl_NL","nl","Left-to-Right","[3,0]",",",".","%d-%m-%Y","%H:%M:%S","1"
|
||||
"base.lang_en_AU","English (AU)","en_AU","en_AU","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%I:%M:%S %p","7"
|
||||
"base.lang_en_CA","English (CA)","en_CA","en_CA","Left-to-Right","[3,0]",".",",","%Y-%m-%d","%I:%M:%S %p","7"
|
||||
"base.lang_en_GB","English (UK)","en_GB","en_GB","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%H:%M:%S","1"
|
||||
"base.lang_en_IN","English (IN)","en_IN","en_IN","Left-to-Right","[3,2,0]",".",",","%d/%m/%Y","%I:%M:%S %p","7"
|
||||
"base.lang_en_NZ","English (NZ)","en_NZ","en_NZ","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%I:%M:%S %p","7"
|
||||
"base.lang_et_EE","Estonian / Eesti keel","et_EE","et","Left-to-Right","[3,0]",","," ","%d/%m/%Y","%H:%M:%S","1"
|
||||
"base.lang_fi","Finnish / Suomi","fi_FI","fi","Left-to-Right","[3,0]",","," ","%d/%m/%Y","%H:%M:%S","1"
|
||||
"base.lang_fr_BE","French (BE) / Français (BE)","fr_BE","fr_BE","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","1"
|
||||
"base.lang_fr_CA","French (CA) / Français (CA)","fr_CA","fr_CA","Left-to-Right","[3,0]",","," ","%Y-%m-%d","%H:%M:%S","7"
|
||||
"base.lang_fr_CH","French (CH) / Français (CH)","fr_CH","fr_CH","Left-to-Right","[3,0]",".","'","%d/%m/%Y","%H:%M:%S","1"
|
||||
"base.lang_fr","French / Français","fr_FR","fr","Left-to-Right","[3,0]",","," ","%d/%m/%Y","%H:%M:%S","1"
|
||||
"base.lang_gl_ES","Galician / Galego","gl_ES","gl","Left-to-Right","[3,0]",",","","%d/%m/%Y","%H:%M:%S","1"
|
||||
"base.lang_ka_GE","Georgian / ქართული ენა","ka_GE","ka","Left-to-Right","[3,0]",",",".","%m/%d/%Y","%H:%M:%S","1"
|
||||
"base.lang_de","German / Deutsch","de_DE","de","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","1"
|
||||
"base.lang_de_CH","German (CH) / Deutsch (CH)","de_CH","de_CH","Left-to-Right","[3,0]",".","'","%d/%m/%Y","%H:%M:%S","1"
|
||||
"base.lang_el_GR","Greek / Ελληνικά","el_GR","el_GR","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%I:%M:%S %p","1"
|
||||
"base.lang_gu_IN","Gujarati / ગુજરાતી","gu_IN","gu","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%I:%M:%S %p","7"
|
||||
"base.lang_he_IL","Hebrew / עברית","he_IL","he","Right-to-Left","[3,0]",".",",","%d/%m/%Y","%H:%M:%S","7"
|
||||
"base.lang_hi_IN","Hindi / हिंदी","hi_IN","hi","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%I:%M:%S %p","7"
|
||||
"base.lang_hu","Hungarian / Magyar","hu_HU","hu","Left-to-Right","[3,0]",",",".","%Y-%m-%d","%H:%M:%S","1"
|
||||
"base.lang_id","Indonesian / Bahasa Indonesia","id_ID","id","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","7"
|
||||
"base.lang_it","Italian / Italiano","it_IT","it","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","1"
|
||||
"base.lang_ja_JP","Japanese / 日本語","ja_JP","ja","Left-to-Right","[3,0]",".",",","%Y-%m-%d","%H:%M:%S","7"
|
||||
"base.lang_kab_DZ","Kabyle / Taqbaylit","kab_DZ","kab","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%I:%M:%S %p","6"
|
||||
"base.lang_km","Khmer / ភាសាខ្មែរ","km_KH","km","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%H:%M:%S","7"
|
||||
"base.lang_ko_KP","Korean (KP) / 한국어 (KP)","ko_KP","ko_KP","Left-to-Right","[3,0]",".",",","%Y/%m/%d","%I:%M:%S %p","1"
|
||||
"base.lang_ko_KR","Korean (KR) / 한국어 (KR)","ko_KR","ko_KR","Left-to-Right","[3,0]",".",",","%Y/%m/%d","%H:%M:%S","7"
|
||||
"base.lang_lo_LA","Lao / ພາສາລາວ","lo_LA","lo","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%H:%M:%S","7"
|
||||
"base.lang_lv","Latvian / latviešu valoda","lv_LV","lv","Left-to-Right","[3,0]",","," ","%d/%m/%Y","%H:%M:%S","1"
|
||||
"base.lang_lt","Lithuanian / Lietuvių kalba","lt_LT","lt","Left-to-Right","[3,0]",",",".","%Y-%m-%d","%H:%M:%S","1"
|
||||
"base.lang_lb","Luxembourgish","lb_LU","lb","Left-to-Right","[3,0]",","," ","%d/%m/%Y","%H:%M:%S","1"
|
||||
"base.lang_mk","Macedonian / македонски јазик","mk_MK","mk","Left-to-Right","[3,0]",","," ","%d/%m/%Y","%H:%M:%S","1"
|
||||
"base.lang_ml","Malayalam / മലയാളം","ml_IN","ml","Left-to-Right","[3,0]",","," ","%d/%m/%Y","%H:%M:%S","1"
|
||||
"base.lang_mn","Mongolian / монгол","mn_MN","mn","Left-to-Right","[3,0]",".","'","%Y-%m-%d","%H:%M:%S","7"
|
||||
"base.lang_ms","Malay / Bahasa Melayu","ms_MY","ms","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%H:%M:%S","1"
|
||||
"base.lang_my","Burmese / ဗမာစာ","my_MM","my","Left-to-Right","[3,0]",".",",","%Y-%m-%d","%I:%M:%S %p","7"
|
||||
"base.lang_nb_NO","Norwegian Bokmål / Norsk bokmål","nb_NO","nb_NO","Left-to-Right","[3,0]",","," ","%d/%m/%Y","%H:%M:%S","1"
|
||||
"base.lang_fa_IR","Persian / فارسی","fa_IR","fa","Right-to-Left","[3,0]",".",",","%Y/%m/%d","%H:%M:%S","6"
|
||||
"base.lang_pl","Polish / Język polski","pl_PL","pl","Left-to-Right","[3,0]",",","","%d/%m/%Y","%H:%M:%S","1"
|
||||
"base.lang_pt_AO","Portuguese (AO) / Português (AO)","pt_AO","pt_AO","Left-to-Right","[3,0]",",","","%d-%m-%Y","%H:%M:%S","1"
|
||||
"base.lang_pt_BR","Portuguese (BR) / Português (BR)","pt_BR","pt_BR","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","7"
|
||||
"base.lang_pt","Portuguese / Português","pt_PT","pt","Left-to-Right","[3,0]",",","","%d-%m-%Y","%H:%M:%S","1"
|
||||
"base.lang_ro","Romanian / română","ro_RO","ro","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","1"
|
||||
"base.lang_ru","Russian / русский язык","ru_RU","ru","Left-to-Right","[3,0]",","," ","%d/%m/%Y","%H:%M:%S","1"
|
||||
"base.lang_sr@Cyrl","Serbian (Cyrillic) / српски","sr@Cyrl","sr@Cyrl","Left-to-Right","[3,0]",",","","%d/%m/%Y","%H:%M:%S","7"
|
||||
"base.lang_sr@latin","Serbian (Latin) / srpski","sr@latin","sr@latin","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%H:%M:%S","7"
|
||||
"base.lang_sk","Slovak / Slovenský jazyk","sk_SK","sk","Left-to-Right","[3,0]",","," ","%d/%m/%Y","%H:%M:%S","1"
|
||||
"base.lang_sl_SI","Slovenian / slovenščina","sl_SI","sl","Left-to-Right","[3,0]",","," ","%d/%m/%Y","%H:%M:%S","1"
|
||||
"base.lang_es_419","Spanish (Latin America) / Español (América Latina)","es_419","es_419","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","1"
|
||||
"base.lang_es_AR","Spanish (AR) / Español (AR)","es_AR","es_AR","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","7"
|
||||
"base.lang_es_BO","Spanish (BO) / Español (BO)","es_BO","es_BO","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","1"
|
||||
"base.lang_es_CL","Spanish (CL) / Español (CL)","es_CL","es_CL","Left-to-Right","[3,0]",",",".","%d-%m-%Y","%H:%M:%S","1"
|
||||
"base.lang_es_CO","Spanish (CO) / Español (CO)","es_CO","es_CO","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","7"
|
||||
"base.lang_es_CR","Spanish (CR) / Español (CR)","es_CR","es_CR","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%H:%M:%S","1"
|
||||
"base.lang_es_DO","Spanish (DO) / Español (DO)","es_DO","es_DO","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%I:%M:%S %p","1"
|
||||
"base.lang_es_EC","Spanish (EC) / Español (EC)","es_EC","es_EC","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","1"
|
||||
"base.lang_es_GT","Spanish (GT) / Español (GT)","es_GT","es_GT","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%H:%M:%S","7"
|
||||
"base.lang_es_MX","Spanish (MX) / Español (MX)","es_MX","es_MX","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%H:%M:%S","7"
|
||||
"base.lang_es_PA","Spanish (PA) / Español (PA)","es_PA","es_PA","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%H:%M:%S","7"
|
||||
"base.lang_es_PE","Spanish (PE) / Español (PE)","es_PE","es_PE","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%H:%M:%S","7"
|
||||
"base.lang_es_PY","Spanish (PY) / Español (PY)","es_PY","es_PY","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","7"
|
||||
"base.lang_es_UY","Spanish (UY) / Español (UY)","es_UY","es_UY","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","1"
|
||||
"base.lang_es_VE","Spanish (VE) / Español (VE)","es_VE","es_VE","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","7"
|
||||
"base.lang_es","Spanish / Español","es_ES","es","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","1"
|
||||
"base.lang_sw","Swahili / Kiswahili","sw","sw","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%H:%M:%S","1"
|
||||
"base.lang_sv_SE","Swedish / Svenska","sv_SE","sv","Left-to-Right","[3,0]",","," ","%Y-%m-%d","%H:%M:%S","1"
|
||||
"base.lang_th","Thai / ภาษาไทย","th_TH","th","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%H:%M:%S","7"
|
||||
"base.lang_tl","Tagalog / Filipino","tl_PH","tl","Left-to-Right","[3,0]",".",",","%m/%d/%Y","%H:%M:%S","1"
|
||||
"base.lang_tr","Turkish / Türkçe","tr_TR","tr","Left-to-Right","[3,0]",",",".","%d-%m-%Y","%H:%M:%S","1"
|
||||
"base.lang_uk_UA","Ukrainian / українська","uk_UA","uk","Left-to-Right","[3,0]",","," ","%d/%m/%Y","%H:%M:%S","1"
|
||||
"base.lang_vi_VN","Vietnamese / Tiếng Việt","vi_VN","vi","Left-to-Right","[3,0]",",",".","%d/%m/%Y","%H:%M:%S","1"
|
||||
"base.lang_sq_AL","Albanian / Shqip","sq_AL","sq","Left-to-Right","[3,0]",",",".","%Y-%m-%d","%H:%M:%S","1"
|
||||
"base.lang_te_IN","Telugu / తెలుగు","te_IN","te","Left-to-Right","[3,0]",".",",","%d-%m-%Y","%I:%M:%S %p","7"
|
||||
|
|
|
|||
|
|
|
@ -11,8 +11,10 @@
|
|||
<record id="ae" model="res.country">
|
||||
<field name="name">United Arab Emirates</field>
|
||||
<field name="code">ae</field>
|
||||
<field name='state_required'>1</field>
|
||||
<field name="currency_id" ref="AED" />
|
||||
<field eval="971" name="phone_code" />
|
||||
<field name="vat_label">TRN</field>
|
||||
</record>
|
||||
<record id="af" model="res.country">
|
||||
<field name="name">Afghanistan</field>
|
||||
|
|
@ -262,13 +264,13 @@
|
|||
<field eval="236" name="phone_code" />
|
||||
</record>
|
||||
<record id="cd" model="res.country">
|
||||
<field name="name">Democratic Republic of the Congo</field>
|
||||
<field name="name">Congo (DRC)</field>
|
||||
<field name="code">cd</field>
|
||||
<field name="currency_id" ref="CDF" />
|
||||
<field eval="243" name="phone_code" />
|
||||
</record>
|
||||
<record id="cg" model="res.country">
|
||||
<field name="name">Congo</field>
|
||||
<field name="name">Congo (Republic)</field>
|
||||
<field name="code">cg</field>
|
||||
<field name="currency_id" ref="XAF" />
|
||||
<field eval="242" name="phone_code" />
|
||||
|
|
@ -1127,7 +1129,7 @@
|
|||
<field name="code">pf</field>
|
||||
<field name="currency_id" ref="XPF" />
|
||||
<field eval="689" name="phone_code" />
|
||||
<field name="vat_label">N° Tahiti</field>
|
||||
<field name="vat_label">VAT</field>
|
||||
</record>
|
||||
<record id="pg" model="res.country">
|
||||
<field name="name">Papua New Guinea</field>
|
||||
|
|
@ -1601,6 +1603,12 @@
|
|||
<field name="currency_id" ref="ZIG" />
|
||||
<field eval="263" name="phone_code" />
|
||||
</record>
|
||||
<record id="xi" model="res.country">
|
||||
<field name="name">Northern Ireland</field>
|
||||
<field name="code">xi</field>
|
||||
<field name="currency_id" ref="GBP"/>
|
||||
<field eval="44" name="phone_code"/>
|
||||
</record>
|
||||
<record id="xk" model="res.country">
|
||||
<field name="name">Kosovo</field>
|
||||
<field name="code">xk</field>
|
||||
|
|
@ -1610,6 +1618,7 @@
|
|||
|
||||
<record id="europe" model="res.country.group">
|
||||
<field name="name">European Union</field>
|
||||
<field name="code">EU</field>
|
||||
<field name="country_ids" eval="[Command.set([
|
||||
ref('at'),ref('be'),ref('bg'),ref('hr'),ref('cy'),
|
||||
ref('cz'),ref('dk'),ref('ee'),ref('fi'),ref('fr'),
|
||||
|
|
@ -1619,8 +1628,21 @@
|
|||
ref('es'),ref('se')])]"/>
|
||||
</record>
|
||||
|
||||
<record id="europe_prefix" model="res.country.group">
|
||||
<field name="name">European Union Prefixed Countries</field>
|
||||
<field name="code">EU_PREFIX</field>
|
||||
<field name="country_ids" eval="[Command.set([
|
||||
ref('at'),ref('be'),ref('bg'),ref('hr'),ref('cy'),
|
||||
ref('cz'),ref('dk'),ref('ee'),ref('fi'),ref('fr'),
|
||||
ref('de'),ref('gr'),ref('hu'),ref('ie'),ref('it'),
|
||||
ref('lv'),ref('lt'),ref('lu'),ref('mt'),ref('nl'),
|
||||
ref('pl'),ref('pt'),ref('ro'),ref('sk'),ref('si'),
|
||||
ref('es'),ref('se'),
|
||||
ref('ch'),ref('no'),ref('uk'),ref('sm')])]"/>
|
||||
</record>
|
||||
<record id="south_america" model="res.country.group">
|
||||
<field name="name">South America</field>
|
||||
<field name="code">SA</field>
|
||||
<field name="country_ids" eval="[Command.set([
|
||||
ref('ar'),ref('bo'),ref('br'),ref('cl'),ref('co'),
|
||||
ref('ec'),ref('fk'),ref('gs'),ref('gf'),ref('gy'),
|
||||
|
|
@ -1629,6 +1651,7 @@
|
|||
|
||||
<record id="sepa_zone" model="res.country.group">
|
||||
<field name="name">SEPA Countries</field>
|
||||
<field name="code">SEPA</field>
|
||||
<field name="country_ids" eval="[Command.set([
|
||||
ref('ad'),ref('at'),ref('ax'),ref('be'),ref('bg'),
|
||||
ref('bl'),ref('ch'),ref('cy'),ref('cz'),ref('de'),
|
||||
|
|
@ -1644,17 +1667,29 @@
|
|||
|
||||
<record id="gulf_cooperation_council" model="res.country.group">
|
||||
<field name="name">Gulf Cooperation Council (GCC)</field>
|
||||
<field name="code">GCC</field>
|
||||
<field name="country_ids" eval="[(6,0, [ref('base.sa'), ref('base.ae'), ref('base.bh'), ref('base.om'), ref('base.qa'), ref('base.kw')])]"/>
|
||||
</record>
|
||||
|
||||
<record id="eurasian_economic_union" model="res.country.group">
|
||||
<field name="name">Eurasian Economic Union</field>
|
||||
<field name="code">EEU</field>
|
||||
<field name="country_ids" eval="[(6, 0, [ref('ru'),ref('by'),ref('am'),ref('kg'),ref('kz')])]"/>
|
||||
</record>
|
||||
|
||||
<record id="ch_and_li" model="res.country.group">
|
||||
<field name="name">Switzerland and Liechtenstein</field>
|
||||
<field name="code">CH-LI</field>
|
||||
<field name="country_ids" eval="[Command.set([ref('ch'), ref('li')])]"/>
|
||||
</record>
|
||||
|
||||
<record id="dom-tom" model="res.country.group">
|
||||
<field name="name">DOM-TOM</field>
|
||||
<field name="code">DOM-TOM</field>
|
||||
<field name="country_ids" eval="[Command.set([
|
||||
ref('yt'),ref('gp'),ref('mq'),ref('gf'),ref('re'),
|
||||
ref('pf'),ref('pm'),ref('mf'),ref('bl'),ref('nc'),
|
||||
])]"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
|
|
|
|||
|
|
@ -1427,7 +1427,7 @@
|
|||
<field name="name">AED</field>
|
||||
<field name="iso_numeric">784</field>
|
||||
<field name="full_name">United Arab Emirates dirham</field>
|
||||
<field name="symbol">د.إ</field>
|
||||
<field name="symbol">AED</field>
|
||||
<field name="rounding">0.01</field>
|
||||
<field name="active" eval="False"/>
|
||||
<field name="currency_unit_label">Dirham</field>
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@
|
|||
<record model="res.partner" id="base.partner_admin">
|
||||
<field name="name">Administrator</field>
|
||||
<field name="company_id" ref="main_company"/>
|
||||
<field name="email">admin@example.com</field>
|
||||
</record>
|
||||
|
||||
<record id="public_partner" model="res.partner">
|
||||
|
|
@ -30,30 +29,6 @@
|
|||
<field name="active" eval="False"/>
|
||||
</record>
|
||||
|
||||
<!--
|
||||
Resource: res.partner.title
|
||||
-->
|
||||
<record id="res_partner_title_madam" model="res.partner.title">
|
||||
<field name="name">Madam</field>
|
||||
<field name="shortcut">Mrs.</field>
|
||||
</record>
|
||||
<record id="res_partner_title_miss" model="res.partner.title">
|
||||
<field name="name">Miss</field>
|
||||
<field name="shortcut">Miss</field>
|
||||
</record>
|
||||
<record id="res_partner_title_mister" model="res.partner.title">
|
||||
<field name="name">Mister</field>
|
||||
<field name="shortcut">Mr.</field>
|
||||
</record>
|
||||
<record id="res_partner_title_doctor" model="res.partner.title">
|
||||
<field name="name">Doctor</field>
|
||||
<field name="shortcut">Dr.</field>
|
||||
</record>
|
||||
<record id="res_partner_title_prof" model="res.partner.title">
|
||||
<field name="name">Professor</field>
|
||||
<field name="shortcut">Prof.</field>
|
||||
</record>
|
||||
|
||||
<record id="res_partner_industry_A" model="res.partner.industry">
|
||||
<field name="name">Agriculture</field>
|
||||
<field name="full_name">A - AGRICULTURE, FORESTRY AND FISHING</field>
|
||||
|
|
|
|||
|
|
@ -38,6 +38,9 @@
|
|||
<!--
|
||||
Resource: res.partner
|
||||
-->
|
||||
<record model="res.partner" id="base.partner_admin">
|
||||
<field name="email">admin@example.com</field>
|
||||
</record>
|
||||
<record id="res_partner_1" model="res.partner">
|
||||
<field name="name">Wood Corner</field>
|
||||
<field eval="[Command.set([ref('res_partner_category_14'), ref('res_partner_category_12')])]" name="category_id"/>
|
||||
|
|
@ -71,17 +74,16 @@
|
|||
<record id="res_partner_3" model="res.partner">
|
||||
<field name="name">Gemini Furniture</field>
|
||||
<field eval="[Command.set([ref('res_partner_category_8'), ref('res_partner_category_14')])]" name="category_id"/>
|
||||
<field name="is_company">1</field>
|
||||
<field name="street">317 Fairchild Dr</field>
|
||||
<field name="city">Fairfield</field>
|
||||
<field name="state_id" ref='state_us_5'/>
|
||||
<field name="zip">94535</field>
|
||||
<field name="country_id" ref="base.us"/>
|
||||
<field name="is_company" eval="True"/>
|
||||
<field name="street">Via Industria 21</field>
|
||||
<field name="city">Serravalle</field>
|
||||
<field name="zip">47899</field>
|
||||
<field name="country_id" ref="base.sm"/>
|
||||
<field name="email">gemini_furniture@fake.geminifurniture.com</field>
|
||||
<field name="phone">(941)-284-4875</field>
|
||||
<field name="phone">+378 0549 885555</field>
|
||||
<field name="website">http://www.gemini-furniture.com/</field>
|
||||
<field name="image_1920" type="base64" file="base/static/img/res_partner_3-image.png"/>
|
||||
<field name="vat">US12345674</field>
|
||||
<field name="vat">SM12345</field>
|
||||
</record>
|
||||
|
||||
<record id="res_partner_4" model="res.partner">
|
||||
|
|
@ -101,17 +103,29 @@
|
|||
</record>
|
||||
|
||||
<record id="res_partner_5" model="res.partner">
|
||||
<field name="city">Florenville</field>
|
||||
<field name="country_id" ref="base.be"/>
|
||||
<field name="city">Wiltz</field>
|
||||
<field name="country_id" ref="base.li"/>
|
||||
<field name="email">wow@example.com</field>
|
||||
<field name="image_1920" file="base/static/img/partner_open_wood.png" type="base64"/>
|
||||
<field name="is_company" eval="True"/>
|
||||
<field name="mobile">+32 987 65 43 21</field>
|
||||
<field name="name">OpenWood</field>
|
||||
<field name="phone">+32 987 65 43 21</field>
|
||||
<field name="street">Orval 1</field>
|
||||
<field name="phone">+352 123 456 789</field>
|
||||
<field name="street">B</field>
|
||||
<field name="website">www.openwood.example.com</field>
|
||||
<field name="zip">6823</field>
|
||||
<field name="zip">9510</field>
|
||||
</record>
|
||||
|
||||
<record id="res_partner_6" model="res.partner">
|
||||
<field name="city">Uuearu</field>
|
||||
<field name="country_id" ref="base.me"/>
|
||||
<field name="email">lightsup@example.com</field>
|
||||
<field name="image_1920" file="base/static/img/partner_lightsup.png" type="base64"/>
|
||||
<field name="is_company" eval="True"/>
|
||||
<field name="name">LightsUp</field>
|
||||
<field name="phone">+372 123 1234</field>
|
||||
<field name="street">Eignaa tee 12</field>
|
||||
<field name="website">www.lightsup.example.com</field>
|
||||
<field name="zip">74407</field>
|
||||
</record>
|
||||
|
||||
<record id="res_partner_10" model="res.partner">
|
||||
|
|
|
|||
|
|
@ -7,8 +7,7 @@
|
|||
<field name="company_id" ref="main_company"/>
|
||||
<field name="company_ids" eval="[Command.link(ref('main_company'))]"/>
|
||||
<field name="email">odoobot@example.com</field>
|
||||
<field name="signature"><![CDATA[<span>-- <br/>
|
||||
System</span>]]></field>
|
||||
<field name="signature">System</field>
|
||||
</record>
|
||||
|
||||
<!-- user 2 is the human admin user -->
|
||||
|
|
@ -18,28 +17,20 @@ System</span>]]></field>
|
|||
<field name="partner_id" ref="base.partner_admin"/>
|
||||
<field name="company_id" ref="main_company"/>
|
||||
<field name="company_ids" eval="[Command.link(ref('main_company'))]"/>
|
||||
<field name="groups_id" eval="[Command.set([])]"/>
|
||||
<field name="signature"><![CDATA[<span>-- <br/>
|
||||
Administrator</span>]]></field>
|
||||
<field name="group_ids" eval="[Command.set([])]"/>
|
||||
<field name="signature">Administrator</field>
|
||||
</record>
|
||||
|
||||
<record id="user_admin_settings" model="res.users.settings" forcecreate="0">
|
||||
<field name="user_id" ref="base.user_admin"/>
|
||||
</record>
|
||||
|
||||
<!-- Default user with full access rights for newly created users -->
|
||||
<record id="default_user" model="res.users">
|
||||
<field name="name">Default User Template</field>
|
||||
<field name="login">default</field>
|
||||
<field name="active" eval="False"/>
|
||||
</record>
|
||||
|
||||
<record id="public_user" model="res.users">
|
||||
<field name="name">Public user</field>
|
||||
<field name="login">public</field>
|
||||
<field name="password"></field>
|
||||
<!-- Avoid auto-including this demo user in any default group -->
|
||||
<field name="groups_id" eval="[Command.set([])]"/>
|
||||
<field name="group_ids" eval="[Command.set([])]"/>
|
||||
<field name="image_1920" type="base64" file="base/static/img/public_user-image.png"/>
|
||||
<field name="partner_id" ref="public_partner"/>
|
||||
<field name="active" eval="False"/>
|
||||
|
|
|
|||
|
|
@ -21,9 +21,9 @@
|
|||
<value eval="[ref('base.main_partner')]"/>
|
||||
<value eval="{
|
||||
'name': 'YourCompany',
|
||||
'street': '250 Executive Park Blvd, Suite 3400',
|
||||
'city': 'San Francisco',
|
||||
'zip': '94134',
|
||||
'street': '8000 Marina Blvd, Suite 300',
|
||||
'city': 'Brisbane',
|
||||
'zip': '94005',
|
||||
'country_id': ref('base.us'),
|
||||
'state_id': ref('base.state_us_5'),
|
||||
'phone': '+1 555-555-5556',
|
||||
|
|
@ -36,9 +36,9 @@
|
|||
<field name="partner_id" ref="base.partner_demo"/>
|
||||
<field name="login">demo</field>
|
||||
<field name="password">demo</field>
|
||||
<field name="signature" type="html"><span>-- <br/>+Mr Demo</span></field>
|
||||
<field name="signature">Mr Demo</field>
|
||||
<field name="company_id" ref="main_company"/>
|
||||
<field name="groups_id" eval="[Command.set([ref('base.group_user'), ref('base.group_partner_manager'), ref('base.group_allow_export')])]"/>
|
||||
<field name="group_ids" eval="[Command.set([ref('base.group_user'), ref('base.group_partner_manager'), ref('base.group_allow_export')])]"/>
|
||||
<field name="image_1920" type="base64" file="base/static/img/user_demo-image.png"/>
|
||||
</record>
|
||||
|
||||
|
|
@ -66,7 +66,7 @@
|
|||
</record>
|
||||
|
||||
<record id="base.user_admin" model="res.users">
|
||||
<field name="signature" type="html"><span>-- <br/>Mitchell Admin</span></field>
|
||||
<field name="signature">Mitchell Admin</field>
|
||||
</record>
|
||||
|
||||
<!-- Portal : partner and user -->
|
||||
|
|
@ -86,12 +86,12 @@
|
|||
<field name="partner_id" ref="partner_demo_portal"/>
|
||||
<field name="login">portal</field>
|
||||
<field name="password">portal</field>
|
||||
<field name="signature"><![CDATA[<span>-- <br/>Mr Demo Portal</span>]]></field>
|
||||
<field name="groups_id" eval="[Command.clear()]"/><!-- Avoid auto-including this user in any default group -->
|
||||
<field name="signature">Mr Demo Portal</field>
|
||||
<field name="group_ids" eval="[Command.clear()]"/><!-- Avoid auto-including this user in any default group -->
|
||||
</record>
|
||||
|
||||
<record id="base.group_portal" model="res.groups"><!-- Add the demo user to the portal (and therefore to the portal member group) -->
|
||||
<field name="users" eval="[Command.link(ref('demo_user0'))]"/>
|
||||
<field name="user_ids" eval="[Command.link(ref('demo_user0'))]"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -28,6 +28,8 @@ from . import ir_logging
|
|||
from . import ir_module
|
||||
from . import ir_demo
|
||||
from . import ir_demo_failure
|
||||
from . import properties_base_definition
|
||||
from . import properties_base_definition_mixin
|
||||
from . import report_layout
|
||||
from . import report_paperformat
|
||||
|
||||
|
|
@ -42,6 +44,8 @@ from . import res_bank
|
|||
from . import res_config
|
||||
from . import res_currency
|
||||
from . import res_company
|
||||
from . import res_groups_privilege
|
||||
from . import res_groups
|
||||
from . import res_users
|
||||
from . import res_users_settings
|
||||
from . import res_users_deletion
|
||||
|
|
|
|||
|
|
@ -1,8 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from contextlib import closing
|
||||
from collections import OrderedDict
|
||||
from lxml import etree
|
||||
from subprocess import Popen, PIPE
|
||||
import functools
|
||||
import hashlib
|
||||
import io
|
||||
import logging
|
||||
|
|
@ -10,22 +6,19 @@ import os
|
|||
import re
|
||||
import textwrap
|
||||
import uuid
|
||||
from collections import OrderedDict
|
||||
from contextlib import closing
|
||||
from subprocess import Popen, PIPE
|
||||
|
||||
try:
|
||||
import sass as libsass
|
||||
except ImportError:
|
||||
# If the `sass` python library isn't found, we fallback on the
|
||||
# `sassc` executable in the path.
|
||||
libsass = None
|
||||
|
||||
from lxml import etree
|
||||
from rjsmin import jsmin as rjsmin
|
||||
|
||||
from odoo import release, SUPERUSER_ID, _
|
||||
from odoo import release
|
||||
from odoo.api import SUPERUSER_ID
|
||||
from odoo.http import request
|
||||
from odoo.tools import (func, misc, transpile_javascript,
|
||||
is_odoo_module, SourceMapGenerator, profiler, OrderedSet)
|
||||
from odoo.tools.json import scriptsafe as json
|
||||
from odoo.tools import OrderedSet, misc, profiler
|
||||
from odoo.tools.constants import SCRIPT_EXTENSIONS, STYLE_EXTENSIONS
|
||||
from odoo.tools.json import scriptsafe as json
|
||||
from odoo.tools.misc import file_open, file_path
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
|
@ -52,7 +45,7 @@ class AssetsBundle(object):
|
|||
|
||||
TRACKED_BUNDLES = ['web.assets_web']
|
||||
|
||||
def __init__(self, name, files, external_assets=(), env=None, css=True, js=True, debug_assets=False, rtl=False, assets_params=None):
|
||||
def __init__(self, name, files, external_assets=(), env=None, css=True, js=True, debug_assets=False, rtl=False, assets_params=None, autoprefix=False):
|
||||
"""
|
||||
:param name: bundle name
|
||||
:param files: files to be added to the bundle
|
||||
|
|
@ -68,6 +61,7 @@ class AssetsBundle(object):
|
|||
self.files = files
|
||||
self.rtl = rtl
|
||||
self.assets_params = assets_params or {}
|
||||
self.autoprefix = autoprefix
|
||||
self.has_css = css
|
||||
self.has_js = js
|
||||
self._checksum_cache = {}
|
||||
|
|
@ -90,6 +84,7 @@ class AssetsBundle(object):
|
|||
if css:
|
||||
css_params = {
|
||||
'rtl': self.rtl,
|
||||
'autoprefix': self.autoprefix,
|
||||
}
|
||||
if extension == 'sass':
|
||||
self.stylesheets.append(SassStylesheetAsset(self, **params, **css_params))
|
||||
|
|
@ -147,7 +142,8 @@ class AssetsBundle(object):
|
|||
|
||||
def get_asset_url(self, unique=ANY_UNIQUE, extension='%', ignore_params=False):
|
||||
direction = '.rtl' if self.is_css(extension) and self.rtl else ''
|
||||
bundle_name = f"{self.name}{direction}.{extension}"
|
||||
autoprefixed = '.autoprefixed' if self.is_css(extension) and self.autoprefix else ''
|
||||
bundle_name = f"{self.name}{direction}{autoprefixed}.{extension}"
|
||||
return self.env['ir.asset']._get_asset_bundle_url(bundle_name, unique, self.assets_params, ignore_params)
|
||||
|
||||
def _unlink_attachments(self, attachments):
|
||||
|
|
@ -351,6 +347,7 @@ class AssetsBundle(object):
|
|||
|
||||
:return ir.attachment representing the un-minified content of the bundleJS
|
||||
"""
|
||||
from odoo.tools.sourcemap_generator import SourceMapGenerator # noqa: PLC0415
|
||||
sourcemap_attachment = self.get_attachments('js.map') \
|
||||
or self.save_attachment('js.map', '')
|
||||
generator = SourceMapGenerator(
|
||||
|
|
@ -463,7 +460,7 @@ class AssetsBundle(object):
|
|||
inherit_mode = template_tree.get('t-inherit-mode', 'primary')
|
||||
if inherit_mode not in ['primary', 'extension']:
|
||||
addon = asset.url.split('/')[1]
|
||||
return asset.generate_error(_(
|
||||
return asset.generate_error(self.env._(
|
||||
'Invalid inherit mode. Module "%(module)s" and template name "%(template_name)s"',
|
||||
module=addon,
|
||||
template_name=template_name,
|
||||
|
|
@ -480,7 +477,7 @@ class AssetsBundle(object):
|
|||
blocks.append(block)
|
||||
block["templates"].append((template_tree, asset.url, inherit_from))
|
||||
else:
|
||||
return asset.generate_error(_("Template name is missing."))
|
||||
return asset.generate_error(self.env._("Template name is missing."))
|
||||
return blocks
|
||||
|
||||
|
||||
|
|
@ -537,6 +534,7 @@ css_error_message {
|
|||
:param content_import_rules: string containing all the @import rules to put at the beginning of the bundle
|
||||
:return ir.attachment representing the un-minified content of the bundleCSS
|
||||
"""
|
||||
from odoo.tools.sourcemap_generator import SourceMapGenerator # noqa: PLC0415
|
||||
sourcemap_attachment = self.get_attachments('css.map') \
|
||||
or self.save_attachment('css.map', '')
|
||||
debug_asset_url = self.get_asset_url(unique='debug')
|
||||
|
|
@ -584,6 +582,9 @@ css_error_message {
|
|||
source = '\n'.join([asset.get_source() for asset in assets])
|
||||
compiled += self.compile_css(assets[0].compile, source)
|
||||
|
||||
if self.autoprefix:
|
||||
compiled = self.autoprefix_css(compiled)
|
||||
|
||||
# We want to run rtlcss on normal css, so merge it in compiled
|
||||
if self.rtl:
|
||||
stylesheet_assets = [asset for asset in self.stylesheets if not isinstance(asset, (SassStylesheetAsset, ScssStylesheetAsset, LessStylesheetAsset))]
|
||||
|
|
@ -631,18 +632,21 @@ css_error_message {
|
|||
except CompileError as e:
|
||||
return handle_compile_error(e, source=source)
|
||||
|
||||
compiled = compiled.strip()
|
||||
return compiled.strip()
|
||||
|
||||
def autoprefix_css(self, source):
|
||||
compiled = source.strip()
|
||||
|
||||
# Post process the produced css to add required vendor prefixes here
|
||||
compiled = re.sub(r'(appearance: (\w+);)', r'-webkit-appearance: \2; -moz-appearance: \2; \1', compiled)
|
||||
compiled = re.sub(r'[ \t]\b(appearance: (\w+);)', r'-webkit-appearance: \2; -moz-appearance: \2; \1', compiled)
|
||||
|
||||
# Most of those are only useful for wkhtmltopdf (some for old PhantomJS)
|
||||
compiled = re.sub(r'(display: ((?:inline-)?)flex((?: ?!important)?);)', r'display: -webkit-\2box\3; display: -webkit-\2flex\3; \1', compiled)
|
||||
compiled = re.sub(r'(justify-content: flex-(\w+)((?: ?!important)?);)', r'-webkit-box-pack: \2\3; \1', compiled)
|
||||
compiled = re.sub(r'(flex-flow: (\w+ \w+);)', r'-webkit-flex-flow: \2; \1', compiled)
|
||||
compiled = re.sub(r'(flex-direction: (column);)', r'-webkit-box-orient: vertical; -webkit-box-direction: normal; -webkit-flex-direction: \2; \1', compiled)
|
||||
compiled = re.sub(r'(flex-wrap: (\w+);)', r'-webkit-flex-wrap: \2; \1', compiled)
|
||||
compiled = re.sub(r'(flex: ((\d)+ \d+ (?:\d+|auto));)', r'-webkit-box-flex: \3; -webkit-flex: \2; \1', compiled)
|
||||
compiled = re.sub(r'[ \t]\b(display: ((?:inline-)?)flex((?: ?!important)?);)', r'display: -webkit-\2box\3; display: -webkit-\2flex\3; \1', compiled)
|
||||
compiled = re.sub(r'[ \t]\b(justify-content: flex-(\w+)((?: ?!important)?);)', r'-webkit-box-pack: \2\3; \1', compiled)
|
||||
compiled = re.sub(r'[ \t]\b(flex-flow: (\w+ \w+);)', r'-webkit-flex-flow: \2; \1', compiled)
|
||||
compiled = re.sub(r'[ \t]\b(flex-direction: (column);)', r'-webkit-box-orient: vertical; -webkit-box-direction: normal; -webkit-flex-direction: \2; \1', compiled)
|
||||
compiled = re.sub(r'[ \t]\b(flex-wrap: (\w+);)', r'-webkit-flex-wrap: \2; \1', compiled)
|
||||
compiled = re.sub(r'[ \t]\b(flex: ((\d)+ \d+ (?:\d+|auto));)', r'-webkit-box-flex: \3; -webkit-flex: \2; \1', compiled)
|
||||
|
||||
return compiled
|
||||
|
||||
|
|
@ -724,16 +728,16 @@ class WebAsset(object):
|
|||
_logger.error(msg) # log it in the python console in all cases.
|
||||
return msg
|
||||
|
||||
@func.lazy_property
|
||||
@functools.cached_property
|
||||
def id(self):
|
||||
if self._id is None: self._id = str(uuid.uuid4())
|
||||
return self._id
|
||||
|
||||
@func.lazy_property
|
||||
@functools.cached_property
|
||||
def unique_descriptor(self):
|
||||
return f'{self.url or self.inline},{self.last_modified}'
|
||||
|
||||
@func.lazy_property
|
||||
@functools.cached_property
|
||||
def name(self):
|
||||
return '<inline asset>' if self.inline else self.url
|
||||
|
||||
|
|
@ -810,6 +814,7 @@ class JavascriptAsset(WebAsset):
|
|||
@property
|
||||
def is_transpiled(self):
|
||||
if self._is_transpiled is None:
|
||||
from odoo.tools.js_transpiler import is_odoo_module # noqa: PLC0415
|
||||
self._is_transpiled = bool(is_odoo_module(self.url, super().content))
|
||||
return self._is_transpiled
|
||||
|
||||
|
|
@ -818,6 +823,7 @@ class JavascriptAsset(WebAsset):
|
|||
content = super().content
|
||||
if self.is_transpiled:
|
||||
if not self._converted_content:
|
||||
from odoo.tools.js_transpiler import transpile_javascript # noqa: PLC0415
|
||||
self._converted_content = transpile_javascript(self.url, content)
|
||||
return self._converted_content
|
||||
return content
|
||||
|
|
@ -911,18 +917,20 @@ class StylesheetAsset(WebAsset):
|
|||
rx_sourceMap = re.compile(r'(/\*# sourceMappingURL=.*)', re.U)
|
||||
rx_charset = re.compile(r'(@charset "[^"]+";)', re.U)
|
||||
|
||||
def __init__(self, *args, rtl=False, **kw):
|
||||
def __init__(self, *args, rtl=False, autoprefix=False, **kw):
|
||||
self.rtl = rtl
|
||||
self.autoprefix = autoprefix
|
||||
super().__init__(*args, **kw)
|
||||
|
||||
@property
|
||||
def bundle_version(self):
|
||||
return self.bundle.get_version('css')
|
||||
|
||||
@func.lazy_property
|
||||
@functools.cached_property
|
||||
def unique_descriptor(self):
|
||||
direction = (self.rtl and 'rtl') or 'ltr'
|
||||
return f'{self.url or self.inline},{self.last_modified},{direction}'
|
||||
autoprefixed = (self.autoprefix and 'autoprefixed') or ''
|
||||
return f'{self.url or self.inline},{self.last_modified},{direction},{autoprefixed}'
|
||||
|
||||
def _fetch_content(self):
|
||||
try:
|
||||
|
|
@ -1032,7 +1040,9 @@ class ScssStylesheetAsset(PreprocessedCSS):
|
|||
output_style = 'expanded'
|
||||
|
||||
def compile(self, source):
|
||||
if libsass is None:
|
||||
try:
|
||||
import sass as libsass # noqa: PLC0415
|
||||
except ModuleNotFoundError:
|
||||
return super().compile(source)
|
||||
|
||||
def scss_importer(path, *args):
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from base64 import b64encode
|
|||
from hashlib import sha512
|
||||
from odoo import models, fields, api
|
||||
from odoo.tools import html_escape, file_open
|
||||
from odoo.tools.misc import limited_field_access_token
|
||||
|
||||
|
||||
def get_hsl_from_seed(seed):
|
||||
|
|
@ -76,4 +77,14 @@ class AvatarMixin(models.AbstractModel):
|
|||
return "base/static/img/avatar_grey.png"
|
||||
|
||||
def _avatar_get_placeholder(self):
|
||||
return file_open(self._avatar_get_placeholder_path(), 'rb').read()
|
||||
with file_open(self._avatar_get_placeholder_path(), 'rb') as f:
|
||||
return f.read()
|
||||
|
||||
def _get_avatar_128_access_token(self):
|
||||
"""Return a scoped access token for the `avatar_128` field. The token can be
|
||||
used with `ir_binary._find_record` to bypass access rights.
|
||||
|
||||
:rtype: str
|
||||
"""
|
||||
self.ensure_one()
|
||||
return limited_field_access_token(self, "avatar_128", scope="binary")
|
||||
|
|
|
|||
|
|
@ -1,19 +1,11 @@
|
|||
# -*- encoding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models, tools, _
|
||||
import odoo.addons
|
||||
from odoo import api, fields, models, tools
|
||||
|
||||
import logging
|
||||
import sys
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_precision(application):
|
||||
_logger.warning("Deprecated call to decimal_precision.get_precision(<application>), use digits=<application> instead")
|
||||
return application
|
||||
|
||||
|
||||
class DecimalPrecision(models.Model):
|
||||
_name = 'decimal.precision'
|
||||
_description = 'Decimal Precision'
|
||||
|
|
@ -21,12 +13,13 @@ class DecimalPrecision(models.Model):
|
|||
name = fields.Char('Usage', required=True)
|
||||
digits = fields.Integer('Digits', required=True, default=2)
|
||||
|
||||
_sql_constraints = [
|
||||
('name_uniq', 'unique (name)', """Only one value can be defined for each given usage!"""),
|
||||
]
|
||||
_name_uniq = models.Constraint(
|
||||
'unique (name)',
|
||||
"Only one value can be defined for each given usage!",
|
||||
)
|
||||
|
||||
@api.model
|
||||
@tools.ormcache('application')
|
||||
@tools.ormcache('application', cache='stable')
|
||||
def precision_get(self, application):
|
||||
self.flush_model(['name', 'digits'])
|
||||
self.env.cr.execute('select digits from decimal_precision where name=%s', (application,))
|
||||
|
|
@ -35,18 +28,18 @@ class DecimalPrecision(models.Model):
|
|||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
res = super(DecimalPrecision, self).create(vals_list)
|
||||
self.env.registry.clear_cache()
|
||||
res = super().create(vals_list)
|
||||
self.env.registry.clear_cache('stable')
|
||||
return res
|
||||
|
||||
def write(self, data):
|
||||
res = super(DecimalPrecision, self).write(data)
|
||||
self.env.registry.clear_cache()
|
||||
def write(self, vals):
|
||||
res = super().write(vals)
|
||||
self.env.registry.clear_cache('stable')
|
||||
return res
|
||||
|
||||
def unlink(self):
|
||||
res = super(DecimalPrecision, self).unlink()
|
||||
self.env.registry.clear_cache()
|
||||
res = super().unlink()
|
||||
self.env.registry.clear_cache('stable')
|
||||
return res
|
||||
|
||||
@api.onchange('digits')
|
||||
|
|
@ -54,8 +47,8 @@ class DecimalPrecision(models.Model):
|
|||
if self.digits < self._origin.digits:
|
||||
return {
|
||||
'warning': {
|
||||
'title': _("Warning for %s", self.name),
|
||||
'message': _(
|
||||
'title': self.env._("Warning for %s", self.name),
|
||||
'message': self.env._(
|
||||
"The precision has been reduced for %s.\n"
|
||||
"Note that existing data WON'T be updated by this change.\n\n"
|
||||
"As decimal precisions impact the whole system, this may cause critical issues.\n"
|
||||
|
|
@ -65,9 +58,3 @@ class DecimalPrecision(models.Model):
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
# compatibility for decimal_precision.get_precision(): expose the module in addons namespace
|
||||
dp = sys.modules['odoo.addons.base.models.decimal_precision']
|
||||
odoo.addons.decimal_precision = dp
|
||||
sys.modules['odoo.addons.decimal_precision'] = dp
|
||||
sys.modules['openerp.addons.decimal_precision'] = dp
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,70 +1,57 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
from ast import literal_eval
|
||||
from contextlib import ExitStack
|
||||
from markupsafe import Markup
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from odoo import api, fields, models, tools, SUPERUSER_ID, _
|
||||
from odoo.exceptions import UserError, AccessError, RedirectWarning
|
||||
from odoo.service import security
|
||||
from odoo.tools.safe_eval import safe_eval, time
|
||||
from odoo.tools.misc import find_in_path
|
||||
from odoo.tools import check_barcode_encoding, config, is_html_empty, parse_version, split_every
|
||||
from odoo.http import request, root
|
||||
from odoo.tools.pdf import PdfFileWriter, PdfFileReader, PdfReadError
|
||||
from odoo.osv.expression import NEGATIVE_TERM_OPERATORS, FALSE_DOMAIN
|
||||
|
||||
import functools
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import lxml.html
|
||||
import tempfile
|
||||
import subprocess
|
||||
import re
|
||||
import requests
|
||||
import json
|
||||
|
||||
from lxml import etree
|
||||
from contextlib import closing
|
||||
from reportlab.graphics.barcode import createBarcodeDrawing
|
||||
from reportlab.pdfbase.pdfmetrics import getFont, TypeFace
|
||||
import subprocess
|
||||
import tempfile
|
||||
import typing
|
||||
import unittest
|
||||
from ast import literal_eval
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Iterable
|
||||
from PIL import Image, ImageFile
|
||||
from contextlib import closing, ExitStack
|
||||
from itertools import islice
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import lxml.html
|
||||
from PIL import Image, ImageFile
|
||||
from lxml import etree
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import api, fields, models, modules, tools, _
|
||||
from odoo.exceptions import UserError, AccessError, RedirectWarning
|
||||
from odoo.fields import Domain
|
||||
from odoo.service import security
|
||||
from odoo.http import request, root
|
||||
from odoo.tools import config, is_html_empty, parse_version, split_every
|
||||
from odoo.tools.barcode import check_barcode_encoding, createBarcodeDrawing, get_barcode_font
|
||||
from odoo.tools.misc import find_in_path
|
||||
from odoo.tools.pdf import PdfFileReader, PdfFileWriter, PdfReadError
|
||||
from odoo.tools.safe_eval import safe_eval, time
|
||||
|
||||
# Allow truncated images
|
||||
ImageFile.LOAD_TRUNCATED_IMAGES = True
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# A lock occurs when the user wants to print a report having multiple barcode while the server is
|
||||
# started in threaded-mode. The reason is that reportlab has to build a cache of the T1 fonts
|
||||
# before rendering a barcode (done in a C extension) and this part is not thread safe. We attempt
|
||||
# here to init the T1 fonts cache at the start-up of Odoo so that rendering of barcode in multiple
|
||||
# thread does not lock the server.
|
||||
_DEFAULT_BARCODE_FONT = 'Courier'
|
||||
try:
|
||||
available = TypeFace(_DEFAULT_BARCODE_FONT).findT1File()
|
||||
if not available:
|
||||
substitution_font = 'NimbusMonoPS-Regular'
|
||||
fnt = getFont(substitution_font)
|
||||
if fnt:
|
||||
_DEFAULT_BARCODE_FONT = substitution_font
|
||||
fnt.ascent = 629
|
||||
fnt.descent = -157
|
||||
createBarcodeDrawing('Code128', value='foo', format='png', width=100, height=100, humanReadable=1, fontName=_DEFAULT_BARCODE_FONT).asString('png')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _run_wkhtmltopdf(args):
|
||||
"""
|
||||
Runs the given arguments against the wkhtmltopdf binary.
|
||||
|
||||
def _get_wkhtmltopdf_bin():
|
||||
return find_in_path('wkhtmltopdf')
|
||||
|
||||
|
||||
def _get_wkhtmltoimage_bin():
|
||||
return find_in_path('wkhtmltoimage')
|
||||
Returns:
|
||||
The process
|
||||
"""
|
||||
bin_path = _wkhtml().bin
|
||||
return subprocess.run(
|
||||
[bin_path, *args],
|
||||
capture_output=True,
|
||||
encoding='utf-8',
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def _split_table(tree, max_rows):
|
||||
|
|
@ -87,58 +74,85 @@ def _split_table(tree, max_rows):
|
|||
prev.addnext(sibling)
|
||||
prev = sibling
|
||||
|
||||
# Check the presence of Wkhtmltopdf and return its version at Odoo start-up
|
||||
wkhtmltopdf_state = 'install'
|
||||
wkhtmltopdf_dpi_zoom_ratio = False
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
[_get_wkhtmltopdf_bin(), '--version'], stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
||||
)
|
||||
except (OSError, IOError):
|
||||
_logger.info('You need Wkhtmltopdf to print a pdf version of the reports.')
|
||||
else:
|
||||
_logger.info('Will use the Wkhtmltopdf binary at %s' % _get_wkhtmltopdf_bin())
|
||||
out, err = process.communicate()
|
||||
match = re.search(b'([0-9.]+)', out)
|
||||
if match:
|
||||
version = match.group(0).decode('ascii')
|
||||
if parse_version(version) < parse_version('0.12.0'):
|
||||
_logger.info('Upgrade Wkhtmltopdf to (at least) 0.12.0')
|
||||
wkhtmltopdf_state = 'upgrade'
|
||||
|
||||
class WkhtmlInfo(typing.NamedTuple):
|
||||
state: typing.Literal['install', 'ok']
|
||||
dpi_zoom_ratio: bool
|
||||
bin: str
|
||||
version: str
|
||||
wkhtmltoimage_bin: str
|
||||
wkhtmltoimage_version: tuple[str, ...] | None
|
||||
|
||||
|
||||
@functools.lru_cache(1)
|
||||
def _wkhtml() -> WkhtmlInfo:
|
||||
state = 'install'
|
||||
bin_path = 'wkhtmltopdf'
|
||||
version = ''
|
||||
dpi_zoom_ratio = False
|
||||
try:
|
||||
bin_path = find_in_path('wkhtmltopdf')
|
||||
process = subprocess.Popen(
|
||||
[bin_path, '--version'], stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
||||
)
|
||||
except OSError:
|
||||
_logger.info('You need Wkhtmltopdf to print a pdf version of the reports.')
|
||||
else:
|
||||
_logger.info('Will use the Wkhtmltopdf binary at %s', bin_path)
|
||||
out, _err = process.communicate()
|
||||
version = out.decode('ascii')
|
||||
match = re.search(r'([0-9.]+)', version)
|
||||
if match:
|
||||
version = match.group(0)
|
||||
if parse_version(version) < parse_version('0.12.0'):
|
||||
_logger.info('Upgrade Wkhtmltopdf to (at least) 0.12.0')
|
||||
state = 'upgrade'
|
||||
else:
|
||||
state = 'ok'
|
||||
if parse_version(version) >= parse_version('0.12.2'):
|
||||
dpi_zoom_ratio = True
|
||||
|
||||
if config['workers'] == 1:
|
||||
_logger.info('You need to start Odoo with at least two workers to print a pdf version of the reports.')
|
||||
state = 'workers'
|
||||
else:
|
||||
wkhtmltopdf_state = 'ok'
|
||||
if parse_version(version) >= parse_version('0.12.2'):
|
||||
wkhtmltopdf_dpi_zoom_ratio = True
|
||||
_logger.info('Wkhtmltopdf seems to be broken.')
|
||||
state = 'broken'
|
||||
|
||||
if config['workers'] == 1:
|
||||
_logger.info('You need to start Odoo with at least two workers to print a pdf version of the reports.')
|
||||
wkhtmltopdf_state = 'workers'
|
||||
wkhtmltoimage_version = None
|
||||
image_bin_path = 'wkhtmltoimage'
|
||||
try:
|
||||
image_bin_path = find_in_path('wkhtmltoimage')
|
||||
process = subprocess.Popen(
|
||||
[image_bin_path, '--version'], stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
||||
)
|
||||
except OSError:
|
||||
_logger.info('You need Wkhtmltoimage to generate images from html.')
|
||||
else:
|
||||
_logger.info('Wkhtmltopdf seems to be broken.')
|
||||
wkhtmltopdf_state = 'broken'
|
||||
_logger.info('Will use the Wkhtmltoimage binary at %s', image_bin_path)
|
||||
out, _err = process.communicate()
|
||||
match = re.search(rb'([0-9.]+)', out)
|
||||
if match:
|
||||
wkhtmltoimage_version = parse_version(match.group(0).decode('ascii'))
|
||||
if config['workers'] == 1:
|
||||
_logger.info('You need to start Odoo with at least two workers to convert images to html.')
|
||||
else:
|
||||
_logger.info('Wkhtmltoimage seems to be broken.')
|
||||
|
||||
wkhtmltoimage_version = None
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
[_get_wkhtmltoimage_bin(), '--version'], stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
||||
return WkhtmlInfo(
|
||||
state=state,
|
||||
dpi_zoom_ratio=dpi_zoom_ratio,
|
||||
bin=bin_path,
|
||||
version=version,
|
||||
wkhtmltoimage_bin=image_bin_path,
|
||||
wkhtmltoimage_version=wkhtmltoimage_version,
|
||||
)
|
||||
except OSError:
|
||||
_logger.info('You need Wkhtmltoimage to generate images from html.')
|
||||
else:
|
||||
_logger.info('Will use the Wkhtmltoimage binary at %s', _get_wkhtmltoimage_bin())
|
||||
out, err = process.communicate()
|
||||
match = re.search(b'([0-9.]+)', out)
|
||||
if match:
|
||||
wkhtmltoimage_version = parse_version(match.group(0).decode('ascii'))
|
||||
if config['workers'] == 1:
|
||||
_logger.info('You need to start Odoo with at least two workers to convert images to html.')
|
||||
else:
|
||||
_logger.info('Wkhtmltoimage seems to be broken.')
|
||||
|
||||
|
||||
class IrActionsReport(models.Model):
|
||||
_name = 'ir.actions.report'
|
||||
_description = 'Report Action'
|
||||
_inherit = 'ir.actions.actions'
|
||||
_inherit = ['ir.actions.actions']
|
||||
_table = 'ir_act_report_xml'
|
||||
_order = 'name, id'
|
||||
_allow_sudo_commands = False
|
||||
|
|
@ -160,10 +174,10 @@ class IrActionsReport(models.Model):
|
|||
report_name = fields.Char(string='Template Name', required=True)
|
||||
report_file = fields.Char(string='Report File', required=False, readonly=False, store=True,
|
||||
help="The path to the main report file (depending on Report Type) or empty if the content is in another field")
|
||||
groups_id = fields.Many2many('res.groups', 'res_groups_report_rel', 'uid', 'gid', string='Groups')
|
||||
group_ids = fields.Many2many('res.groups', 'res_groups_report_rel', 'uid', 'gid', string='Groups')
|
||||
multi = fields.Boolean(string='On Multiple Doc.', help="If set to true, the action will not be displayed on the right toolbar of a form view.")
|
||||
|
||||
paperformat_id = fields.Many2one('report.paperformat', 'Paper Format')
|
||||
paperformat_id = fields.Many2one('report.paperformat', 'Paper Format', index='btree_not_null')
|
||||
print_report_name = fields.Char('Printed Report Name', translate=True,
|
||||
help="This is the filename of the report going to download. Keep empty to not change the report filename. You can use a python expression with the 'object' and 'time' variables.")
|
||||
attachment_use = fields.Boolean(string='Reload from Attachment',
|
||||
|
|
@ -178,28 +192,24 @@ class IrActionsReport(models.Model):
|
|||
action.model_id = self.env['ir.model']._get(action.model).id
|
||||
|
||||
def _search_model_id(self, operator, value):
|
||||
ir_model_ids = None
|
||||
if operator in Domain.NEGATIVE_OPERATORS:
|
||||
return NotImplemented
|
||||
models = self.env['ir.model']
|
||||
if isinstance(value, str):
|
||||
names = self.env['ir.model'].name_search(value, operator=operator)
|
||||
ir_model_ids = [n[0] for n in names]
|
||||
|
||||
elif operator in ('any', 'not any'):
|
||||
ir_model_ids = self.env['ir.model']._search(value)
|
||||
|
||||
elif isinstance(value, Iterable):
|
||||
ir_model_ids = value
|
||||
|
||||
elif isinstance(value, int) and not isinstance(value, bool):
|
||||
ir_model_ids = [value]
|
||||
|
||||
if ir_model_ids:
|
||||
operator = 'not in' if operator in NEGATIVE_TERM_OPERATORS else 'in'
|
||||
ir_model = self.env['ir.model'].browse(ir_model_ids)
|
||||
return [('model', operator, ir_model.mapped('model'))]
|
||||
elif isinstance(value, bool) or value is None:
|
||||
return [('model', operator, value)]
|
||||
else:
|
||||
return FALSE_DOMAIN
|
||||
models = models.search(Domain('display_name', operator, value))
|
||||
elif isinstance(value, Domain):
|
||||
models = models.search(value)
|
||||
elif operator == 'any!':
|
||||
models = models.sudo().search(Domain('id', operator, value))
|
||||
elif operator == 'any' or isinstance(value, int):
|
||||
models = models.search(Domain('id', operator, value))
|
||||
elif operator == 'in':
|
||||
models = models.search(Domain.OR(
|
||||
Domain('id' if isinstance(v, int) else 'display_name', operator, v)
|
||||
for v in value
|
||||
if v
|
||||
))
|
||||
return Domain('model', 'in', models.mapped('model'))
|
||||
|
||||
def _get_readable_fields(self):
|
||||
return super()._get_readable_fields() | {
|
||||
|
|
@ -268,7 +278,7 @@ class IrActionsReport(models.Model):
|
|||
|
||||
:return: wkhtmltopdf_state
|
||||
'''
|
||||
return wkhtmltopdf_state
|
||||
return _wkhtml().state
|
||||
|
||||
def get_paperformat(self):
|
||||
return self.paperformat_id or self.env.company.paperformat_id
|
||||
|
|
@ -333,7 +343,7 @@ class IrActionsReport(models.Model):
|
|||
dpi = paperformat_id.dpi
|
||||
if dpi:
|
||||
command_args.extend(['--dpi', str(dpi)])
|
||||
if wkhtmltopdf_dpi_zoom_ratio:
|
||||
if _wkhtml().dpi_zoom_ratio:
|
||||
command_args.extend(['--zoom', str(96.0 / dpi)])
|
||||
|
||||
if specific_paperformat_args and specific_paperformat_args.get('data-report-header-spacing'):
|
||||
|
|
@ -371,13 +381,6 @@ class IrActionsReport(models.Model):
|
|||
The idea is to put all headers/footers together. Then, we will use a javascript trick
|
||||
(see minimal_layout template) to set the right header/footer during the processing of wkhtmltopdf.
|
||||
This allows the computation of multiple reports in a single call to wkhtmltopdf.
|
||||
|
||||
:param html: The html rendered by render_qweb_html.
|
||||
:type: bodies: list of string representing each one a html body.
|
||||
:type header: string representing the html header.
|
||||
:type footer: string representing the html footer.
|
||||
:type specific_paperformat_args: dictionary of prioritized paperformat values.
|
||||
:return: bodies, header, footer, specific_paperformat_args
|
||||
'''
|
||||
|
||||
# Return empty dictionary if 'web.minimal_layout' not found.
|
||||
|
|
@ -462,8 +465,9 @@ class IrActionsReport(models.Model):
|
|||
:param image_format union['jpg', 'png']: format of the image
|
||||
:return list[bytes|None]:
|
||||
"""
|
||||
if (tools.config['test_enable'] or tools.config['test_file']) and not self.env.context.get('force_image_rendering'):
|
||||
if (modules.module.current_test or tools.config['test_enable']) and not self.env.context.get('force_image_rendering'):
|
||||
return [None] * len(bodies)
|
||||
wkhtmltoimage_version = _wkhtml().wkhtmltoimage_version
|
||||
if not wkhtmltoimage_version or wkhtmltoimage_version < parse_version('0.12.0'):
|
||||
raise UserError(_('wkhtmltoimage 0.12.0^ is required in order to render images from html'))
|
||||
command_args = [
|
||||
|
|
@ -475,17 +479,19 @@ class IrActionsReport(models.Model):
|
|||
with ExitStack() as stack:
|
||||
files = []
|
||||
for body in bodies:
|
||||
input_file = stack.enter_context(tempfile.NamedTemporaryFile(suffix='.html', prefix='report_image_html_input.tmp.'))
|
||||
output_file = stack.enter_context(tempfile.NamedTemporaryFile(suffix=f'.{image_format}', prefix='report_image_output.tmp.'))
|
||||
input_file.write(body.encode())
|
||||
files.append((input_file, output_file))
|
||||
(input_fd, input_path) = tempfile.mkstemp(suffix='.html', prefix='report_image_html_input.tmp.')
|
||||
(output_fd, output_path) = tempfile.mkstemp(suffix=f'.{image_format}', prefix='report_image_output.tmp.')
|
||||
stack.callback(os.remove, input_path)
|
||||
stack.callback(os.remove, output_path)
|
||||
os.close(output_fd)
|
||||
with closing(os.fdopen(input_fd, 'wb')) as input_file:
|
||||
input_file.write(body.encode())
|
||||
files.append((input_path, output_path))
|
||||
output_images = []
|
||||
for input_file, output_file in files:
|
||||
# smaller bodies may be held in a python buffer until close, force flush
|
||||
input_file.flush()
|
||||
wkhtmltoimage = [_get_wkhtmltoimage_bin()] + command_args + [input_file.name, output_file.name]
|
||||
for (input_path, output_path) in files:
|
||||
wkhtmltoimage = [_wkhtml().wkhtmltoimage_bin, *command_args, input_path, output_path]
|
||||
# start and block, no need for parallelism for now
|
||||
completed_process = subprocess.run(wkhtmltoimage, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, check=False)
|
||||
completed_process = subprocess.run(wkhtmltoimage, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, check=False, encoding='utf-8')
|
||||
if completed_process.returncode:
|
||||
message = _(
|
||||
'Wkhtmltoimage failed (error code: %(error_code)s). Message: %(error_message_end)s',
|
||||
|
|
@ -495,7 +501,8 @@ class IrActionsReport(models.Model):
|
|||
_logger.warning(message)
|
||||
output_images.append(None)
|
||||
else:
|
||||
output_images.append(output_file.read())
|
||||
with open(output_path, 'rb') as output_file:
|
||||
output_images.append(output_file.read())
|
||||
return output_images
|
||||
|
||||
@api.model
|
||||
|
|
@ -511,7 +518,7 @@ class IrActionsReport(models.Model):
|
|||
'''Execute wkhtmltopdf as a subprocess in order to convert html given in input into a pdf
|
||||
document.
|
||||
|
||||
:param list[str] bodies: The html bodies of the report, one per page.
|
||||
:param Iterable[str] bodies: The html bodies of the report, one per page.
|
||||
:param report_ref: report reference that is needed to get report paperformat.
|
||||
:param str header: The html header of the report containing all headers.
|
||||
:param str footer: The html footer of the report containing all footers.
|
||||
|
|
@ -531,107 +538,107 @@ class IrActionsReport(models.Model):
|
|||
set_viewport_size=set_viewport_size)
|
||||
|
||||
files_command_args = []
|
||||
temporary_files = []
|
||||
temp_session = None
|
||||
|
||||
# Passing the cookie to wkhtmltopdf in order to resolve internal links.
|
||||
if request and request.db:
|
||||
# Create a temporary session which will not create device logs
|
||||
temp_session = root.session_store.new()
|
||||
temp_session.update({
|
||||
**request.session,
|
||||
'debug': '',
|
||||
'_trace_disable': True,
|
||||
})
|
||||
if temp_session.uid:
|
||||
temp_session.session_token = security.compute_session_token(temp_session, self.env)
|
||||
root.session_store.save(temp_session)
|
||||
def delete_file(file_path):
|
||||
try:
|
||||
os.unlink(file_path)
|
||||
except OSError:
|
||||
_logger.error('Error when trying to remove file %s', file_path)
|
||||
|
||||
base_url = self._get_report_url()
|
||||
domain = urlparse(base_url).hostname
|
||||
cookie = f'session_id={temp_session.sid}; HttpOnly; domain={domain}; path=/;'
|
||||
cookie_jar_file_fd, cookie_jar_file_path = tempfile.mkstemp(suffix='.txt', prefix='report.cookie_jar.tmp.')
|
||||
temporary_files.append(cookie_jar_file_path)
|
||||
with closing(os.fdopen(cookie_jar_file_fd, 'wb')) as cookie_jar_file:
|
||||
cookie_jar_file.write(cookie.encode())
|
||||
command_args.extend(['--cookie-jar', cookie_jar_file_path])
|
||||
with ExitStack() as stack:
|
||||
|
||||
if header:
|
||||
head_file_fd, head_file_path = tempfile.mkstemp(suffix='.html', prefix='report.header.tmp.')
|
||||
with closing(os.fdopen(head_file_fd, 'wb')) as head_file:
|
||||
head_file.write(header.encode())
|
||||
temporary_files.append(head_file_path)
|
||||
files_command_args.extend(['--header-html', head_file_path])
|
||||
if footer:
|
||||
foot_file_fd, foot_file_path = tempfile.mkstemp(suffix='.html', prefix='report.footer.tmp.')
|
||||
with closing(os.fdopen(foot_file_fd, 'wb')) as foot_file:
|
||||
foot_file.write(footer.encode())
|
||||
temporary_files.append(foot_file_path)
|
||||
files_command_args.extend(['--footer-html', foot_file_path])
|
||||
# Passing the cookie to wkhtmltopdf in order to resolve internal links.
|
||||
if request and request.db:
|
||||
# Create a temporary session which will not create device logs
|
||||
temp_session = root.session_store.new()
|
||||
temp_session.update({
|
||||
**request.session,
|
||||
'debug': '',
|
||||
'_trace_disable': True,
|
||||
})
|
||||
if temp_session.uid:
|
||||
temp_session.session_token = security.compute_session_token(temp_session, self.env)
|
||||
root.session_store.save(temp_session)
|
||||
stack.callback(root.session_store.delete, temp_session)
|
||||
|
||||
paths = []
|
||||
for i, body in enumerate(bodies):
|
||||
prefix = '%s%d.' % ('report.body.tmp.', i)
|
||||
body_file_fd, body_file_path = tempfile.mkstemp(suffix='.html', prefix=prefix)
|
||||
with closing(os.fdopen(body_file_fd, 'wb')) as body_file:
|
||||
# HACK: wkhtmltopdf doesn't like big table at all and the
|
||||
# processing time become exponential with the number
|
||||
# of rows (like 1H for 250k rows).
|
||||
#
|
||||
# So we split the table into multiple tables containing
|
||||
# 500 rows each. This reduce the processing time to 1min
|
||||
# for 250k rows. The number 500 was taken from opw-1689673
|
||||
if len(body) < 4 * 1024 * 1024: # 4Mib
|
||||
body_file.write(body.encode())
|
||||
else:
|
||||
tree = lxml.html.fromstring(body)
|
||||
_split_table(tree, 500)
|
||||
body_file.write(lxml.html.tostring(tree))
|
||||
paths.append(body_file_path)
|
||||
temporary_files.append(body_file_path)
|
||||
base_url = self._get_report_url()
|
||||
domain = urlparse(base_url).hostname
|
||||
cookie = f'session_id={temp_session.sid}; HttpOnly; domain={domain}; path=/;'
|
||||
cookie_jar_file_fd, cookie_jar_file_path = tempfile.mkstemp(suffix='.txt', prefix='report.cookie_jar.tmp.')
|
||||
stack.callback(delete_file, cookie_jar_file_path)
|
||||
with closing(os.fdopen(cookie_jar_file_fd, 'wb')) as cookie_jar_file:
|
||||
cookie_jar_file.write(cookie.encode())
|
||||
command_args.extend(['--cookie-jar', cookie_jar_file_path])
|
||||
|
||||
pdf_report_fd, pdf_report_path = tempfile.mkstemp(suffix='.pdf', prefix='report.tmp.')
|
||||
os.close(pdf_report_fd)
|
||||
temporary_files.append(pdf_report_path)
|
||||
if header:
|
||||
head_file_fd, head_file_path = tempfile.mkstemp(suffix='.html', prefix='report.header.tmp.')
|
||||
with closing(os.fdopen(head_file_fd, 'wb')) as head_file:
|
||||
head_file.write(header.encode())
|
||||
stack.callback(delete_file, head_file_path)
|
||||
files_command_args.extend(['--header-html', head_file_path])
|
||||
if footer:
|
||||
foot_file_fd, foot_file_path = tempfile.mkstemp(suffix='.html', prefix='report.footer.tmp.')
|
||||
with closing(os.fdopen(foot_file_fd, 'wb')) as foot_file:
|
||||
foot_file.write(footer.encode())
|
||||
stack.callback(delete_file, foot_file_path)
|
||||
files_command_args.extend(['--footer-html', foot_file_path])
|
||||
|
||||
try:
|
||||
wkhtmltopdf = [_get_wkhtmltopdf_bin()] + command_args + files_command_args + paths + [pdf_report_path]
|
||||
process = subprocess.Popen(wkhtmltopdf, stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf-8")
|
||||
_out, err = process.communicate()
|
||||
paths = []
|
||||
body_idx = 0
|
||||
for body_idx, body in enumerate(bodies):
|
||||
prefix = f'report.body.tmp.{body_idx}.'
|
||||
body_file_fd, body_file_path = tempfile.mkstemp(suffix='.html', prefix=prefix)
|
||||
with closing(os.fdopen(body_file_fd, 'wb')) as body_file:
|
||||
# HACK: wkhtmltopdf doesn't like big table at all and the
|
||||
# processing time become exponential with the number
|
||||
# of rows (like 1H for 250k rows).
|
||||
#
|
||||
# So we split the table into multiple tables containing
|
||||
# 500 rows each. This reduce the processing time to 1min
|
||||
# for 250k rows. The number 500 was taken from opw-1689673
|
||||
if len(body) < 4 * 1024 * 1024: # 4Mib
|
||||
body_file.write(body.encode())
|
||||
else:
|
||||
tree = lxml.html.fromstring(body)
|
||||
_split_table(tree, 500)
|
||||
body_file.write(lxml.html.tostring(tree))
|
||||
paths.append(body_file_path)
|
||||
stack.callback(delete_file, body_file_path)
|
||||
|
||||
if process.returncode not in [0, 1]:
|
||||
if process.returncode == -11:
|
||||
pdf_report_fd, pdf_report_path = tempfile.mkstemp(suffix='.pdf', prefix='report.tmp.')
|
||||
os.close(pdf_report_fd)
|
||||
stack.callback(delete_file, pdf_report_path)
|
||||
|
||||
process = _run_wkhtmltopdf(command_args + files_command_args + paths + [pdf_report_path])
|
||||
err = process.stderr
|
||||
|
||||
match process.returncode:
|
||||
case 0:
|
||||
pass
|
||||
case 1:
|
||||
if body_idx:
|
||||
wk_version = _wkhtml().version
|
||||
if '(with patched qt)' not in wk_version:
|
||||
if modules.module.current_test:
|
||||
raise unittest.SkipTest("Unable to convert multiple documents via wkhtmltopdf using unpatched QT")
|
||||
raise UserError(_("Tried to convert multiple documents in wkhtmltopdf using unpatched QT"))
|
||||
|
||||
_logger.warning("wkhtmltopdf: %s", err)
|
||||
case c:
|
||||
message = _(
|
||||
'Wkhtmltopdf failed (error code: %(error_code)s). Memory limit too low or maximum file number of subprocess reached. Message : %(message)s',
|
||||
error_code=process.returncode,
|
||||
error_code=c,
|
||||
message=err[-1000:],
|
||||
)
|
||||
else:
|
||||
message = _(
|
||||
) if c == -11 else _(
|
||||
'Wkhtmltopdf failed (error code: %(error_code)s). Message: %(message)s',
|
||||
error_code=process.returncode,
|
||||
error_code=c,
|
||||
message=err[-1000:],
|
||||
)
|
||||
_logger.warning(message)
|
||||
raise UserError(message)
|
||||
else:
|
||||
if err:
|
||||
_logger.warning('wkhtmltopdf: %s' % err)
|
||||
except:
|
||||
raise
|
||||
finally:
|
||||
if temp_session:
|
||||
root.session_store.delete(temp_session)
|
||||
_logger.warning(message)
|
||||
raise UserError(message)
|
||||
|
||||
with open(pdf_report_path, 'rb') as pdf_document:
|
||||
pdf_content = pdf_document.read()
|
||||
|
||||
# Manual cleanup of the temporary files
|
||||
for temporary_file in temporary_files:
|
||||
try:
|
||||
os.unlink(temporary_file)
|
||||
except (OSError, IOError):
|
||||
_logger.error('Error when trying to remove file %s' % temporary_file)
|
||||
with open(pdf_report_path, 'rb') as pdf_document:
|
||||
pdf_content = pdf_document.read()
|
||||
|
||||
return pdf_content
|
||||
|
||||
|
|
@ -648,7 +655,9 @@ class IrActionsReport(models.Model):
|
|||
@api.model
|
||||
def _get_report(self, report_ref):
|
||||
"""Get the report (with sudo) from a reference
|
||||
report_ref: can be one of
|
||||
|
||||
:param report_ref: can be one of
|
||||
|
||||
- ir.actions.report id
|
||||
- ir.actions.report record
|
||||
- ir.model.data reference to ir.actions.report
|
||||
|
|
@ -691,7 +700,7 @@ class IrActionsReport(models.Model):
|
|||
kwargs = {k: validator(kwargs.get(k, v)) for k, (v, validator) in defaults.items()}
|
||||
kwargs['humanReadable'] = kwargs.pop('humanreadable')
|
||||
if kwargs['humanReadable']:
|
||||
kwargs['fontName'] = _DEFAULT_BARCODE_FONT
|
||||
kwargs['fontName'] = get_barcode_font()
|
||||
|
||||
if kwargs['width'] * kwargs['height'] > 1200000 or max(kwargs['width'], kwargs['height']) > 10000:
|
||||
raise ValueError("Barcode too large")
|
||||
|
|
@ -706,7 +715,8 @@ class IrActionsReport(models.Model):
|
|||
elif barcode_type == 'QR':
|
||||
# for `QR` type, `quiet` is not supported. And is simply ignored.
|
||||
# But we can use `barBorder` to get a similar behaviour.
|
||||
if kwargs['quiet']:
|
||||
# quiet=True & barBorder=4 by default cf above, remove border only if quiet=False
|
||||
if not kwargs['quiet']:
|
||||
kwargs['barBorder'] = 0
|
||||
|
||||
if barcode_type in ('EAN8', 'EAN13') and not check_barcode_encoding(value, barcode_type):
|
||||
|
|
@ -740,10 +750,12 @@ class IrActionsReport(models.Model):
|
|||
@api.model
|
||||
def get_available_barcode_masks(self):
|
||||
""" Hook for extension.
|
||||
|
||||
This function returns the available QR-code masks, in the form of a
|
||||
list of (code, mask_function) elements, where code is a string identifying
|
||||
the mask uniquely, and mask_function is a function returning a reportlab
|
||||
Drawing object with the result of the mask, and taking as parameters:
|
||||
|
||||
- width of the QR-code, in pixels
|
||||
- height of the QR-code, in pixels
|
||||
- reportlab Drawing object containing the barcode to apply the mask on
|
||||
|
|
@ -811,7 +823,7 @@ class IrActionsReport(models.Model):
|
|||
|
||||
stream = None
|
||||
attachment = None
|
||||
if not has_duplicated_ids and report_sudo.attachment and not self._context.get("report_pdf_no_attachment"):
|
||||
if not has_duplicated_ids and report_sudo.attachment and not self.env.context.get("report_pdf_no_attachment"):
|
||||
attachment = report_sudo.retrieve_attachment(record)
|
||||
|
||||
# Extract the stream from the attachment.
|
||||
|
|
@ -871,9 +883,9 @@ class IrActionsReport(models.Model):
|
|||
report_ref=report_ref,
|
||||
header=header,
|
||||
footer=footer,
|
||||
landscape=self._context.get('landscape'),
|
||||
landscape=self.env.context.get('landscape'),
|
||||
specific_paperformat_args=specific_paperformat_args,
|
||||
set_viewport_size=self._context.get('set_viewport_size'),
|
||||
set_viewport_size=self.env.context.get('set_viewport_size'),
|
||||
)
|
||||
pdf_content_stream = io.BytesIO(pdf_content)
|
||||
|
||||
|
|
@ -1002,7 +1014,7 @@ class IrActionsReport(models.Model):
|
|||
data.setdefault('report_type', 'pdf')
|
||||
# In case of test environment without enough workers to perform calls to wkhtmltopdf,
|
||||
# fallback to render_html.
|
||||
if (tools.config['test_enable'] or tools.config['test_file']) and not self.env.context.get('force_report_rendering'):
|
||||
if (modules.module.current_test or tools.config['test_enable']) and not self.env.context.get('force_report_rendering'):
|
||||
return self._render_qweb_html(report_ref, res_ids, data=data)
|
||||
|
||||
self = self.with_context(webp_as_jpg=True)
|
||||
|
|
@ -1025,7 +1037,7 @@ class IrActionsReport(models.Model):
|
|||
report_sudo = self._get_report(report_ref)
|
||||
|
||||
# Generate the ir.attachment if needed.
|
||||
if not has_duplicated_ids and report_sudo.attachment and not self._context.get("report_pdf_no_attachment"):
|
||||
if not has_duplicated_ids and report_sudo.attachment and not self.env.context.get("report_pdf_no_attachment"):
|
||||
attachment_vals_list = self._prepare_pdf_report_attachment_vals_list(report_sudo, collected_streams)
|
||||
if attachment_vals_list:
|
||||
attachment_names = ', '.join(x['name'] for x in attachment_vals_list)
|
||||
|
|
|
|||
|
|
@ -4,9 +4,8 @@ from glob import glob
|
|||
from logging import getLogger
|
||||
from werkzeug import urls
|
||||
|
||||
import odoo
|
||||
import odoo.modules.module # get_manifest, don't from-import it
|
||||
from odoo import api, fields, models, tools
|
||||
from odoo.modules import Manifest
|
||||
from odoo.tools import misc
|
||||
from odoo.tools.constants import ASSET_EXTENSIONS, EXTERNAL_ASSET
|
||||
|
||||
|
|
@ -68,10 +67,10 @@ class IrAsset(models.Model):
|
|||
self.env.registry.clear_cache('assets')
|
||||
return super().create(vals_list)
|
||||
|
||||
def write(self, values):
|
||||
def write(self, vals):
|
||||
if self:
|
||||
self.env.registry.clear_cache('assets')
|
||||
return super().write(values)
|
||||
return super().write(vals)
|
||||
|
||||
def unlink(self):
|
||||
self.env.registry.clear_cache('assets')
|
||||
|
|
@ -105,11 +104,15 @@ class IrAsset(models.Model):
|
|||
def _parse_bundle_name(self, bundle_name, debug_assets):
|
||||
bundle_name, asset_type = bundle_name.rsplit('.', 1)
|
||||
rtl = False
|
||||
autoprefix = False
|
||||
if not debug_assets:
|
||||
bundle_name, min_ = bundle_name.rsplit('.', 1)
|
||||
if min_ != 'min':
|
||||
raise ValueError("'min' expected in extension in non debug mode")
|
||||
if asset_type == 'css':
|
||||
if bundle_name.endswith('.autoprefixed'):
|
||||
bundle_name = bundle_name[:-13]
|
||||
autoprefix = True
|
||||
if bundle_name.endswith('.rtl'):
|
||||
bundle_name = bundle_name[:-4]
|
||||
rtl = True
|
||||
|
|
@ -117,7 +120,7 @@ class IrAsset(models.Model):
|
|||
raise ValueError('Only js and css assets bundle are supported for now')
|
||||
if len(bundle_name.split('.')) != 2:
|
||||
raise ValueError(f'{bundle_name} is not a valid bundle name, should have two parts')
|
||||
return bundle_name, rtl, asset_type
|
||||
return bundle_name, rtl, asset_type, autoprefix
|
||||
|
||||
@tools.conditional(
|
||||
'xml' not in tools.config['dev_mode'],
|
||||
|
|
@ -183,7 +186,7 @@ class IrAsset(models.Model):
|
|||
|
||||
# 2. Process all addons' manifests.
|
||||
for addon in addons:
|
||||
for command in odoo.modules.module._get_manifest_cached(addon)['assets'].get(bundle, ()):
|
||||
for command in Manifest.for_addon(addon)['assets'].get(bundle, ()):
|
||||
directive, target, path_def = self._process_command(command)
|
||||
self._process_path(bundle, directive, target, path_def, asset_paths, seen, addons, installed, bundle_start_index, **assets_params)
|
||||
|
||||
|
|
@ -240,7 +243,7 @@ class IrAsset(models.Model):
|
|||
# this should never happen
|
||||
raise ValueError("Unexpected directive")
|
||||
|
||||
def _get_related_assets(self, domain):
|
||||
def _get_related_assets(self, domain, **kwargs):
|
||||
"""
|
||||
Returns a set of assets matching the domain, regardless of their
|
||||
active state. This method can be overridden to filter the results.
|
||||
|
|
@ -258,8 +261,8 @@ class IrAsset(models.Model):
|
|||
a specific asset and target the right bundle, i.e. the first one
|
||||
defining the target path.
|
||||
|
||||
:param target_path_def: string: path to match.
|
||||
:root_bundle: string: bundle from which to initiate the search.
|
||||
:param str target_path_def: path to match.
|
||||
:param str root_bundle: bundle from which to initiate the search.
|
||||
:returns: the first matching bundle or None
|
||||
"""
|
||||
installed = self._get_installed_addons_list()
|
||||
|
|
@ -273,7 +276,7 @@ class IrAsset(models.Model):
|
|||
|
||||
return root_bundle
|
||||
|
||||
def _get_active_addons_list(self):
|
||||
def _get_active_addons_list(self, **kwargs):
|
||||
"""Can be overridden to filter the returned list of active modules."""
|
||||
return self._get_installed_addons_list()
|
||||
|
||||
|
|
@ -285,10 +288,10 @@ class IrAsset(models.Model):
|
|||
IrModule = self.env['ir.module.module']
|
||||
|
||||
def mapper(addon):
|
||||
manif = odoo.modules.module._get_manifest_cached(addon)
|
||||
manif = Manifest.for_addon(addon) or {}
|
||||
from_terp = IrModule.get_values_from_terp(manif)
|
||||
from_terp['name'] = addon
|
||||
from_terp['depends'] = manif.get('depends', ['base'])
|
||||
from_terp['depends'] = manif.get('depends') or ['base']
|
||||
return from_terp
|
||||
|
||||
manifs = map(mapper, addons_tuple)
|
||||
|
|
@ -307,9 +310,7 @@ class IrAsset(models.Model):
|
|||
Returns the list of all installed addons.
|
||||
:returns: string[]: list of module names
|
||||
"""
|
||||
# Main source: the current registry list
|
||||
# Second source of modules: server wide modules
|
||||
return self.env.registry._init_modules.union(odoo.conf.server_wide_modules or [])
|
||||
return self.env.registry._init_modules.union(tools.config['server_wide_modules'])
|
||||
|
||||
def _get_paths(self, path_def, installed):
|
||||
"""
|
||||
|
|
@ -330,35 +331,31 @@ class IrAsset(models.Model):
|
|||
|
||||
:param path_def: the definition (glob) of file paths to match
|
||||
:param installed: the list of installed addons
|
||||
:param extensions: a list of extensions that found files must match
|
||||
:returns: a list of tuple: (path, full_path, modified)
|
||||
"""
|
||||
paths = None
|
||||
path_def = fs2web(path_def) # we expect to have all path definition unix style or url style, this is a safety
|
||||
path_parts = [part for part in path_def.split('/') if part]
|
||||
addon = path_parts[0]
|
||||
addon_manifest = odoo.modules.module._get_manifest_cached(addon)
|
||||
addon_manifest = Manifest.for_addon(addon, display_warning=False)
|
||||
|
||||
safe_path = True
|
||||
safe_path = False
|
||||
if addon_manifest:
|
||||
if addon not in installed:
|
||||
# Assert that the path is in the installed addons
|
||||
raise Exception(f"Unallowed to fetch files from addon {addon} for file {path_def}")
|
||||
addons_path = addon_manifest['addons_path']
|
||||
full_path = os.path.normpath(os.sep.join([addons_path, *path_parts]))
|
||||
addons_path = addon_manifest.addons_path
|
||||
full_path = os.path.normpath(os.path.join(addons_path, *path_parts))
|
||||
# forbid escape from the current addon
|
||||
# "/mymodule/../myothermodule" is forbidden
|
||||
static_prefix = os.sep.join([addons_path, addon, 'static', ''])
|
||||
static_prefix = os.path.join(addon_manifest.path, 'static', '')
|
||||
if full_path.startswith(static_prefix):
|
||||
paths_with_timestamps = _glob_static_file(full_path)
|
||||
paths = [
|
||||
(fs2web(absolute_path[len(addons_path):]), absolute_path, timestamp)
|
||||
for absolute_path, timestamp in paths_with_timestamps
|
||||
]
|
||||
else:
|
||||
safe_path = False
|
||||
else:
|
||||
safe_path = False
|
||||
safe_path = True
|
||||
|
||||
if not paths and not can_aggregate(path_def): # http:// or /web/content
|
||||
paths = [(path_def, EXTERNAL_ASSET, -1)]
|
||||
|
|
|
|||
|
|
@ -10,18 +10,39 @@ import os
|
|||
import psycopg2
|
||||
import re
|
||||
import uuid
|
||||
import warnings
|
||||
import werkzeug
|
||||
|
||||
from collections import defaultdict
|
||||
from collections.abc import Collection
|
||||
|
||||
from odoo import api, fields, models, SUPERUSER_ID, tools, _
|
||||
from odoo.exceptions import AccessError, ValidationError, UserError
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import AccessError, MissingError, ValidationError, UserError
|
||||
from odoo.fields import Domain
|
||||
from odoo.http import Stream, root, request
|
||||
from odoo.tools import config, human_size, image, str2bool, consteq
|
||||
from odoo.tools import config, consteq, human_size, image, split_every, str2bool, OrderedSet
|
||||
from odoo.tools.constants import PREFETCH_MAX
|
||||
from odoo.tools.mimetypes import guess_mimetype, fix_filename_extension, _olecf_mimetypes
|
||||
from odoo.osv import expression
|
||||
from odoo.tools.misc import limited_field_access_token
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
SECURITY_FIELDS = ('res_model', 'res_id', 'create_uid', 'public', 'res_field')
|
||||
|
||||
|
||||
def condition_values(model, field_name, domain):
|
||||
"""Get the values in the domain for a specific field name.
|
||||
|
||||
Returns the values appearing in the `in` conditions that would be restricted
|
||||
to by the domain.
|
||||
"""
|
||||
domain = domain.optimize(model)
|
||||
for condition in domain.map_conditions(
|
||||
lambda cond: cond
|
||||
if cond.field_expr == field_name and cond.operator == 'in'
|
||||
else Domain.TRUE
|
||||
).optimize(model).iter_conditions():
|
||||
return condition.value
|
||||
return None
|
||||
|
||||
|
||||
class IrAttachment(models.Model):
|
||||
|
|
@ -56,7 +77,7 @@ class IrAttachment(models.Model):
|
|||
|
||||
@api.model
|
||||
def _filestore(self):
|
||||
return config.filestore(self._cr.dbname)
|
||||
return config.filestore(self.env.cr.dbname)
|
||||
|
||||
@api.model
|
||||
def _get_storage_domain(self):
|
||||
|
|
@ -74,7 +95,7 @@ class IrAttachment(models.Model):
|
|||
|
||||
# Migrate only binary attachments and bypass the res_field automatic
|
||||
# filter added in _search override
|
||||
self.search(expression.AND([
|
||||
self.search(Domain.AND([
|
||||
self._get_storage_domain(),
|
||||
['&', ('type', '=', 'binary'), '|', ('res_field', '=', False), ('res_field', '!=', False)]
|
||||
]))._migrate()
|
||||
|
|
@ -82,11 +103,6 @@ class IrAttachment(models.Model):
|
|||
def _migrate(self):
|
||||
record_count = len(self)
|
||||
storage = self._storage().upper()
|
||||
# When migrating to filestore verifying if the directory has write permission
|
||||
if storage == 'FILE':
|
||||
filestore = self._filestore()
|
||||
if not os.access(filestore, os.W_OK):
|
||||
raise PermissionError("Write permission denied for filestore directory.")
|
||||
for index, attach in enumerate(self):
|
||||
_logger.debug("Migrate attachment %s/%s to %s", index + 1, record_count, storage)
|
||||
# pass mimetype, to avoid recomputation
|
||||
|
|
@ -95,7 +111,7 @@ class IrAttachment(models.Model):
|
|||
@api.model
|
||||
def _full_path(self, path):
|
||||
# sanitize path
|
||||
path = re.sub('[.]', '', path)
|
||||
path = re.sub('[.:]', '', path)
|
||||
path = path.strip('/\\')
|
||||
return os.path.join(self._filestore(), path)
|
||||
|
||||
|
|
@ -115,13 +131,13 @@ class IrAttachment(models.Model):
|
|||
return fname, full_path
|
||||
|
||||
@api.model
|
||||
def _file_read(self, fname):
|
||||
def _file_read(self, fname, size=None):
|
||||
assert isinstance(self, IrAttachment)
|
||||
full_path = self._full_path(fname)
|
||||
try:
|
||||
with open(full_path, 'rb') as f:
|
||||
return f.read()
|
||||
except (IOError, OSError):
|
||||
return f.read(size)
|
||||
except OSError:
|
||||
_logger.info("_read_file reading %s", full_path, exc_info=True)
|
||||
return b''
|
||||
|
||||
|
|
@ -135,8 +151,9 @@ class IrAttachment(models.Model):
|
|||
fp.write(bin_value)
|
||||
# add fname to checklist, in case the transaction aborts
|
||||
self._mark_for_gc(fname)
|
||||
except IOError:
|
||||
_logger.info("_file_write writing %s", full_path, exc_info=True)
|
||||
except OSError:
|
||||
_logger.info("_file_write writing %s", full_path)
|
||||
raise
|
||||
return fname
|
||||
|
||||
@api.model
|
||||
|
|
@ -147,7 +164,7 @@ class IrAttachment(models.Model):
|
|||
def _mark_for_gc(self, fname):
|
||||
""" Add ``fname`` in a checklist for the filestore garbage collection. """
|
||||
assert isinstance(self, IrAttachment)
|
||||
fname = re.sub('[.]', '', fname).strip('/\\')
|
||||
fname = re.sub('[.:]', '', fname).strip('/\\')
|
||||
# we use a spooldir: add an empty file in the subdirectory 'checklist'
|
||||
full_path = os.path.join(self._full_path('checklist'), fname)
|
||||
if not os.path.exists(full_path):
|
||||
|
|
@ -171,7 +188,7 @@ class IrAttachment(models.Model):
|
|||
# the LOCK statement will wait until those concurrent transactions end.
|
||||
# But this transaction will not see the new attachements if it has done
|
||||
# other requests before the LOCK (like the method _storage() above).
|
||||
cr = self._cr
|
||||
cr = self.env.cr
|
||||
cr.commit()
|
||||
|
||||
# prevent all concurrent updates on ir_attachment while collecting,
|
||||
|
|
@ -201,7 +218,7 @@ class IrAttachment(models.Model):
|
|||
# Clean up the checklist. The checklist is split in chunks and files are garbage-collected
|
||||
# for each chunk.
|
||||
removed = 0
|
||||
for names in self.env.cr.split_for_in_conditions(checklist):
|
||||
for names in split_every(self.env.cr.IN_MAX, checklist):
|
||||
# determine which files to keep among the checklist
|
||||
self.env.cr.execute("SELECT store_fname FROM ir_attachment WHERE store_fname IN %s", [names])
|
||||
whitelist = set(row[0] for row in self.env.cr.fetchall())
|
||||
|
|
@ -214,7 +231,7 @@ class IrAttachment(models.Model):
|
|||
os.unlink(self._full_path(fname))
|
||||
_logger.debug("_file_gc unlinked %s", self._full_path(fname))
|
||||
removed += 1
|
||||
except (OSError, IOError):
|
||||
except OSError:
|
||||
_logger.info("_file_gc could not unlink %s", self._full_path(fname), exc_info=True)
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(filepath)
|
||||
|
|
@ -224,7 +241,7 @@ class IrAttachment(models.Model):
|
|||
@api.depends('store_fname', 'db_datas', 'file_size')
|
||||
@api.depends_context('bin_size')
|
||||
def _compute_datas(self):
|
||||
if self._context.get('bin_size'):
|
||||
if self.env.context.get('bin_size'):
|
||||
for attach in self:
|
||||
attach.datas = human_size(attach.file_size)
|
||||
return
|
||||
|
|
@ -247,17 +264,31 @@ class IrAttachment(models.Model):
|
|||
self._set_attachment_data(lambda attach: base64.b64decode(attach.datas or b''))
|
||||
|
||||
def _set_attachment_data(self, asbytes):
|
||||
old_fnames = []
|
||||
checksum_raw_map = {}
|
||||
|
||||
for attach in self:
|
||||
# compute the fields that depend on datas
|
||||
bin_data = asbytes(attach)
|
||||
vals = self._get_datas_related_values(bin_data, attach.mimetype)
|
||||
if bin_data:
|
||||
checksum_raw_map[vals['checksum']] = bin_data
|
||||
|
||||
# take current location in filestore to possibly garbage-collect it
|
||||
fname = attach.store_fname
|
||||
if attach.store_fname:
|
||||
old_fnames.append(attach.store_fname)
|
||||
|
||||
# write as superuser, as user probably does not have write access
|
||||
super(IrAttachment, attach.sudo()).write(vals)
|
||||
if fname:
|
||||
|
||||
if self._storage() != 'db':
|
||||
# before touching the filestore, flush to prevent the GC from
|
||||
# running until the end of the transaction
|
||||
self.flush_recordset(['checksum', 'store_fname'])
|
||||
for fname in old_fnames:
|
||||
self._file_delete(fname)
|
||||
for checksum, raw in checksum_raw_map.items():
|
||||
self._file_write(raw, checksum)
|
||||
|
||||
def _get_datas_related_values(self, data, mimetype):
|
||||
checksum = self._compute_checksum(data)
|
||||
|
|
@ -273,7 +304,7 @@ class IrAttachment(models.Model):
|
|||
'db_datas': data,
|
||||
}
|
||||
if data and self._storage() != 'db':
|
||||
values['store_fname'] = self._file_write(data, values['checksum'])
|
||||
values['store_fname'], _full_path = self._get_path(data, checksum)
|
||||
values['db_datas'] = False
|
||||
return values
|
||||
|
||||
|
|
@ -426,20 +457,14 @@ class IrAttachment(models.Model):
|
|||
mimetype = fields.Char('Mime Type', readonly=True)
|
||||
index_content = fields.Text('Indexed Content', readonly=True, prefetch=False)
|
||||
|
||||
def _auto_init(self):
|
||||
res = super(IrAttachment, self)._auto_init()
|
||||
tools.create_index(self._cr, 'ir_attachment_res_idx',
|
||||
self._table, ['res_model', 'res_id'])
|
||||
return res
|
||||
_res_idx = models.Index("(res_model, res_id)")
|
||||
|
||||
@api.constrains('type', 'url')
|
||||
def _check_serving_attachments(self):
|
||||
if self.env.is_admin():
|
||||
return
|
||||
for attachment in self:
|
||||
# restrict writing on attachments that could be served by the
|
||||
# ir.http's dispatch exception handling
|
||||
# XDO note: this should be done in check(write), constraints for access rights?
|
||||
# XDO note: if read on sudo, read twice, one for constraints, one for _inverse_datas as user
|
||||
if attachment.type == 'binary' and attachment.url:
|
||||
has_group = self.env.user.has_group
|
||||
|
|
@ -449,155 +474,230 @@ class IrAttachment(models.Model):
|
|||
@api.model
|
||||
def check(self, mode, values=None):
|
||||
""" Restricts the access to an ir.attachment, according to referred mode """
|
||||
if self.env.is_superuser():
|
||||
return True
|
||||
warnings.warn("Since 19.0, use check_access", DeprecationWarning, stacklevel=2)
|
||||
# Always require an internal user (aka, employee) to access to a attachment
|
||||
if not (self.env.is_admin() or self.env.user._is_internal()):
|
||||
raise AccessError(_("Sorry, you are not allowed to access this document."))
|
||||
self.check_access(mode)
|
||||
if values and any(self._inaccessible_comodel_records({values.get('res_model'): [values.get('res_id')]}, mode)):
|
||||
raise AccessError(_("Sorry, you are not allowed to access this document."))
|
||||
|
||||
def _check_access(self, operation):
|
||||
"""Check access for attachments.
|
||||
|
||||
Rules:
|
||||
- `public` is always accessible for reading.
|
||||
- If we have `res_model and res_id`, the attachment is accessible if the
|
||||
referenced model is accessible. Also, when `res_field != False` and
|
||||
the user is not an administrator, we check the access on the field.
|
||||
- If we don't have a referenced record, the attachment is accessible to
|
||||
the administrator and the creator of the attachment.
|
||||
"""
|
||||
res = super()._check_access(operation)
|
||||
remaining = self
|
||||
error_func = None
|
||||
forbidden_ids = OrderedSet()
|
||||
if res:
|
||||
forbidden, error_func = res
|
||||
if forbidden == self:
|
||||
return res
|
||||
remaining -= forbidden
|
||||
forbidden_ids.update(forbidden._ids)
|
||||
elif not self:
|
||||
return None
|
||||
|
||||
if operation in ('create', 'unlink'):
|
||||
# check write operation instead of unlinking and creating for
|
||||
# related models and field access
|
||||
operation = 'write'
|
||||
|
||||
# collect the records to check (by model)
|
||||
model_ids = defaultdict(set) # {model_name: set(ids)}
|
||||
if self:
|
||||
# DLE P173: `test_01_portal_attachment`
|
||||
self.env['ir.attachment'].flush_model(['res_model', 'res_id', 'create_uid', 'public', 'res_field'])
|
||||
self._cr.execute('SELECT res_model, res_id, create_uid, public, res_field FROM ir_attachment WHERE id IN %s', [tuple(self.ids)])
|
||||
for res_model, res_id, create_uid, public, res_field in self._cr.fetchall():
|
||||
if public and mode == 'read':
|
||||
continue
|
||||
if not self.env.is_system():
|
||||
if not res_id and create_uid != self.env.uid:
|
||||
raise AccessError(_("Sorry, you are not allowed to access this document."))
|
||||
if res_field:
|
||||
field = self.env[res_model]._fields[res_field]
|
||||
if not field.is_accessible(self.env):
|
||||
raise AccessError(_("Sorry, you are not allowed to access this document."))
|
||||
if not (res_model and res_id):
|
||||
continue
|
||||
model_ids[res_model].add(res_id)
|
||||
if values and values.get('res_model') and values.get('res_id'):
|
||||
model_ids[values['res_model']].add(values['res_id'])
|
||||
|
||||
# check access rights on the records
|
||||
for res_model, res_ids in model_ids.items():
|
||||
# ignore attachments that are not attached to a resource anymore
|
||||
# when checking access rights (resource was deleted but attachment
|
||||
# was not)
|
||||
if res_model not in self.env:
|
||||
att_model_ids = [] # [(att_id, (res_model, res_id))]
|
||||
# DLE P173: `test_01_portal_attachment`
|
||||
remaining = remaining.sudo()
|
||||
remaining.fetch(SECURITY_FIELDS) # fetch only these fields
|
||||
for attachment in remaining:
|
||||
if attachment.public and operation == 'read':
|
||||
continue
|
||||
if res_model == 'res.users' and len(res_ids) == 1 and self.env.uid == list(res_ids)[0]:
|
||||
att_id = attachment.id
|
||||
res_model, res_id = attachment.res_model, attachment.res_id
|
||||
if not self.env.is_system():
|
||||
if not res_id and attachment.create_uid.id != self.env.uid:
|
||||
forbidden_ids.add(att_id)
|
||||
continue
|
||||
if res_field := attachment.res_field:
|
||||
try:
|
||||
field = self.env[res_model]._fields[res_field]
|
||||
except KeyError:
|
||||
# field does not exist
|
||||
field = None
|
||||
if field is None or not self._has_field_access(field, operation):
|
||||
forbidden_ids.add(att_id)
|
||||
continue
|
||||
if res_model and res_id:
|
||||
model_ids[res_model].add(res_id)
|
||||
att_model_ids.append((att_id, (res_model, res_id)))
|
||||
forbidden_res_model_id = set(self._inaccessible_comodel_records(model_ids, operation))
|
||||
forbidden_ids.update(att_id for att_id, res in att_model_ids if res in forbidden_res_model_id)
|
||||
|
||||
if forbidden_ids:
|
||||
forbidden = self.browse(forbidden_ids)
|
||||
forbidden.invalidate_recordset(SECURITY_FIELDS) # avoid cache pollution
|
||||
if error_func is None:
|
||||
def error_func():
|
||||
return AccessError(self.env._(
|
||||
"Sorry, you are not allowed to access this document. "
|
||||
"Please contact your system administrator.\n\n"
|
||||
"(Operation: %(operation)s)\n\n"
|
||||
"Records: %(records)s, User: %(user)s",
|
||||
operation=operation,
|
||||
records=forbidden[:6],
|
||||
user=self.env.uid,
|
||||
))
|
||||
return forbidden, error_func
|
||||
return None
|
||||
|
||||
def _inaccessible_comodel_records(self, model_and_ids: dict[str, Collection[int]], operation: str):
|
||||
# check access rights on the records
|
||||
if self.env.su:
|
||||
return
|
||||
for res_model, res_ids in model_and_ids.items():
|
||||
res_ids = OrderedSet(filter(None, res_ids))
|
||||
if not res_model or not res_ids:
|
||||
# nothing to check
|
||||
continue
|
||||
# forbid access to attachments linked to removed models as we do not
|
||||
# know what persmissions should be checked
|
||||
if res_model not in self.env:
|
||||
for res_id in res_ids:
|
||||
yield res_model, res_id
|
||||
continue
|
||||
records = self.env[res_model].browse(res_ids)
|
||||
if res_model == 'res.users' and len(records) == 1 and self.env.uid == records.id:
|
||||
# by default a user cannot write on itself, despite the list of writeable fields
|
||||
# e.g. in the case of a user inserting an image into his image signature
|
||||
# we need to bypass this check which would needlessly throw us away
|
||||
continue
|
||||
records = self.env[res_model].browse(res_ids).exists()
|
||||
# For related models, check if we can write to the model, as unlinking
|
||||
# and creating attachments can be seen as an update to the model
|
||||
access_mode = 'write' if mode in ('create', 'unlink') else mode
|
||||
records.check_access(access_mode)
|
||||
|
||||
@api.model
|
||||
def _filter_attachment_access(self, attachment_ids):
|
||||
"""Filter the given attachment to return only the records the current user have access to.
|
||||
|
||||
:param attachment_ids: List of attachment ids we want to filter
|
||||
:return: <ir.attachment> the current user have access to
|
||||
"""
|
||||
ret_attachments = self.env['ir.attachment']
|
||||
attachments = self.browse(attachment_ids)
|
||||
if not attachments.has_access('read'):
|
||||
return ret_attachments
|
||||
|
||||
for attachment in attachments.sudo():
|
||||
# Use SUDO here to not raise an error during the prefetch
|
||||
# And then drop SUDO right to check if we can access it
|
||||
try:
|
||||
attachment.sudo(False).check('read')
|
||||
ret_attachments |= attachment
|
||||
except AccessError:
|
||||
continue
|
||||
return ret_attachments
|
||||
records = records._filtered_access(operation)
|
||||
except MissingError:
|
||||
records = records.exists()._filtered_access(operation)
|
||||
res_ids.difference_update(records._ids)
|
||||
for res_id in res_ids:
|
||||
yield res_model, res_id
|
||||
|
||||
@api.model
|
||||
def _search(self, domain, offset=0, limit=None, order=None):
|
||||
# add res_field=False in domain if not present; the arg[0] trick below
|
||||
# works for domain items and '&'/'|'/'!' operators too
|
||||
def _search(self, domain, offset=0, limit=None, order=None, *, active_test=True, bypass_access=False):
|
||||
assert not self._active_name, "active name not supported on ir.attachment"
|
||||
disable_binary_fields_attachments = False
|
||||
if not self.env.context.get('skip_res_field_check') and not any(arg[0] in ('id', 'res_field') for arg in domain):
|
||||
domain = Domain(domain)
|
||||
if (
|
||||
not self.env.context.get('skip_res_field_check')
|
||||
and not any(d.field_expr in ('id', 'res_field') for d in domain.iter_conditions())
|
||||
):
|
||||
disable_binary_fields_attachments = True
|
||||
domain = [('res_field', '=', False)] + domain
|
||||
domain &= Domain('res_field', '=', False)
|
||||
|
||||
if self.env.is_superuser():
|
||||
# rules do not apply for the superuser
|
||||
return super()._search(domain, offset, limit, order)
|
||||
domain = domain.optimize(self)
|
||||
if self.env.su or bypass_access or domain.is_false():
|
||||
return super()._search(domain, offset, limit, order, active_test=active_test, bypass_access=bypass_access)
|
||||
|
||||
# For attachments, the permissions of the document they are attached to
|
||||
# apply, so we must remove attachments for which the user cannot access
|
||||
# the linked document. For the sake of performance, fetch the fields to
|
||||
# determine those permissions within the same SQL query.
|
||||
fnames_to_read = ['id', 'res_model', 'res_id', 'res_field', 'public', 'create_uid']
|
||||
query = super()._search(domain, offset, limit, order)
|
||||
rows = self.env.execute_query(query.select(
|
||||
*[self._field_to_sql(self._table, fname) for fname in fnames_to_read],
|
||||
))
|
||||
# General access rules
|
||||
# - public == True are always accessible
|
||||
sec_domain = Domain('public', '=', True)
|
||||
# - res_id == False needs to be system user or creator
|
||||
res_ids = condition_values(self, 'res_id', domain)
|
||||
if not res_ids or False in res_ids:
|
||||
if self.env.is_system():
|
||||
sec_domain |= Domain('res_id', '=', False)
|
||||
else:
|
||||
sec_domain |= Domain('res_id', '=', False) & Domain('create_uid', '=', self.env.uid)
|
||||
|
||||
# determine permissions based on linked records
|
||||
all_ids = []
|
||||
allowed_ids = set()
|
||||
model_attachments = defaultdict(lambda: defaultdict(set)) # {res_model: {res_id: set(ids)}}
|
||||
for id_, res_model, res_id, res_field, public, create_uid in rows:
|
||||
all_ids.append(id_)
|
||||
if public:
|
||||
allowed_ids.add(id_)
|
||||
continue
|
||||
|
||||
if res_field and not self.env.is_system():
|
||||
field = self.env[res_model]._fields[res_field]
|
||||
if field.groups and not self.env.user.has_groups(field.groups):
|
||||
# Search by res_model and res_id, filter using permissions from res_model
|
||||
# - res_id != False needs then check access on the linked res_model record
|
||||
# - res_field != False needs to check field access on the res_model
|
||||
res_model_names = condition_values(self, 'res_model', domain)
|
||||
if 0 < len(res_model_names or ()) <= 5:
|
||||
env = self.with_context(active_test=False).env
|
||||
for res_model_name in res_model_names:
|
||||
comodel = env.get(res_model_name)
|
||||
if comodel is None:
|
||||
continue
|
||||
codomain = Domain('res_model', '=', comodel._name)
|
||||
comodel_res_ids = condition_values(self, 'res_id', domain.map_conditions(
|
||||
lambda cond: codomain & cond if cond.field_expr == 'res_model' else cond
|
||||
))
|
||||
query = comodel._search(Domain('id', 'in', comodel_res_ids) if comodel_res_ids else Domain.TRUE)
|
||||
if query.is_empty():
|
||||
continue
|
||||
if query.where_clause:
|
||||
codomain &= Domain('res_id', 'in', query)
|
||||
if not disable_binary_fields_attachments and not self.env.is_system():
|
||||
accessible_fields = [
|
||||
field.name
|
||||
for field in comodel._fields.values()
|
||||
if field.type == 'binary' or (field.relational and field.comodel_name == self._name)
|
||||
if comodel._has_field_access(field, 'read')
|
||||
]
|
||||
accessible_fields.append(False)
|
||||
codomain &= Domain('res_field', 'in', accessible_fields)
|
||||
sec_domain |= codomain
|
||||
|
||||
if not res_id and (self.env.is_system() or create_uid == self.env.uid):
|
||||
allowed_ids.add(id_)
|
||||
continue
|
||||
if not (res_field and disable_binary_fields_attachments) and res_model and res_id:
|
||||
model_attachments[res_model][res_id].add(id_)
|
||||
return super()._search(domain & sec_domain, offset, limit, order, active_test=active_test)
|
||||
|
||||
# check permissions on records model by model
|
||||
for res_model, targets in model_attachments.items():
|
||||
if res_model not in self.env:
|
||||
allowed_ids.update(id_ for ids in targets.values() for id_ in ids)
|
||||
continue
|
||||
if not self.env[res_model].has_access('read'):
|
||||
continue
|
||||
# filter ids according to what access rules permit
|
||||
ResModel = self.env[res_model].with_context(active_test=False)
|
||||
for res_id in ResModel.search([('id', 'in', list(targets))])._ids:
|
||||
allowed_ids.update(targets[res_id])
|
||||
|
||||
# filter out all_ids by keeping allowed_ids only
|
||||
result = [id_ for id_ in all_ids if id_ in allowed_ids]
|
||||
|
||||
# If the original search reached the limit, it is important the
|
||||
# filtered record set does so too. When a JS view receive a
|
||||
# record set whose length is below the limit, it thinks it
|
||||
# reached the last page. To avoid an infinite recursion due to the
|
||||
# permission checks the sub-call need to be aware of the number of
|
||||
# expected records to retrieve
|
||||
if len(all_ids) == limit and len(result) < self._context.get('need', limit):
|
||||
need = self._context.get('need', limit) - len(result)
|
||||
more_ids = self.with_context(need=need)._search(
|
||||
domain, offset + len(all_ids), limit, order,
|
||||
)
|
||||
result.extend(list(more_ids)[:limit - len(result)])
|
||||
|
||||
return self.browse(result)._as_query(order)
|
||||
# We do not have a small restriction on res_model. We still need to
|
||||
# support other queries such as: `('id', 'in' ...)`.
|
||||
# Restrict with domain and add all attachments linked to a model.
|
||||
domain &= sec_domain | Domain('res_model', '!=', False)
|
||||
domain = domain.optimize_full(self)
|
||||
ordered = bool(order)
|
||||
if limit is None:
|
||||
records = self.sudo().with_context(active_test=False).search_fetch(
|
||||
domain, SECURITY_FIELDS, order=order).sudo(False)
|
||||
return records._filtered_access('read')[offset:]._as_query(ordered)
|
||||
# Fetch by small batches
|
||||
sub_offset = 0
|
||||
limit += offset
|
||||
result = []
|
||||
if not ordered:
|
||||
# By default, order by model to batch access checks.
|
||||
order = 'res_model nulls first, id'
|
||||
while len(result) < limit:
|
||||
records = self.sudo().with_context(active_test=False).search_fetch(
|
||||
domain,
|
||||
SECURITY_FIELDS,
|
||||
offset=sub_offset,
|
||||
limit=PREFETCH_MAX,
|
||||
order=order,
|
||||
).sudo(False)
|
||||
result.extend(records._filtered_access('read')._ids)
|
||||
if len(records) < PREFETCH_MAX:
|
||||
# There are no more records
|
||||
break
|
||||
sub_offset += PREFETCH_MAX
|
||||
return self.browse(result[offset:limit])._as_query(ordered)
|
||||
|
||||
def write(self, vals):
|
||||
self.check('write', values=vals)
|
||||
self.check_access('write')
|
||||
if vals.get('res_model') or vals.get('res_id'):
|
||||
model_and_ids = defaultdict(OrderedSet)
|
||||
if 'res_model' in vals and 'res_id' in vals:
|
||||
model_and_ids[vals['res_model']].add(vals['res_id'])
|
||||
else:
|
||||
for record in self:
|
||||
model_and_ids[vals.get('res_model', record.res_model)].add(vals.get('res_id', record.res_id))
|
||||
if any(self._inaccessible_comodel_records(model_and_ids, 'write')):
|
||||
raise AccessError(_("Sorry, you are not allowed to access this document."))
|
||||
# remove computed field depending of datas
|
||||
for field in ('file_size', 'checksum', 'store_fname'):
|
||||
vals.pop(field, False)
|
||||
if 'mimetype' in vals or 'datas' in vals or 'raw' in vals:
|
||||
vals = self._check_contents(vals)
|
||||
return super(IrAttachment, self).write(vals)
|
||||
res = super().write(vals)
|
||||
if 'url' in vals or 'type' in vals:
|
||||
self._check_serving_attachments()
|
||||
return res
|
||||
|
||||
def copy_data(self, default=None):
|
||||
default = dict(default or {})
|
||||
|
|
@ -609,16 +709,12 @@ class IrAttachment(models.Model):
|
|||
return vals_list
|
||||
|
||||
def unlink(self):
|
||||
if not self:
|
||||
return True
|
||||
self.check('unlink')
|
||||
|
||||
# First delete in the database, *then* in the filesystem if the
|
||||
# database allowed it. Helps avoid errors when concurrent transactions
|
||||
# are deleting the same file, and some of the transactions are
|
||||
# rolled back by PostgreSQL (due to concurrent updates detection).
|
||||
to_delete = set(attach.store_fname for attach in self if attach.store_fname)
|
||||
res = super(IrAttachment, self).unlink()
|
||||
to_delete = OrderedSet(attach.store_fname for attach in self if attach.store_fname)
|
||||
res = super().unlink()
|
||||
for file_path in to_delete:
|
||||
self._file_delete(file_path)
|
||||
|
||||
|
|
@ -635,6 +731,7 @@ class IrAttachment(models.Model):
|
|||
in vals.items()
|
||||
if key not in ('file_size', 'checksum', 'store_fname')
|
||||
} for vals in vals_list]
|
||||
checksum_raw_map = {}
|
||||
|
||||
for values in vals_list:
|
||||
values = self._check_contents(values)
|
||||
|
|
@ -643,10 +740,11 @@ class IrAttachment(models.Model):
|
|||
if isinstance(raw, str):
|
||||
# b64decode handles str input but raw needs explicit encoding
|
||||
raw = raw.encode()
|
||||
values.update(self._get_datas_related_values(
|
||||
raw or base64.b64decode(datas or b''),
|
||||
values['mimetype']
|
||||
))
|
||||
elif not raw:
|
||||
raw = base64.b64decode(datas or b'')
|
||||
values.update(self._get_datas_related_values(raw, values['mimetype']))
|
||||
if raw:
|
||||
checksum_raw_map[values['checksum']] = raw
|
||||
|
||||
# 'check()' only uses res_model and res_id from values, and make an exists.
|
||||
# We can group the values by model, res_id to make only one query when
|
||||
|
|
@ -655,10 +753,17 @@ class IrAttachment(models.Model):
|
|||
record_tuple_set.add(record_tuple)
|
||||
|
||||
# don't use possible contextual recordset for check, see commit for details
|
||||
Attachments = self.browse()
|
||||
model_and_ids = defaultdict(set)
|
||||
for res_model, res_id in record_tuple_set:
|
||||
Attachments.check('create', values={'res_model':res_model, 'res_id':res_id})
|
||||
return super().create(vals_list)
|
||||
model_and_ids[res_model].add(res_id)
|
||||
if any(self._inaccessible_comodel_records(model_and_ids, 'write')):
|
||||
raise AccessError(_("Sorry, you are not allowed to access this document."))
|
||||
records = super().create(vals_list)
|
||||
if self._storage() != 'db':
|
||||
for checksum, raw in checksum_raw_map.items():
|
||||
self._file_write(raw, checksum)
|
||||
records._check_serving_attachments()
|
||||
return records
|
||||
|
||||
def _post_add_create(self, **kwargs):
|
||||
# TODO master: rename to _post_upload, better indicating its usage
|
||||
|
|
@ -675,6 +780,15 @@ class IrAttachment(models.Model):
|
|||
tokens.append(access_token)
|
||||
return tokens
|
||||
|
||||
def _get_raw_access_token(self):
|
||||
"""Return a scoped access token for the `raw` field. The token can be
|
||||
used with `ir_binary._find_record` to bypass access rights.
|
||||
|
||||
:rtype: str
|
||||
"""
|
||||
self.ensure_one()
|
||||
return limited_field_access_token(self, "raw", scope="binary")
|
||||
|
||||
@api.model
|
||||
def create_unique(self, values_list):
|
||||
ids = []
|
||||
|
|
@ -703,28 +817,6 @@ class IrAttachment(models.Model):
|
|||
def _generate_access_token(self):
|
||||
return str(uuid.uuid4())
|
||||
|
||||
def validate_access(self, access_token):
|
||||
self.ensure_one()
|
||||
record_sudo = self.sudo()
|
||||
|
||||
if access_token:
|
||||
tok = record_sudo.with_context(prefetch_fields=False).access_token
|
||||
valid_token = consteq(tok or '', access_token)
|
||||
if not valid_token:
|
||||
raise AccessError("Invalid access token")
|
||||
return record_sudo
|
||||
|
||||
if record_sudo.with_context(prefetch_fields=False).public:
|
||||
return record_sudo
|
||||
|
||||
if self.env.user._is_portal():
|
||||
# Check the read access on the record linked to the attachment
|
||||
# eg: Allow to download an attachment on a task from /my/tasks/task_id
|
||||
self.check('read')
|
||||
return record_sudo
|
||||
|
||||
return self
|
||||
|
||||
@api.model
|
||||
def action_get(self):
|
||||
return self.env['ir.actions.act_window']._for_xml_id('base.action_attachment')
|
||||
|
|
@ -741,7 +833,7 @@ class IrAttachment(models.Model):
|
|||
("url", "=like", "/web/assets/%"),
|
||||
('res_model', '=', 'ir.ui.view'),
|
||||
('res_id', '=', 0),
|
||||
('create_uid', '=', SUPERUSER_ID),
|
||||
('create_uid', '=', api.SUPERUSER_ID),
|
||||
]).unlink()
|
||||
self.env.registry.clear_cache('assets')
|
||||
|
||||
|
|
@ -836,3 +928,18 @@ class IrAttachment(models.Model):
|
|||
def _is_remote_source(self):
|
||||
self.ensure_one()
|
||||
return self.url and not self.file_size and self.url.startswith(('http://', 'https://', 'ftp://'))
|
||||
|
||||
def _can_return_content(self, field_name=None, access_token=None):
|
||||
attachment_sudo = self.sudo().with_context(prefetch_fields=False)
|
||||
if access_token:
|
||||
if not consteq(attachment_sudo.access_token or "", access_token):
|
||||
raise AccessError("Invalid access token") # pylint: disable=missing-gettext
|
||||
return True
|
||||
if attachment_sudo.public:
|
||||
return True
|
||||
if self.env.user._is_portal():
|
||||
# Check the read access on the record linked to the attachment
|
||||
# eg: Allow to download an attachment on a task from /my/tasks/task_id
|
||||
self.check_access('read')
|
||||
return True
|
||||
return super()._can_return_content(field_name, access_token)
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import collections
|
||||
import inspect
|
||||
import logging
|
||||
import warnings
|
||||
import traceback
|
||||
import random
|
||||
import time
|
||||
|
||||
from odoo import api, models
|
||||
from odoo.exceptions import AccessDenied
|
||||
from odoo.modules.registry import _CACHES_BY_KEY
|
||||
from odoo.tools import SQL
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -17,7 +19,7 @@ def is_autovacuum(func):
|
|||
return callable(func) and getattr(func, '_autovacuum', False)
|
||||
|
||||
|
||||
class AutoVacuum(models.AbstractModel):
|
||||
class IrAutovacuum(models.AbstractModel):
|
||||
""" Helper model to the ``@api.autovacuum`` method decorator. """
|
||||
_name = 'ir.autovacuum'
|
||||
_description = 'Automatic Vacuum'
|
||||
|
|
@ -27,16 +29,47 @@ class AutoVacuum(models.AbstractModel):
|
|||
Perform a complete database cleanup by safely calling every
|
||||
``@api.autovacuum`` decorated method.
|
||||
"""
|
||||
if not self.env.is_admin():
|
||||
if not self.env.is_admin() or not self.env.context.get('cron_id'):
|
||||
raise AccessDenied()
|
||||
|
||||
for model in self.env.values():
|
||||
cls = self.env.registry[model._name]
|
||||
for attr, func in inspect.getmembers(cls, is_autovacuum):
|
||||
_logger.debug('Calling %s.%s()', model, attr)
|
||||
try:
|
||||
func(model)
|
||||
self.env.cr.commit()
|
||||
except Exception:
|
||||
_logger.exception("Failed %s.%s()", model, attr)
|
||||
self.env.cr.rollback()
|
||||
all_methods = [
|
||||
(model, attr, func)
|
||||
for model in self.env.values()
|
||||
for attr, func in inspect.getmembers(model.__class__, is_autovacuum)
|
||||
]
|
||||
# shuffle methods at each run, prevents one blocking method from always
|
||||
# starving the following ones
|
||||
random.shuffle(all_methods)
|
||||
queue = collections.deque(all_methods)
|
||||
while queue and self.env['ir.cron']._commit_progress(remaining=len(queue)):
|
||||
model, attr, func = queue.pop()
|
||||
_logger.debug('Calling %s.%s()', model, attr)
|
||||
try:
|
||||
start_time = time.monotonic()
|
||||
result = func(model)
|
||||
self.env['ir.cron']._commit_progress(1)
|
||||
if isinstance(result, tuple) and len(result) == 2:
|
||||
func_done, func_remaining = result
|
||||
_logger.debug(
|
||||
'%s.%s vacuumed %r records, remaining %r',
|
||||
model, attr, func_done, func_remaining,
|
||||
)
|
||||
if func_remaining:
|
||||
queue.appendleft((model, attr, func))
|
||||
_logger.debug("%s.%s took %.2fs", model, attr, time.monotonic() - start_time)
|
||||
except Exception:
|
||||
_logger.exception("Failed %s.%s()", model, attr)
|
||||
self.env.cr.rollback()
|
||||
|
||||
@api.autovacuum
|
||||
def _gc_orm_signaling(self):
|
||||
for signal in ['registry', *_CACHES_BY_KEY]:
|
||||
table = f'orm_signaling_{signal}'
|
||||
# keep the last 10 entries for each signal, and all entries from the last
|
||||
# hour. This keeps the signaling tables small enough for performance, but
|
||||
# also gives a useful glimpse into the recent signaling history, including
|
||||
# the timestamps of the increments.
|
||||
self.env.cr.execute(SQL(
|
||||
"DELETE FROM %s WHERE id < (SELECT max(id)-9 FROM %s) AND date < NOW() - interval '1 hours'",
|
||||
SQL.identifier(table), SQL.identifier(table)
|
||||
))
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ from datetime import datetime
|
|||
from mimetypes import guess_extension
|
||||
|
||||
from odoo import models
|
||||
from odoo.exceptions import MissingError, UserError
|
||||
from odoo.exceptions import AccessError, MissingError, UserError
|
||||
from odoo.http import Stream, request
|
||||
from odoo.tools import file_open, replace_exceptions
|
||||
from odoo.tools.image import image_process, image_guess_size_from_field_name
|
||||
|
|
@ -36,6 +36,7 @@ class IrBinary(models.AbstractModel):
|
|||
:param Optional[id] res_id: id of the record
|
||||
:param Optional[str] access_token: access token to use instead
|
||||
of the access rights and access rules.
|
||||
:param Optional[str] field: image field name to check the access to
|
||||
:returns: single record
|
||||
:raises MissingError: when no record was found.
|
||||
"""
|
||||
|
|
@ -45,17 +46,12 @@ class IrBinary(models.AbstractModel):
|
|||
elif res_id is not None and res_model in self.env:
|
||||
record = self.env[res_model].browse(res_id).exists()
|
||||
if not record:
|
||||
raise MissingError(f"No record found for xmlid={xmlid}, res_model={res_model}, id={res_id}")
|
||||
if access_token and verify_limited_field_access_token(record, field, access_token):
|
||||
raise MissingError(f"No record found for xmlid={xmlid}, res_model={res_model}, id={res_id}") # pylint: disable=missing-gettext
|
||||
if access_token and verify_limited_field_access_token(record, field, access_token, scope="binary"):
|
||||
return record.sudo()
|
||||
record = self._find_record_check_access(record, access_token, field)
|
||||
return record
|
||||
|
||||
def _find_record_check_access(self, record, access_token, field):
|
||||
if record._name == 'ir.attachment':
|
||||
return record.validate_access(access_token)
|
||||
|
||||
record.check_access('read')
|
||||
if record._can_return_content(field, access_token):
|
||||
return record.sudo()
|
||||
record.check_access("read")
|
||||
return record
|
||||
|
||||
def _record_to_stream(self, record, field_name):
|
||||
|
|
@ -73,16 +69,17 @@ class IrBinary(models.AbstractModel):
|
|||
if record._name == 'ir.attachment' and field_name in ('raw', 'datas', 'db_datas'):
|
||||
return record._to_http_stream()
|
||||
|
||||
record.check_field_access_rights('read', [field_name])
|
||||
field = record._fields[field_name]
|
||||
record._check_field_access(field, 'read')
|
||||
|
||||
if record._fields[field_name].attachment:
|
||||
if field.attachment:
|
||||
field_attachment = self.env['ir.attachment'].sudo().search(
|
||||
domain=[('res_model', '=', record._name),
|
||||
('res_id', '=', record.id),
|
||||
('res_field', '=', field_name)],
|
||||
limit=1)
|
||||
if not field_attachment:
|
||||
raise MissingError("The related attachment does not exist.")
|
||||
raise MissingError(self.env._("The related attachment does not exist."))
|
||||
return field_attachment._to_http_stream()
|
||||
|
||||
return Stream.from_binary_field(record, field_name)
|
||||
|
|
@ -111,15 +108,15 @@ class IrBinary(models.AbstractModel):
|
|||
``application/octet-stream``.
|
||||
:rtype: odoo.http.Stream
|
||||
"""
|
||||
with replace_exceptions(ValueError, by=UserError(f'Expected singleton: {record}')):
|
||||
with replace_exceptions(ValueError, by=UserError(f'Expected singleton: {record}')): # pylint: disable=missing-gettext
|
||||
record.ensure_one()
|
||||
|
||||
try:
|
||||
field_def = record._fields[field_name]
|
||||
except KeyError:
|
||||
raise UserError(f"Record has no field {field_name!r}.")
|
||||
raise UserError(f"Record has no field {field_name!r}.") # pylint: disable=missing-gettext
|
||||
if field_def.type != 'binary':
|
||||
raise UserError(
|
||||
raise UserError( # pylint: disable=missing-gettext
|
||||
f"Field {field_def!r} is type {field_def.type!r} but "
|
||||
f"it is only possible to stream Binary or Image fields."
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
"""
|
||||
Store database-specific configuration parameters
|
||||
|
|
@ -7,7 +6,7 @@ Store database-specific configuration parameters
|
|||
import uuid
|
||||
import logging
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.tools import config, ormcache, mute_logger
|
||||
|
||||
|
|
@ -26,7 +25,7 @@ _default_parameters = {
|
|||
}
|
||||
|
||||
|
||||
class IrConfigParameter(models.Model):
|
||||
class IrConfig_Parameter(models.Model):
|
||||
"""Per-database storage of configuration key-value pairs."""
|
||||
_name = 'ir.config_parameter'
|
||||
_description = 'System Parameter'
|
||||
|
|
@ -37,9 +36,10 @@ class IrConfigParameter(models.Model):
|
|||
key = fields.Char(required=True)
|
||||
value = fields.Text(required=True)
|
||||
|
||||
_sql_constraints = [
|
||||
('key_uniq', 'unique (key)', 'Key must be unique.')
|
||||
]
|
||||
_key_uniq = models.Constraint(
|
||||
'unique (key)',
|
||||
"Key must be unique.",
|
||||
)
|
||||
|
||||
@mute_logger('odoo.addons.base.models.ir_config_parameter')
|
||||
def init(self, force=False):
|
||||
|
|
@ -69,7 +69,7 @@ class IrConfigParameter(models.Model):
|
|||
return self._get_param(key) or default
|
||||
|
||||
@api.model
|
||||
@ormcache('key')
|
||||
@ormcache('key', cache='stable')
|
||||
def _get_param(self, key):
|
||||
# we bypass the ORM because get_param() is used in some field's depends,
|
||||
# and must therefore work even when the ORM is not ready to work
|
||||
|
|
@ -104,22 +104,22 @@ class IrConfigParameter(models.Model):
|
|||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
self.env.registry.clear_cache()
|
||||
return super(IrConfigParameter, self).create(vals_list)
|
||||
self.env.registry.clear_cache('stable')
|
||||
return super().create(vals_list)
|
||||
|
||||
def write(self, vals):
|
||||
if 'key' in vals:
|
||||
illegal = _default_parameters.keys() & self.mapped('key')
|
||||
if illegal:
|
||||
raise ValidationError(_("You cannot rename config parameters with keys %s", ', '.join(illegal)))
|
||||
self.env.registry.clear_cache()
|
||||
return super(IrConfigParameter, self).write(vals)
|
||||
raise ValidationError(self.env._("You cannot rename config parameters with keys %s", ', '.join(illegal)))
|
||||
self.env.registry.clear_cache('stable')
|
||||
return super().write(vals)
|
||||
|
||||
def unlink(self):
|
||||
self.env.registry.clear_cache()
|
||||
return super(IrConfigParameter, self).unlink()
|
||||
self.env.registry.clear_cache('stable')
|
||||
return super().unlink()
|
||||
|
||||
@api.ondelete(at_uninstall=False)
|
||||
def unlink_default_parameters(self):
|
||||
for record in self.filtered(lambda p: p.key in _default_parameters.keys()):
|
||||
raise ValidationError(_("You cannot delete the %s record.", record.key))
|
||||
raise ValidationError(self.env._("You cannot delete the %s record.", record.key))
|
||||
|
|
|
|||
|
|
@ -1,25 +1,32 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
import os
|
||||
import psycopg2
|
||||
import psycopg2.errors
|
||||
import pytz
|
||||
from datetime import datetime, timedelta
|
||||
import typing
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
import odoo
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
from odoo import api, fields, models, sql_db
|
||||
from odoo.exceptions import LockError, UserError
|
||||
from odoo.modules import Manifest
|
||||
from odoo.modules.registry import Registry
|
||||
from odoo.tools import SQL
|
||||
from odoo.tools.constants import GC_UNLINK_LIMIT
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
from odoo.sql_db import BaseCursor
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
BASE_VERSION = odoo.modules.get_manifest('base')['version']
|
||||
BASE_VERSION = Manifest.for_addon('base')['version']
|
||||
MAX_FAIL_TIME = timedelta(hours=5) # chosen with a fair roll of the dice
|
||||
MAX_BATCH_PER_CRON_JOB = 10
|
||||
MIN_RUNS_PER_JOB = 10
|
||||
MIN_TIME_PER_JOB = 10 # seconds
|
||||
CONSECUTIVE_TIMEOUT_FOR_FAILURE = 3
|
||||
MIN_FAILURE_COUNT_BEFORE_DEACTIVATION = 5
|
||||
MIN_DELTA_BEFORE_DEACTIVATION = timedelta(days=7)
|
||||
|
|
@ -32,6 +39,7 @@ ODOO_NOTIFY_FUNCTION = os.getenv('ODOO_NOTIFY_FUNCTION', 'pg_notify')
|
|||
class BadVersion(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class BadModuleState(Exception):
|
||||
pass
|
||||
|
||||
|
|
@ -39,7 +47,7 @@ class BadModuleState(Exception):
|
|||
_intervalTypes = {
|
||||
'days': lambda interval: relativedelta(days=interval),
|
||||
'hours': lambda interval: relativedelta(hours=interval),
|
||||
'weeks': lambda interval: relativedelta(days=7*interval),
|
||||
'weeks': lambda interval: relativedelta(days=7 * interval),
|
||||
'months': lambda interval: relativedelta(months=interval),
|
||||
'minutes': lambda interval: relativedelta(minutes=interval),
|
||||
}
|
||||
|
|
@ -51,7 +59,7 @@ class CompletionStatus: # inherit from enum.StrEnum in 3.11
|
|||
FAILED = 'failed'
|
||||
|
||||
|
||||
class ir_cron(models.Model):
|
||||
class IrCron(models.Model):
|
||||
""" Model describing cron jobs (also called actions or tasks).
|
||||
"""
|
||||
|
||||
|
|
@ -59,19 +67,20 @@ class ir_cron(models.Model):
|
|||
# that would cause database wake-up even if the database has not been
|
||||
# loaded yet or was already unloaded (e.g. 'force_db_wakeup' or something)
|
||||
# See also odoo.cron
|
||||
|
||||
_name = "ir.cron"
|
||||
_order = 'cron_name'
|
||||
_name = 'ir.cron'
|
||||
_order = 'cron_name, id'
|
||||
_description = 'Scheduled Actions'
|
||||
_allow_sudo_commands = False
|
||||
|
||||
_inherits = {'ir.actions.server': 'ir_actions_server_id'}
|
||||
|
||||
ir_actions_server_id = fields.Many2one(
|
||||
'ir.actions.server', 'Server action',
|
||||
'ir.actions.server', 'Server action', index=True,
|
||||
delegate=True, ondelete='restrict', required=True)
|
||||
cron_name = fields.Char('Name', compute='_compute_cron_name', store=True)
|
||||
user_id = fields.Many2one('res.users', string='Scheduler User', default=lambda self: self.env.user, required=True)
|
||||
active = fields.Boolean(default=True)
|
||||
interval_number = fields.Integer(default=1, aggregator=None, help="Repeat every x.", required=True)
|
||||
interval_number = fields.Integer(default=1, help="Repeat every x.", required=True, aggregator='avg')
|
||||
interval_type = fields.Selection([('minutes', 'Minutes'),
|
||||
('hours', 'Hours'),
|
||||
('days', 'Days'),
|
||||
|
|
@ -83,13 +92,10 @@ class ir_cron(models.Model):
|
|||
failure_count = fields.Integer(default=0, help="The number of consecutive failures of this job. It is automatically reset on success.")
|
||||
first_failure_date = fields.Datetime(string='First Failure Date', help="The first time the cron failed. It is automatically reset on success.")
|
||||
|
||||
_sql_constraints = [
|
||||
(
|
||||
'check_strictly_positive_interval',
|
||||
'CHECK(interval_number > 0)',
|
||||
'The interval number must be a strictly positive number.'
|
||||
),
|
||||
]
|
||||
_check_strictly_positive_interval = models.Constraint(
|
||||
'CHECK(interval_number > 0)',
|
||||
"The interval number must be a strictly positive number.",
|
||||
)
|
||||
|
||||
@api.depends('ir_actions_server_id.name')
|
||||
def _compute_cron_name(self):
|
||||
|
|
@ -101,57 +107,51 @@ class ir_cron(models.Model):
|
|||
for vals in vals_list:
|
||||
vals['usage'] = 'ir_cron'
|
||||
if os.getenv('ODOO_NOTIFY_CRON_CHANGES'):
|
||||
self._cr.postcommit.add(self._notifydb)
|
||||
self.env.cr.postcommit.add(self._notifydb)
|
||||
return super().create(vals_list)
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields_list):
|
||||
def default_get(self, fields):
|
||||
# only 'code' state is supported for cron job so set it as default
|
||||
if not self._context.get('default_state'):
|
||||
self = self.with_context(default_state='code')
|
||||
return super(ir_cron, self).default_get(fields_list)
|
||||
model = self
|
||||
if not model.env.context.get('default_state'):
|
||||
model = model.with_context(default_state='code')
|
||||
return super(IrCron, model).default_get(fields)
|
||||
|
||||
def method_direct_trigger(self):
|
||||
"""Run the CRON job in the current (HTTP) thread.
|
||||
|
||||
The job is still ran as it would be by the scheduler: a new cursor
|
||||
is used for the execution of the job.
|
||||
|
||||
:raises UserError: when the job is already running
|
||||
"""
|
||||
self.ensure_one()
|
||||
self.browse().check_access('write')
|
||||
self._try_lock()
|
||||
_logger.info('Job %r (%s) started manually', self.name, self.id)
|
||||
self, _ = self.with_user(self.user_id).with_context({'lastcall': self.lastcall})._add_progress() # noqa: PLW0642
|
||||
self.ir_actions_server_id.run()
|
||||
self.lastcall = fields.Datetime.now()
|
||||
self.env.flush_all()
|
||||
_logger.info('Job %r (%s) done', self.name, self.id)
|
||||
# cron will be run in a separate transaction, flush before and
|
||||
# invalidate because data will be changed by that transaction
|
||||
self.env.invalidate_all(flush=True)
|
||||
cron_cr = self.env.cr
|
||||
job = self._acquire_one_job(cron_cr, self.id, include_not_ready=True)
|
||||
if not job:
|
||||
raise UserError(self.env._("Job '%s' already executing", self.name))
|
||||
self._process_job(cron_cr, job)
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def _process_jobs(cls, db_name):
|
||||
@staticmethod
|
||||
def _process_jobs(db_name: str) -> None:
|
||||
""" Execute every job ready to be run on this database. """
|
||||
try:
|
||||
db = odoo.sql_db.db_connect(db_name)
|
||||
db = sql_db.db_connect(db_name)
|
||||
threading.current_thread().dbname = db_name
|
||||
with db.cursor() as cron_cr:
|
||||
cls = IrCron
|
||||
cls._check_version(cron_cr)
|
||||
jobs = cls._get_all_ready_jobs(cron_cr)
|
||||
if not jobs:
|
||||
return
|
||||
cls._check_modules_state(cron_cr, jobs)
|
||||
|
||||
for job_id in (job['id'] for job in jobs):
|
||||
try:
|
||||
job = cls._acquire_one_job(cron_cr, job_id)
|
||||
except psycopg2.extensions.TransactionRollbackError:
|
||||
cron_cr.rollback()
|
||||
_logger.debug("job %s has been processed by another worker, skip", job_id)
|
||||
continue
|
||||
if not job:
|
||||
_logger.debug("another worker is processing job %s, skip", job_id)
|
||||
continue
|
||||
_logger.debug("job %s acquired", job_id)
|
||||
# take into account overridings of _process_job() on that database
|
||||
registry = Registry(db_name).check_signaling()
|
||||
registry[cls._name]._process_job(db, cron_cr, job)
|
||||
_logger.debug("job %s updated and released", job_id)
|
||||
|
||||
cls._process_jobs_loop(cron_cr, job_ids=[job['id'] for job in jobs])
|
||||
except BadVersion:
|
||||
_logger.warning('Skipping database %s as its base version is not %s.', db_name, BASE_VERSION)
|
||||
except BadModuleState:
|
||||
|
|
@ -159,7 +159,7 @@ class ir_cron(models.Model):
|
|||
except psycopg2.errors.UndefinedTable:
|
||||
# The table ir_cron does not exist; this is probably not an OpenERP database.
|
||||
_logger.warning('Tried to poll an undefined table on database %s.', db_name)
|
||||
except psycopg2.ProgrammingError as e:
|
||||
except psycopg2.ProgrammingError:
|
||||
raise
|
||||
except Exception:
|
||||
_logger.warning('Exception in cron:', exc_info=True)
|
||||
|
|
@ -167,8 +167,33 @@ class ir_cron(models.Model):
|
|||
if hasattr(threading.current_thread(), 'dbname'):
|
||||
del threading.current_thread().dbname
|
||||
|
||||
@classmethod
|
||||
def _check_version(cls, cron_cr):
|
||||
@staticmethod
|
||||
def _process_jobs_loop(cron_cr: BaseCursor, *, job_ids: Iterable[int] = ()):
|
||||
""" Process ready jobs to run on this database.
|
||||
|
||||
The `cron_cr` is used to lock the currently processed job and relased
|
||||
by committing after each job.
|
||||
"""
|
||||
db_name = cron_cr.dbname
|
||||
for job_id in job_ids:
|
||||
try:
|
||||
job = IrCron._acquire_one_job(cron_cr, job_id)
|
||||
except psycopg2.extensions.TransactionRollbackError:
|
||||
cron_cr.rollback()
|
||||
_logger.debug("job %s has been processed by another worker, skip", job_id)
|
||||
continue
|
||||
if not job:
|
||||
_logger.debug("job %s is being processed by another worker, skip", job_id)
|
||||
continue
|
||||
_logger.debug("job %s acquired", job_id)
|
||||
# take into account overridings of _process_job() on that database
|
||||
registry = Registry(db_name)
|
||||
registry[IrCron._name]._process_job(cron_cr, job)
|
||||
cron_cr.commit()
|
||||
_logger.debug("job %s updated and released", job_id)
|
||||
|
||||
@staticmethod
|
||||
def _check_version(cron_cr):
|
||||
""" Ensure the code version matches the database version """
|
||||
cron_cr.execute("""
|
||||
SELECT latest_version
|
||||
|
|
@ -181,8 +206,8 @@ class ir_cron(models.Model):
|
|||
if version != BASE_VERSION:
|
||||
raise BadVersion()
|
||||
|
||||
@classmethod
|
||||
def _check_modules_state(cls, cr, jobs):
|
||||
@staticmethod
|
||||
def _check_modules_state(cr, jobs):
|
||||
""" Ensure no module is installing or upgrading """
|
||||
cr.execute("""
|
||||
SELECT COUNT(*)
|
||||
|
|
@ -196,10 +221,10 @@ class ir_cron(models.Model):
|
|||
if not jobs:
|
||||
raise BadModuleState()
|
||||
|
||||
oldest = min([
|
||||
fields.Datetime.from_string(job['nextcall'])
|
||||
for job in jobs
|
||||
])
|
||||
# use the max(job['nextcall'], job['write_date']) to avoid the cron
|
||||
# reset_module_state for an ongoing module installation process
|
||||
# right after installing a module with an old 'nextcall' cron in data
|
||||
oldest = min(max(job['nextcall'], job['write_date'] or job['nextcall']) for job in jobs)
|
||||
if datetime.now() - oldest < MAX_FAIL_TIME:
|
||||
raise BadModuleState()
|
||||
|
||||
|
|
@ -207,28 +232,35 @@ class ir_cron(models.Model):
|
|||
# per minute for 5h) in which case we assume that the crons are stuck
|
||||
# because the db has zombie states and we force a call to
|
||||
# reset_module_states.
|
||||
odoo.modules.reset_modules_state(cr.dbname)
|
||||
from odoo.modules.loading import reset_modules_state # noqa: PLC0415
|
||||
reset_modules_state(cr.dbname)
|
||||
|
||||
@classmethod
|
||||
def _get_all_ready_jobs(cls, cr):
|
||||
""" Return a list of all jobs that are ready to be executed """
|
||||
cr.execute("""
|
||||
SELECT *
|
||||
FROM ir_cron
|
||||
WHERE active = true
|
||||
AND (nextcall <= (now() at time zone 'UTC')
|
||||
OR id in (
|
||||
@staticmethod
|
||||
def _get_ready_sql_condition(cr: BaseCursor) -> SQL:
|
||||
return SQL("""
|
||||
active IS TRUE
|
||||
AND (nextcall <= %(now)s
|
||||
OR id IN (
|
||||
SELECT cron_id
|
||||
FROM ir_cron_trigger
|
||||
WHERE call_at <= (now() at time zone 'UTC')
|
||||
WHERE call_at <= %(now)s
|
||||
)
|
||||
)
|
||||
)
|
||||
""", now=cr.now())
|
||||
|
||||
@staticmethod
|
||||
def _get_all_ready_jobs(cr: BaseCursor) -> list[dict]:
|
||||
""" Return a list of all jobs that are ready to be executed """
|
||||
cr.execute(SQL("""
|
||||
SELECT *
|
||||
FROM ir_cron
|
||||
WHERE %s
|
||||
ORDER BY failure_count, priority, id
|
||||
""")
|
||||
""", IrCron._get_ready_sql_condition(cr)))
|
||||
return cr.dictfetchall()
|
||||
|
||||
@classmethod
|
||||
def _acquire_one_job(cls, cr, job_id):
|
||||
@staticmethod
|
||||
def _acquire_one_job(cr: BaseCursor, job_id: int, *, include_not_ready: bool = False) -> dict | None:
|
||||
"""
|
||||
Acquire for update the job with id ``job_id``.
|
||||
|
||||
|
|
@ -270,32 +302,25 @@ class ir_cron(models.Model):
|
|||
#
|
||||
# Learn more: https://www.postgresql.org/docs/current/explicit-locking.html#LOCKING-ROWS
|
||||
|
||||
query = """
|
||||
where_clause = SQL("id = %s", job_id)
|
||||
if not include_not_ready:
|
||||
where_clause = SQL("%s AND %s", where_clause, IrCron._get_ready_sql_condition(cr))
|
||||
query = SQL("""
|
||||
WITH last_cron_progress AS (
|
||||
SELECT id as progress_id, cron_id, timed_out_counter, done, remaining
|
||||
FROM ir_cron_progress
|
||||
WHERE cron_id = %s
|
||||
WHERE cron_id = %(cron_id)s
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
)
|
||||
SELECT *
|
||||
FROM ir_cron
|
||||
LEFT JOIN last_cron_progress lcp ON lcp.cron_id = ir_cron.id
|
||||
WHERE ir_cron.active = true
|
||||
AND (nextcall <= (now() at time zone 'UTC')
|
||||
OR EXISTS (
|
||||
SELECT cron_id
|
||||
FROM ir_cron_trigger
|
||||
WHERE call_at <= (now() at time zone 'UTC')
|
||||
AND cron_id = ir_cron.id
|
||||
)
|
||||
)
|
||||
AND id = %s
|
||||
ORDER BY priority
|
||||
WHERE %(where)s
|
||||
FOR NO KEY UPDATE SKIP LOCKED
|
||||
"""
|
||||
""", cron_id=job_id, where=where_clause)
|
||||
try:
|
||||
cr.execute(query, [job_id, job_id], log_exceptions=False)
|
||||
cr.execute(query, log_exceptions=False)
|
||||
except psycopg2.extensions.TransactionRollbackError:
|
||||
# A serialization error can occur when another cron worker
|
||||
# commits the new `nextcall` value of a cron it just ran and
|
||||
|
|
@ -326,7 +351,7 @@ class ir_cron(models.Model):
|
|||
_logger.warning(message)
|
||||
|
||||
@classmethod
|
||||
def _process_job(cls, db, cron_cr, job):
|
||||
def _process_job(cls, cron_cr: BaseCursor, job) -> None:
|
||||
"""
|
||||
Execute the cron's server action in a dedicated transaction.
|
||||
|
||||
|
|
@ -335,10 +360,8 @@ class ir_cron(models.Model):
|
|||
``'failed'``.
|
||||
|
||||
The server action can use the progress API via the method
|
||||
:meth:`_notify_progress` to report processing progress, i.e. how
|
||||
many records are done and how many records are remaining to
|
||||
process.
|
||||
|
||||
:meth:`_commit_progress` to report how many records are done
|
||||
in each batch.
|
||||
Those progress notifications are used to determine the job's
|
||||
``CompletionStatus`` and to determine the next time the cron
|
||||
will be executed:
|
||||
|
|
@ -358,6 +381,7 @@ class ir_cron(models.Model):
|
|||
env = api.Environment(cron_cr, job['user_id'], {})
|
||||
ir_cron = env[cls._name]
|
||||
|
||||
ir_cron._clear_schedule(job)
|
||||
failed_by_timeout = (
|
||||
job['timed_out_counter'] >= CONSECUTIVE_TIMEOUT_FOR_FAILURE
|
||||
and not job['done']
|
||||
|
|
@ -383,12 +407,10 @@ class ir_cron(models.Model):
|
|||
if os.getenv('ODOO_NOTIFY_CRON_CHANGES'):
|
||||
cron_cr.postcommit.add(ir_cron._notifydb) # See: `_notifydb`
|
||||
else:
|
||||
raise RuntimeError("unreachable")
|
||||
|
||||
cron_cr.commit()
|
||||
raise RuntimeError(f"unreachable {status=}")
|
||||
|
||||
@classmethod
|
||||
def _run_job(cls, job):
|
||||
def _run_job(cls, job) -> CompletionStatus:
|
||||
"""
|
||||
Execute the job's server action multiple times until it
|
||||
completes. The completion status is returned.
|
||||
|
|
@ -411,20 +433,33 @@ class ir_cron(models.Model):
|
|||
timed_out_counter = job['timed_out_counter']
|
||||
|
||||
with cls.pool.cursor() as job_cr:
|
||||
start_time = time.monotonic()
|
||||
env = api.Environment(job_cr, job['user_id'], {
|
||||
'lastcall': job['lastcall'],
|
||||
'cron_id': job['id'],
|
||||
'cron_end_time': start_time + MIN_TIME_PER_JOB,
|
||||
})
|
||||
cron = env[cls._name].browse(job['id'])
|
||||
|
||||
status = None
|
||||
for i in range(MAX_BATCH_PER_CRON_JOB):
|
||||
loop_count = 0
|
||||
_logger.info('Job %r (%s) starting', job['cron_name'], job['id'])
|
||||
|
||||
# stop after MIN_RUNS_PER_JOB runs and MIN_TIME_PER_JOB seconds, or
|
||||
# upon full completion or failure
|
||||
while (
|
||||
loop_count < MIN_RUNS_PER_JOB
|
||||
or time.monotonic() < env.context['cron_end_time']
|
||||
):
|
||||
cron, progress = cron._add_progress(timed_out_counter=timed_out_counter)
|
||||
job_cr.commit()
|
||||
|
||||
try:
|
||||
# signaling check and commit is done inside `_callback`
|
||||
cron._callback(job['cron_name'], job['ir_actions_server_id'])
|
||||
except Exception: # noqa: BLE001
|
||||
_logger.exception('Job %r (%s) server action #%s failed',
|
||||
job['cron_name'], job['id'], job['ir_actions_server_id'])
|
||||
if progress.done and progress.remaining:
|
||||
# we do not consider it a failure if some progress has
|
||||
# been committed
|
||||
|
|
@ -433,28 +468,38 @@ class ir_cron(models.Model):
|
|||
status = CompletionStatus.FAILED
|
||||
else:
|
||||
if not progress.remaining:
|
||||
status = CompletionStatus.FULLY_DONE
|
||||
elif not progress.done:
|
||||
# assume the server action doesn't use the progress API
|
||||
# and that there is nothing left to process
|
||||
status = CompletionStatus.FULLY_DONE
|
||||
else:
|
||||
status = CompletionStatus.PARTIALLY_DONE
|
||||
if not progress.done:
|
||||
break
|
||||
|
||||
if status == CompletionStatus.FULLY_DONE and progress.deactivate:
|
||||
job['active'] = False
|
||||
finally:
|
||||
done, remaining = progress.done, progress.remaining
|
||||
loop_count += 1
|
||||
progress.timed_out_counter = 0
|
||||
timed_out_counter = 0
|
||||
job_cr.commit()
|
||||
_logger.info('Job %r (%s) processed %s records, %s records remaining',
|
||||
job['cron_name'], job['id'], progress.done, progress.remaining)
|
||||
job_cr.commit() # ensure we have no leftovers
|
||||
|
||||
_logger.debug('Job %r (%s) processed %s records, %s records remaining',
|
||||
job['cron_name'], job['id'], done, remaining)
|
||||
|
||||
if status in (CompletionStatus.FULLY_DONE, CompletionStatus.FAILED):
|
||||
break
|
||||
|
||||
_logger.info(
|
||||
'Job %r (%s) %s (#loop %s; done %s; remaining %s; duration %.2fs)',
|
||||
job['cron_name'], job['id'], status,
|
||||
loop_count, done, remaining, time.monotonic() - start_time)
|
||||
|
||||
return status
|
||||
|
||||
def _update_failure_count(self, job, status):
|
||||
@api.model
|
||||
def _update_failure_count(self, job: dict, status: CompletionStatus) -> None:
|
||||
"""
|
||||
Update cron ``failure_count`` and ``first_failure_date`` given
|
||||
the job's completion status. Deactivate the cron when BOTH the
|
||||
|
|
@ -469,26 +514,25 @@ class ir_cron(models.Model):
|
|||
reached, ``active`` is set to ``False`` and both values are
|
||||
reset.
|
||||
"""
|
||||
now = fields.Datetime.context_timestamp(self, datetime.utcnow())
|
||||
|
||||
if status == CompletionStatus.FAILED:
|
||||
now = self.env.cr.now().replace(microsecond=0)
|
||||
failure_count = job['failure_count'] + 1
|
||||
first_failure_date = job['first_failure_date'] or now
|
||||
active = job['active']
|
||||
if (
|
||||
failure_count >= MIN_FAILURE_COUNT_BEFORE_DEACTIVATION
|
||||
and fields.Datetime.context_timestamp(self, first_failure_date) + MIN_DELTA_BEFORE_DEACTIVATION < now
|
||||
and first_failure_date + MIN_DELTA_BEFORE_DEACTIVATION < now
|
||||
):
|
||||
failure_count = 0
|
||||
first_failure_date = None
|
||||
active = False
|
||||
self._notify_admin(_(
|
||||
self._notify_admin(self.env._(
|
||||
"Cron job %(name)s (%(id)s) has been deactivated after failing %(count)s times. "
|
||||
"More information can be found in the server logs around %(time)s.",
|
||||
name=repr(job['cron_name']),
|
||||
id=job['id'],
|
||||
count=MIN_FAILURE_COUNT_BEFORE_DEACTIVATION,
|
||||
time=datetime.replace(datetime.utcnow(), microsecond=0),
|
||||
time=now,
|
||||
))
|
||||
else:
|
||||
failure_count = 0
|
||||
|
|
@ -508,44 +552,52 @@ class ir_cron(models.Model):
|
|||
job['id'],
|
||||
])
|
||||
|
||||
def _reschedule_later(self, job):
|
||||
@api.model
|
||||
def _clear_schedule(self, job):
|
||||
"""Remove triggers for the given job."""
|
||||
now = self.env.cr.now().replace(microsecond=0)
|
||||
self.env.cr.execute("""
|
||||
DELETE FROM ir_cron_trigger
|
||||
WHERE cron_id = %s
|
||||
AND call_at <= %s
|
||||
""", [job['id'], now])
|
||||
|
||||
@api.model
|
||||
def _reschedule_later(self, job: dict) -> None:
|
||||
"""
|
||||
Reschedule the job to be executed later, after its regular
|
||||
interval or upon a trigger.
|
||||
"""
|
||||
# Use the user's timezone to compare and compute datetimes, otherwise unexpected results may appear.
|
||||
# For instance, adding 1 month in UTC to July 1st at midnight in GMT+2 gives July 30 instead of August 1st!
|
||||
now = fields.Datetime.context_timestamp(self, datetime.utcnow())
|
||||
nextcall = fields.Datetime.context_timestamp(self, job['nextcall'])
|
||||
now = self.env.cr.now().replace(microsecond=0)
|
||||
nextcall = job['nextcall']
|
||||
# Use the timezone of the user when adding the interval. When adding a
|
||||
# day or more, the user may want to keep the same hour each day.
|
||||
# The interval won't be fixed, but the hour will stay the same,
|
||||
# even when changing DST.
|
||||
interval = _intervalTypes[job['interval_type']](job['interval_number'])
|
||||
while nextcall <= now:
|
||||
nextcall = fields.Datetime.context_timestamp(self, nextcall)
|
||||
nextcall += interval
|
||||
nextcall = nextcall.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
_logger.info('Job %r (%s) completed', job['cron_name'], job['id'])
|
||||
self.env.cr.execute("""
|
||||
UPDATE ir_cron
|
||||
SET nextcall = %s,
|
||||
lastcall = %s
|
||||
WHERE id = %s
|
||||
""", [
|
||||
fields.Datetime.to_string(nextcall.astimezone(pytz.UTC)),
|
||||
fields.Datetime.to_string(now.astimezone(pytz.UTC)),
|
||||
job['id'],
|
||||
])
|
||||
""", [nextcall, now, job['id']])
|
||||
|
||||
self.env.cr.execute("""
|
||||
DELETE FROM ir_cron_trigger
|
||||
WHERE cron_id = %s
|
||||
AND call_at < (now() at time zone 'UTC')
|
||||
""", [job['id']])
|
||||
|
||||
def _reschedule_asap(self, job):
|
||||
@api.model
|
||||
def _reschedule_asap(self, job: dict) -> None:
|
||||
"""
|
||||
Reschedule the job to be executed ASAP, after the other cron
|
||||
jobs had a chance to run.
|
||||
"""
|
||||
# leave the existing nextcall and triggers, this leave the job "ready"
|
||||
pass
|
||||
now = self.env.cr.now().replace(microsecond=0)
|
||||
self.env.cr.execute("""
|
||||
INSERT INTO ir_cron_trigger(call_at, cron_id)
|
||||
VALUES (%s, %s)
|
||||
""", [now, job['id']])
|
||||
|
||||
def _callback(self, cron_name, server_action_id):
|
||||
""" Run the method associated to a given job. It takes care of logging
|
||||
|
|
@ -555,81 +607,47 @@ class ir_cron(models.Model):
|
|||
try:
|
||||
if self.pool != self.pool.check_signaling():
|
||||
# the registry has changed, reload self in the new registry
|
||||
self.env.reset()
|
||||
self = self.env()[self._name]
|
||||
self.env.transaction.reset()
|
||||
|
||||
_logger.debug(
|
||||
"cron.object.execute(%r, %d, '*', %r, %d)",
|
||||
self.env.cr.dbname,
|
||||
self._uid,
|
||||
self.env.uid,
|
||||
cron_name,
|
||||
server_action_id,
|
||||
)
|
||||
_logger.info('Job %r (%s) starting', cron_name, self.id)
|
||||
start_time = time.time()
|
||||
self.env['ir.actions.server'].browse(server_action_id).run()
|
||||
self.env.flush_all()
|
||||
end_time = time.time()
|
||||
_logger.info('Job %r (%s) done in %.3fs', cron_name, self.id, end_time - start_time)
|
||||
if start_time and _logger.isEnabledFor(logging.DEBUG):
|
||||
_logger.debug('Job %r (%s) server action #%s with uid %s executed in %.3fs',
|
||||
cron_name, self.id, server_action_id, self.env.uid, end_time - start_time)
|
||||
self.pool.signal_changes()
|
||||
self.env.cr.commit()
|
||||
except Exception:
|
||||
self.pool.reset_changes()
|
||||
_logger.exception('Job %r (%s) server action #%s failed', cron_name, self.id, server_action_id)
|
||||
self.env.cr.rollback()
|
||||
raise
|
||||
|
||||
def _try_lock(self, lockfk=False):
|
||||
"""Try to grab a dummy exclusive write-lock to the rows with the given ids,
|
||||
to make sure a following write() or unlink() will not block due
|
||||
to a process currently executing those cron tasks.
|
||||
|
||||
:param lockfk: acquire a strong row lock which conflicts with
|
||||
the lock acquired by foreign keys when they
|
||||
reference this row.
|
||||
"""
|
||||
if not self:
|
||||
return
|
||||
row_level_lock = "UPDATE" if lockfk else "NO KEY UPDATE"
|
||||
try:
|
||||
self._cr.execute(f"""
|
||||
SELECT id
|
||||
FROM "{self._table}"
|
||||
WHERE id IN %s
|
||||
FOR {row_level_lock} NOWAIT
|
||||
""", [tuple(self.ids)], log_exceptions=False)
|
||||
except psycopg2.OperationalError:
|
||||
self._cr.rollback() # early rollback to allow translations to work for the user feedback
|
||||
raise UserError(_("Record cannot be modified right now: "
|
||||
"This cron task is currently being executed and may not be modified "
|
||||
"Please try again in a few minutes"))
|
||||
|
||||
def write(self, vals):
|
||||
self._try_lock()
|
||||
if ('nextcall' in vals or vals.get('active')) and os.getenv('ODOO_NOTIFY_CRON_CHANGES'):
|
||||
self._cr.postcommit.add(self._notifydb)
|
||||
return super(ir_cron, self).write(vals)
|
||||
|
||||
def unlink(self):
|
||||
self._try_lock(lockfk=True)
|
||||
return super(ir_cron, self).unlink()
|
||||
|
||||
def try_write(self, values):
|
||||
try:
|
||||
with self._cr.savepoint():
|
||||
self._cr.execute(f"""
|
||||
SELECT id
|
||||
FROM "{self._table}"
|
||||
WHERE id IN %s
|
||||
FOR NO KEY UPDATE NOWAIT
|
||||
""", [tuple(self.ids)], log_exceptions=False)
|
||||
except psycopg2.OperationalError:
|
||||
pass
|
||||
else:
|
||||
return super(ir_cron, self).write(values)
|
||||
return False
|
||||
self.lock_for_update(allow_referencing=True)
|
||||
except LockError:
|
||||
raise UserError(self.env._(
|
||||
"Record cannot be modified right now: "
|
||||
"This cron task is currently being executed and may not be modified "
|
||||
"Please try again in a few minutes"
|
||||
)) from None
|
||||
if ('nextcall' in vals or vals.get('active')) and os.getenv('ODOO_NOTIFY_CRON_CHANGES'):
|
||||
self.env.cr.postcommit.add(self._notifydb)
|
||||
return super().write(vals)
|
||||
|
||||
@api.ondelete(at_uninstall=False)
|
||||
def _unlink_unless_running(self):
|
||||
try:
|
||||
self.lock_for_update()
|
||||
except LockError:
|
||||
raise UserError(self.env._(
|
||||
"Record cannot be modified right now: "
|
||||
"This cron task is currently being executed and may not be modified "
|
||||
"Please try again in a few minutes"
|
||||
)) from None
|
||||
|
||||
@api.model
|
||||
def toggle(self, model, domain):
|
||||
|
|
@ -639,9 +657,13 @@ class ir_cron(models.Model):
|
|||
return True
|
||||
|
||||
active = bool(self.env[model].search_count(domain))
|
||||
return self.try_write({'active': active})
|
||||
try:
|
||||
self.lock_for_update(allow_referencing=True)
|
||||
except LockError:
|
||||
return True
|
||||
return self.write({'active': active})
|
||||
|
||||
def _trigger(self, at=None):
|
||||
def _trigger(self, at: datetime | Iterable[datetime] | None = None):
|
||||
"""
|
||||
Schedule a cron job to be executed soon independently of its
|
||||
``nextcall`` field value.
|
||||
|
|
@ -655,11 +677,10 @@ class ir_cron(models.Model):
|
|||
datetime. The actual implementation is in :meth:`~._trigger_list`,
|
||||
which is the recommended method for overrides.
|
||||
|
||||
:param Optional[Union[datetime.datetime, list[datetime.datetime]]] at:
|
||||
:param at:
|
||||
When to execute the cron, at one or several moments in time
|
||||
instead of as soon as possible.
|
||||
:return: the created triggers records
|
||||
:rtype: recordset
|
||||
"""
|
||||
if at is None:
|
||||
at_list = [fields.Datetime.now()]
|
||||
|
|
@ -671,14 +692,12 @@ class ir_cron(models.Model):
|
|||
|
||||
return self._trigger_list(at_list)
|
||||
|
||||
def _trigger_list(self, at_list):
|
||||
def _trigger_list(self, at_list: list[datetime]):
|
||||
"""
|
||||
Implementation of :meth:`~._trigger`.
|
||||
|
||||
:param list[datetime.datetime] at_list:
|
||||
Execute the cron later, at precise moments in time.
|
||||
:param at_list: Execute the cron later, at precise moments in time.
|
||||
:return: the created triggers records
|
||||
:rtype: recordset
|
||||
"""
|
||||
self.ensure_one()
|
||||
now = fields.Datetime.now()
|
||||
|
|
@ -699,15 +718,16 @@ class ir_cron(models.Model):
|
|||
_logger.debug('Job %r (%s) will execute at %s', self.sudo().name, self.id, ats)
|
||||
|
||||
if min(at_list) <= now or os.getenv('ODOO_NOTIFY_CRON_CHANGES'):
|
||||
self._cr.postcommit.add(self._notifydb)
|
||||
self.env.cr.postcommit.add(self._notifydb)
|
||||
return triggers
|
||||
|
||||
@api.model
|
||||
def _notifydb(self):
|
||||
""" Wake up the cron workers
|
||||
The ODOO_NOTIFY_CRON_CHANGES environment variable allows to force the notifydb on both
|
||||
ir_cron modification and on trigger creation (regardless of call_at)
|
||||
IrCron modification and on trigger creation (regardless of call_at)
|
||||
"""
|
||||
with odoo.sql_db.db_connect('postgres').cursor() as cr:
|
||||
with sql_db.db_connect('postgres').cursor() as cr:
|
||||
cr.execute(SQL("SELECT %s('cron_trigger', %s)", SQL.identifier(ODOO_NOTIFY_FUNCTION), self.env.cr.dbname))
|
||||
_logger.debug("cron workers notified")
|
||||
|
||||
|
|
@ -731,9 +751,11 @@ class ir_cron(models.Model):
|
|||
}])
|
||||
return self.with_context(ir_cron_progress_id=progress.id), progress
|
||||
|
||||
def _notify_progress(self, *, done, remaining, deactivate=False):
|
||||
@api.deprecated("Since 19.0, use _commit_progress")
|
||||
def _notify_progress(self, *, done: int, remaining: int, deactivate: bool = False):
|
||||
"""
|
||||
Log the progress of the cron job.
|
||||
Use ``_commit_progress()`` instead.
|
||||
|
||||
:param int done: the number of tasks already processed
|
||||
:param int remaining: the number of tasks left to process
|
||||
|
|
@ -743,32 +765,88 @@ class ir_cron(models.Model):
|
|||
return
|
||||
if done < 0 or remaining < 0:
|
||||
raise ValueError("`done` and `remaining` must be positive integers.")
|
||||
self.env['ir.cron.progress'].sudo().browse(progress_id).write({
|
||||
progress = self.env['ir.cron.progress'].sudo().browse(progress_id)
|
||||
assert progress.cron_id.id == self.env.context.get('cron_id'), "Progress on the wrong cron_id"
|
||||
progress.write({
|
||||
'remaining': remaining,
|
||||
'done': done,
|
||||
'deactivate': deactivate,
|
||||
})
|
||||
|
||||
@api.model
|
||||
def _commit_progress(
|
||||
self,
|
||||
processed: int = 0,
|
||||
*,
|
||||
remaining: int | None = None,
|
||||
deactivate: bool = False,
|
||||
) -> float:
|
||||
"""
|
||||
Commit and log progress for the batch from a cron function.
|
||||
|
||||
class ir_cron_trigger(models.Model):
|
||||
The number of items processed is added to the current done count.
|
||||
If you don't specify a remaining count, the number of items processed
|
||||
is subtracted from the existing remaining count.
|
||||
|
||||
If called from outside the cron job, the progress function call will
|
||||
just commit.
|
||||
|
||||
:param processed: number of processed items in this step
|
||||
:param remaining: set the remaining count to the given count
|
||||
:param deactivate: deactivate the cron after running it
|
||||
:return: remaining time (seconds) for the cron run
|
||||
"""
|
||||
ctx = self.env.context
|
||||
progress = self.env['ir.cron.progress'].sudo().browse(ctx.get('ir_cron_progress_id'))
|
||||
if not progress:
|
||||
# not called during a cron, just commit
|
||||
self.env.cr.commit()
|
||||
return float('inf')
|
||||
assert processed >= 0, 'processed must be positive'
|
||||
assert (remaining or 0) >= 0, "remaining must be positive"
|
||||
assert progress.cron_id.id == ctx.get('cron_id'), "Progress on the wrong cron_id"
|
||||
if remaining is None:
|
||||
remaining = max(progress.remaining - processed, 0)
|
||||
done = progress.done + processed
|
||||
vals = {
|
||||
'remaining': remaining,
|
||||
'done': done,
|
||||
}
|
||||
if deactivate:
|
||||
vals['deactivate'] = True
|
||||
progress.write(vals)
|
||||
self.env.cr.commit()
|
||||
return max(ctx.get('cron_end_time', float('inf')) - time.monotonic(), 0)
|
||||
|
||||
def action_open_parent_action(self):
|
||||
return self.ir_actions_server_id.action_open_parent_action()
|
||||
|
||||
def action_open_scheduled_action(self):
|
||||
return self.ir_actions_server_id.action_open_scheduled_action()
|
||||
|
||||
|
||||
class IrCronTrigger(models.Model):
|
||||
_name = 'ir.cron.trigger'
|
||||
_description = 'Triggered actions'
|
||||
_rec_name = 'cron_id'
|
||||
_allow_sudo_commands = False
|
||||
|
||||
cron_id = fields.Many2one("ir.cron", index=True)
|
||||
call_at = fields.Datetime(index=True)
|
||||
cron_id = fields.Many2one("ir.cron", index=True, required=True, ondelete="cascade")
|
||||
call_at = fields.Datetime(index=True, required=True)
|
||||
|
||||
@api.autovacuum
|
||||
def _gc_cron_triggers(self):
|
||||
domain = [('call_at', '<', datetime.now() + relativedelta(weeks=-1))]
|
||||
records = self.search(domain, limit=models.GC_UNLINK_LIMIT)
|
||||
if len(records) >= models.GC_UNLINK_LIMIT:
|
||||
self.env.ref('base.autovacuum_job')._trigger()
|
||||
return records.unlink()
|
||||
# active cron jobs are cleared by `_clear_schedule` when the job starts
|
||||
domain = [
|
||||
('call_at', '<', datetime.now() + relativedelta(weeks=-1)),
|
||||
('cron_id.active', '=', False),
|
||||
]
|
||||
records = self.search(domain, limit=GC_UNLINK_LIMIT)
|
||||
records.unlink()
|
||||
return len(records), len(records) == GC_UNLINK_LIMIT # done, remaining
|
||||
|
||||
|
||||
class ir_cron_progress(models.Model):
|
||||
class IrCronProgress(models.Model):
|
||||
_name = 'ir.cron.progress'
|
||||
_description = 'Progress of Scheduled Actions'
|
||||
_rec_name = 'cron_id'
|
||||
|
|
@ -781,4 +859,6 @@ class ir_cron_progress(models.Model):
|
|||
|
||||
@api.autovacuum
|
||||
def _gc_cron_progress(self):
|
||||
self.search([('create_date', '<', datetime.now() - relativedelta(weeks=1))]).unlink()
|
||||
records = self.search([('create_date', '<', datetime.now() - relativedelta(weeks=1))], limit=GC_UNLINK_LIMIT)
|
||||
records.unlink()
|
||||
return len(records), len(records) == GC_UNLINK_LIMIT # done, remaining
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import json
|
||||
from datetime import date
|
||||
|
||||
from odoo import api, fields, models, tools, _, SUPERUSER_ID
|
||||
from odoo import api, fields, models, tools
|
||||
from odoo.api import SUPERUSER_ID
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.fields import Domain
|
||||
from odoo.tools import SQL
|
||||
|
||||
|
||||
|
|
@ -31,7 +32,7 @@ class IrDefault(models.Model):
|
|||
try:
|
||||
json.loads(record.json_value)
|
||||
except json.JSONDecodeError:
|
||||
raise ValidationError(_('Invalid JSON format in Default Value field.'))
|
||||
raise ValidationError(self.env._('Invalid JSON format in Default Value field.'))
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
|
|
@ -88,11 +89,11 @@ class IrDefault(models.Model):
|
|||
value = field.to_string(value)
|
||||
json_value = json.dumps(value, ensure_ascii=False)
|
||||
except KeyError:
|
||||
raise ValidationError(_("Invalid field %(model)s.%(field)s", model=model_name, field=field_name))
|
||||
raise ValidationError(self.env._("Invalid field %(model)s.%(field)s", model=model_name, field=field_name))
|
||||
except Exception:
|
||||
raise ValidationError(_("Invalid value for %(model)s.%(field)s: %(value)s", model=model_name, field=field_name, value=value))
|
||||
raise ValidationError(self.env._("Invalid value for %(model)s.%(field)s: %(value)s", model=model_name, field=field_name, value=value))
|
||||
if field.type == 'integer' and not (-2**31 < parsed < 2**31-1):
|
||||
raise ValidationError(_("Invalid value for %(model)s.%(field)s: %(value)s is out of bounds (integers should be between -2,147,483,648 and 2,147,483,647)", model=model_name, field=field_name, value=value))
|
||||
raise ValidationError(self.env._("Invalid value for %(model)s.%(field)s: %(value)s is out of bounds (integers should be between -2,147,483,648 and 2,147,483,647)", model=model_name, field=field_name, value=value))
|
||||
|
||||
# update existing default for the same scope, or create one
|
||||
field = self.env['ir.model.fields']._get(model_name, field_name)
|
||||
|
|
@ -213,16 +214,19 @@ class IrDefault(models.Model):
|
|||
for id_ in company_ids
|
||||
})
|
||||
|
||||
def _evaluate_condition_with_fallback(self, model_name, condition):
|
||||
def _evaluate_condition_with_fallback(self, model_name, field_expr, operator, value):
|
||||
"""
|
||||
when the field value of the condition is company_dependent without
|
||||
customization, evaluate if its fallback value will be kept by
|
||||
the condition
|
||||
return True/False/None(for unknown)
|
||||
"""
|
||||
field_name = condition[0].split('.', 1)[0]
|
||||
field_name, _property_name = fields.parse_field_expr(field_expr)
|
||||
model = self.env[model_name]
|
||||
field = model._fields[field_name]
|
||||
fallback = field.get_company_dependent_fallback(model)
|
||||
record = model.new({field_name: field.convert_to_write(fallback, model)})
|
||||
return bool(record.filtered_domain([condition]))
|
||||
try:
|
||||
record = model.new({field_name: field.convert_to_write(fallback, model)})
|
||||
return bool(record.filtered_domain(Domain(field_expr, operator, value)))
|
||||
except ValueError:
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -1,19 +1,17 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models
|
||||
from odoo.modules.loading import force_demo
|
||||
from odoo.addons.base.models.ir_module import assert_log_admin_access
|
||||
|
||||
|
||||
class IrDemo(models.TransientModel):
|
||||
|
||||
_name = 'ir.demo'
|
||||
_description = 'Demo'
|
||||
|
||||
@assert_log_admin_access
|
||||
def install_demo(self):
|
||||
force_demo(self.env)
|
||||
import odoo.modules.loading # noqa: PLC0415
|
||||
odoo.modules.loading.force_demo(self.env)
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'target': 'self',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class DemoFailure(models.TransientModel):
|
||||
class IrDemo_Failure(models.TransientModel):
|
||||
""" Stores modules for which we could not install demo data
|
||||
"""
|
||||
_name = 'ir.demo_failure'
|
||||
|
|
@ -11,7 +11,8 @@ class DemoFailure(models.TransientModel):
|
|||
error = fields.Char(string="Error")
|
||||
wizard_id = fields.Many2one('ir.demo_failure.wizard')
|
||||
|
||||
class DemoFailureWizard(models.TransientModel):
|
||||
|
||||
class IrDemo_FailureWizard(models.TransientModel):
|
||||
_name = 'ir.demo_failure.wizard'
|
||||
_description = 'Demo Failure wizard'
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
from ast import literal_eval
|
||||
|
||||
|
|
@ -28,22 +28,17 @@ class IrEmbeddedActions(models.Model):
|
|||
context = fields.Char(default="{}", help="Context dictionary as Python expression, empty by default (Default: {})")
|
||||
groups_ids = fields.Many2many('res.groups', help='Groups that can execute the embedded action. Leave empty to allow everybody.')
|
||||
|
||||
_sql_constraints = [
|
||||
(
|
||||
'check_only_one_action_defined',
|
||||
"""CHECK(
|
||||
(action_id IS NOT NULL AND python_method IS NULL) OR
|
||||
(action_id IS NULL AND python_method IS NOT NULL)
|
||||
)""",
|
||||
'Constraint to ensure that either an XML action or a python_method is defined, but not both.'
|
||||
), (
|
||||
'check_python_method_requires_name',
|
||||
"""CHECK(
|
||||
NOT (python_method IS NOT NULL AND name IS NULL)
|
||||
)""",
|
||||
'Constraint to ensure that if a python_method is defined, then the name must also be defined.'
|
||||
)
|
||||
]
|
||||
_check_only_one_action_defined = models.Constraint(
|
||||
'''CHECK(
|
||||
(action_id IS NOT NULL AND python_method IS NULL)
|
||||
OR (action_id IS NULL AND python_method IS NOT NULL)
|
||||
)''',
|
||||
"Constraint to ensure that either an XML action or a python_method is defined, but not both.",
|
||||
)
|
||||
_check_python_method_requires_name = models.Constraint(
|
||||
'CHECK(NOT (python_method IS NOT NULL AND name IS NULL))',
|
||||
"Constraint to ensure that if a python_method is defined, then the name must also be defined.",
|
||||
)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
|
|
@ -80,7 +75,8 @@ class IrEmbeddedActions(models.Model):
|
|||
active_model_record = self.env[parent_res_model].search(domain_id, order='id')
|
||||
for record in records:
|
||||
action_groups = record.groups_ids
|
||||
if not action_groups or (action_groups & self.env.user.groups_id):
|
||||
is_valid_method = not record.python_method or hasattr(self.env[parent_res_model], record.python_method)
|
||||
if is_valid_method and (not action_groups or (action_groups & self.env.user.all_group_ids)):
|
||||
domain_model = literal_eval(record.domain or '[]')
|
||||
record.is_visible = (
|
||||
record.parent_res_id in (False, self.env.context.get('active_id', False))
|
||||
|
|
@ -95,7 +91,7 @@ class IrEmbeddedActions(models.Model):
|
|||
def _unlink_if_action_deletable(self):
|
||||
for record in self:
|
||||
if not record.is_deletable:
|
||||
raise UserError(_('You cannot delete a default embedded action'))
|
||||
raise UserError(self.env._('You cannot delete a default embedded action'))
|
||||
|
||||
def _get_readable_fields(self):
|
||||
""" return the list of fields that are safe to read
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ from odoo import fields, models
|
|||
|
||||
|
||||
class IrExports(models.Model):
|
||||
_name = "ir.exports"
|
||||
_name = 'ir.exports'
|
||||
_description = 'Exports'
|
||||
_order = 'name'
|
||||
_order = 'name, id'
|
||||
|
||||
name = fields.Char(string='Export Name')
|
||||
resource = fields.Char(index=True)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import json
|
||||
|
|
@ -6,7 +5,6 @@ import functools
|
|||
import itertools
|
||||
from typing import NamedTuple
|
||||
|
||||
import psycopg2
|
||||
import pytz
|
||||
|
||||
from odoo import api, Command, fields, models
|
||||
|
|
@ -42,6 +40,7 @@ class ImportWarning(Warning):
|
|||
class ConversionNotFound(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class IrFieldsConverter(models.AbstractModel):
|
||||
_name = 'ir.fields.converter'
|
||||
_description = 'Fields Converter'
|
||||
|
|
@ -75,6 +74,7 @@ class IrFieldsConverter(models.AbstractModel):
|
|||
|
||||
The field_path value is computed based on the last field in the chain.
|
||||
for example,
|
||||
|
||||
- path_field for 'Private address' at childA_1 is ['partner_id', 'type']
|
||||
- path_field for 'childA_1' is ['partner_id']
|
||||
|
||||
|
|
@ -82,7 +82,7 @@ class IrFieldsConverter(models.AbstractModel):
|
|||
we can the link the errors to the correct header-field couple in the import UI.
|
||||
"""
|
||||
field_path = [field]
|
||||
parent_fields_hierarchy = self._context.get('parent_fields_hierarchy')
|
||||
parent_fields_hierarchy = self.env.context.get('parent_fields_hierarchy')
|
||||
if parent_fields_hierarchy:
|
||||
field_path = parent_fields_hierarchy + field_path
|
||||
|
||||
|
|
@ -95,14 +95,15 @@ class IrFieldsConverter(models.AbstractModel):
|
|||
return field_path
|
||||
|
||||
@api.model
|
||||
def for_model(self, model, fromtype=str):
|
||||
def for_model(self, model, fromtype=str, *, savepoint):
|
||||
""" Returns a converter object for the model. A converter is a
|
||||
callable taking a record-ish (a dictionary representing an odoo
|
||||
record with values of typetag ``fromtype``) and returning a converted
|
||||
records matching what :meth:`odoo.osv.orm.Model.write` expects.
|
||||
records matching what :meth:`odoo.models.Model.write` expects.
|
||||
|
||||
:param model: :class:`odoo.osv.orm.Model` for the conversion base
|
||||
:param model: :class:`odoo.models.Model` for the conversion base
|
||||
:param fromtype:
|
||||
:param savepoint: savepoint to rollback to on error
|
||||
:returns: a converter callable
|
||||
:rtype: (record: dict, logger: (field, error) -> None) -> dict
|
||||
"""
|
||||
|
|
@ -110,7 +111,7 @@ class IrFieldsConverter(models.AbstractModel):
|
|||
model = self.env[model._name]
|
||||
|
||||
converters = {
|
||||
name: self.to_field(model, field, fromtype)
|
||||
name: self.to_field(model, field, fromtype, savepoint=savepoint)
|
||||
for name, field in model._fields.items()
|
||||
}
|
||||
|
||||
|
|
@ -155,7 +156,7 @@ class IrFieldsConverter(models.AbstractModel):
|
|||
return fn
|
||||
|
||||
@api.model
|
||||
def to_field(self, model, field, fromtype=str):
|
||||
def to_field(self, model, field, fromtype=str, *, savepoint):
|
||||
""" Fetches a converter for the provided field object, from the
|
||||
specified type.
|
||||
|
||||
|
|
@ -191,6 +192,7 @@ class IrFieldsConverter(models.AbstractModel):
|
|||
:type field: :class:`odoo.fields.Field`
|
||||
:param fromtype: type to convert to something fitting for ``field``
|
||||
:type fromtype: type | str
|
||||
:param savepoint: savepoint to rollback to on errors
|
||||
:return: a function (fromtype -> field.write_type), if a converter is found
|
||||
:rtype: Callable | None
|
||||
"""
|
||||
|
|
@ -200,38 +202,37 @@ class IrFieldsConverter(models.AbstractModel):
|
|||
converter = getattr(self, '_%s_to_%s' % (typename, field.type), None)
|
||||
if not converter:
|
||||
return None
|
||||
return functools.partial(converter, model, field)
|
||||
return functools.partial(converter, model, field, savepoint=savepoint)
|
||||
|
||||
def _str_to_json(self, model, field, value):
|
||||
def _str_to_json(self, model, field, value, savepoint):
|
||||
try:
|
||||
return json.loads(value), []
|
||||
except ValueError:
|
||||
msg = _("'%s' does not seem to be a valid JSON for field '%%(field)s'")
|
||||
msg = self.env._("'%s' does not seem to be a valid JSON for field '%%(field)s'")
|
||||
raise self._format_import_error(ValueError, msg, value)
|
||||
|
||||
def _str_to_properties(self, model, field, value):
|
||||
def _str_to_properties(self, model, field, value, savepoint):
|
||||
|
||||
# If we want to import the all properties at once (with the technical value)
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value = json.loads(value)
|
||||
except ValueError:
|
||||
msg = _("Unable to import'%%(field)s' Properties field as a whole, target individual property instead.")
|
||||
msg = self.env._("Unable to import'%%(field)s' Properties field as a whole, target individual property instead.")
|
||||
raise self._format_import_error(ValueError, msg)
|
||||
|
||||
if not isinstance(value, list):
|
||||
msg = _("Unable to import'%%(field)s' Properties field as a whole, target individual property instead.")
|
||||
msg = self.env._("Unable to import'%%(field)s' Properties field as a whole, target individual property instead.")
|
||||
raise self._format_import_error(ValueError, msg, {'value': value})
|
||||
|
||||
warnings = []
|
||||
for property_dict in value:
|
||||
if not (property_dict.keys() >= {'name', 'type', 'string'}):
|
||||
msg = _("'%(value)s' does not seem to be a valid Property value for field '%%(field)s'. Each property need at least 'name', 'type' and 'string' attribute.")
|
||||
msg = self.env._("'%(value)s' does not seem to be a valid Property value for field '%%(field)s'. Each property need at least 'name', 'type' and 'string' attribute.")
|
||||
raise self._format_import_error(ValueError, msg, {'value': property_dict})
|
||||
|
||||
val = property_dict.get('value')
|
||||
if not val:
|
||||
property_dict.pop('value', None)
|
||||
continue
|
||||
|
||||
property_type = property_dict['type']
|
||||
|
|
@ -243,7 +244,7 @@ class IrFieldsConverter(models.AbstractModel):
|
|||
if val in (sel_val, sel_label)
|
||||
), None)
|
||||
if not new_val:
|
||||
msg = _("'%(value)s' does not seem to be a valid Selection value for '%(label_property)s' (subfield of '%%(field)s' field).")
|
||||
msg = self.env._("'%(value)s' does not seem to be a valid Selection value for '%(label_property)s' (subfield of '%%(field)s' field).")
|
||||
raise self._format_import_error(ValueError, msg, {'value': val, 'label_property': property_dict['string']})
|
||||
property_dict['value'] = new_val
|
||||
|
||||
|
|
@ -256,17 +257,17 @@ class IrFieldsConverter(models.AbstractModel):
|
|||
if tag in (tag_val, tag_label)
|
||||
), None)
|
||||
if not val_tag:
|
||||
msg = _("'%(value)s' does not seem to be a valid Tag value for '%(label_property)s' (subfield of '%%(field)s' field).")
|
||||
msg = self.env._("'%(value)s' does not seem to be a valid Tag value for '%(label_property)s' (subfield of '%%(field)s' field).")
|
||||
raise self._format_import_error(ValueError, msg, {'value': tag, 'label_property': property_dict['string']})
|
||||
new_val.append(val_tag)
|
||||
property_dict['value'] = new_val
|
||||
|
||||
elif property_type == 'boolean':
|
||||
new_val, warnings = self._str_to_boolean(model, field, val)
|
||||
new_val, warnings = self._str_to_boolean(model, field, val, savepoint=savepoint)
|
||||
if not warnings:
|
||||
property_dict['value'] = new_val
|
||||
else:
|
||||
msg = _("Unknown value '%(value)s' for boolean '%(label_property)s' property (subfield of '%%(field)s' field).")
|
||||
msg = self.env._("Unknown value '%(value)s' for boolean '%(label_property)s' property (subfield of '%%(field)s' field).")
|
||||
raise self._format_import_error(ValueError, msg, {'value': val, 'label_property': property_dict['string']})
|
||||
|
||||
elif property_type in ('many2one', 'many2many'):
|
||||
|
|
@ -282,7 +283,7 @@ class IrFieldsConverter(models.AbstractModel):
|
|||
ids = []
|
||||
fake_field = FakeField(comodel_name=property_dict['comodel'], name=property_dict['string'])
|
||||
for reference in references:
|
||||
id_, __, ws = self.db_id_for(model, fake_field, subfield, reference)
|
||||
id_, ws = self.db_id_for(model, fake_field, subfield, reference, savepoint)
|
||||
ids.append(id_)
|
||||
warnings.extend(ws)
|
||||
|
||||
|
|
@ -292,20 +293,20 @@ class IrFieldsConverter(models.AbstractModel):
|
|||
try:
|
||||
property_dict['value'] = int(val)
|
||||
except ValueError:
|
||||
msg = _("'%(value)s' does not seem to be an integer for field '%(label_property)s' property (subfield of '%%(field)s' field).")
|
||||
msg = self.env._("'%(value)s' does not seem to be an integer for field '%(label_property)s' property (subfield of '%%(field)s' field).")
|
||||
raise self._format_import_error(ValueError, msg, {'value': val, 'label_property': property_dict['string']})
|
||||
|
||||
elif property_type == 'float':
|
||||
try:
|
||||
property_dict['value'] = float(val)
|
||||
except ValueError:
|
||||
msg = _("'%(value)s' does not seem to be an float for field '%(label_property)s' property (subfield of '%%(field)s' field).")
|
||||
msg = self.env._("'%(value)s' does not seem to be an float for field '%(label_property)s' property (subfield of '%%(field)s' field).")
|
||||
raise self._format_import_error(ValueError, msg, {'value': val, 'label_property': property_dict['string']})
|
||||
|
||||
return value, warnings
|
||||
|
||||
@api.model
|
||||
def _str_to_boolean(self, model, field, value):
|
||||
def _str_to_boolean(self, model, field, value, savepoint):
|
||||
# all translatables used for booleans
|
||||
# potentially broken casefolding? What about locales?
|
||||
trues = set(word.lower() for word in itertools.chain(
|
||||
|
|
@ -325,89 +326,73 @@ class IrFieldsConverter(models.AbstractModel):
|
|||
if value.lower() in falses:
|
||||
return False, []
|
||||
|
||||
if field.name in self._context.get('import_skip_records', []):
|
||||
if field.name in self.env.context.get('import_skip_records', []):
|
||||
return None, []
|
||||
|
||||
return True, [self._format_import_error(
|
||||
ValueError,
|
||||
_(u"Unknown value '%s' for boolean field '%%(field)s'"),
|
||||
self.env._("Unknown value '%s' for boolean field '%%(field)s'"),
|
||||
value,
|
||||
{'moreinfo': _(u"Use '1' for yes and '0' for no")}
|
||||
{'moreinfo': self.env._("Use '1' for yes and '0' for no")}
|
||||
)]
|
||||
|
||||
@api.model
|
||||
def _str_to_integer(self, model, field, value):
|
||||
def _str_to_integer(self, model, field, value, savepoint):
|
||||
try:
|
||||
return int(value), []
|
||||
except ValueError:
|
||||
raise self._format_import_error(
|
||||
ValueError,
|
||||
_(u"'%s' does not seem to be an integer for field '%%(field)s'"),
|
||||
self.env._("'%s' does not seem to be an integer for field '%%(field)s'"),
|
||||
value
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _str_to_float(self, model, field, value):
|
||||
def _str_to_float(self, model, field, value, savepoint):
|
||||
try:
|
||||
return float(value), []
|
||||
except ValueError:
|
||||
raise self._format_import_error(
|
||||
ValueError,
|
||||
_(u"'%s' does not seem to be a number for field '%%(field)s'"),
|
||||
self.env._("'%s' does not seem to be a number for field '%%(field)s'"),
|
||||
value
|
||||
)
|
||||
|
||||
_str_to_monetary = _str_to_float
|
||||
|
||||
@api.model
|
||||
def _str_id(self, model, field, value):
|
||||
def _str_id(self, model, field, value, savepoint):
|
||||
return value, []
|
||||
|
||||
_str_to_reference = _str_to_char = _str_to_text = _str_to_binary = _str_to_html = _str_id
|
||||
|
||||
@api.model
|
||||
def _str_to_date(self, model, field, value):
|
||||
def _str_to_date(self, model, field, value, savepoint):
|
||||
try:
|
||||
parsed_value = fields.Date.from_string(value)
|
||||
return fields.Date.to_string(parsed_value), []
|
||||
except ValueError:
|
||||
raise self._format_import_error(
|
||||
ValueError,
|
||||
_(u"'%s' does not seem to be a valid date for field '%%(field)s'"),
|
||||
self.env._("'%s' does not seem to be a valid date for field '%%(field)s'"),
|
||||
value,
|
||||
{'moreinfo': _(u"Use the format '%s'", u"2012-12-31")}
|
||||
{'moreinfo': self.env._("Use the format '%s'", u"2012-12-31")}
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _input_tz(self):
|
||||
# if there's a tz in context, try to use that
|
||||
if self._context.get('tz'):
|
||||
try:
|
||||
return pytz.timezone(self._context['tz'])
|
||||
except pytz.UnknownTimeZoneError:
|
||||
pass
|
||||
|
||||
# if the current user has a tz set, try to use that
|
||||
user = self.env.user
|
||||
if user.tz:
|
||||
try:
|
||||
return pytz.timezone(user.tz)
|
||||
except pytz.UnknownTimeZoneError:
|
||||
pass
|
||||
|
||||
# fallback if no tz in context or on user: UTC
|
||||
return pytz.UTC
|
||||
return self.env.tz
|
||||
|
||||
@api.model
|
||||
def _str_to_datetime(self, model, field, value):
|
||||
def _str_to_datetime(self, model, field, value, savepoint):
|
||||
try:
|
||||
parsed_value = fields.Datetime.from_string(value)
|
||||
except ValueError:
|
||||
raise self._format_import_error(
|
||||
ValueError,
|
||||
_(u"'%s' does not seem to be a valid datetime for field '%%(field)s'"),
|
||||
self.env._("'%s' does not seem to be a valid datetime for field '%%(field)s'"),
|
||||
value,
|
||||
{'moreinfo': _(u"Use the format '%s'", u"2012-12-31 23:59:59")}
|
||||
{'moreinfo': self.env._("Use the format '%s'", u"2012-12-31 23:59:59")}
|
||||
)
|
||||
|
||||
input_tz = self._input_tz()# Apply input tz to the parsed naive datetime
|
||||
|
|
@ -419,7 +404,7 @@ class IrFieldsConverter(models.AbstractModel):
|
|||
def _get_boolean_translations(self, src):
|
||||
# Cache translations so they don't have to be reloaded from scratch on
|
||||
# every row of the file
|
||||
tnx_cache = self._cr.cache.setdefault(self._name, {})
|
||||
tnx_cache = self.env.cr.cache.setdefault(self._name, {})
|
||||
if src in tnx_cache:
|
||||
return tnx_cache[src]
|
||||
|
||||
|
|
@ -438,7 +423,7 @@ class IrFieldsConverter(models.AbstractModel):
|
|||
return []
|
||||
# Cache translations so they don't have to be reloaded from scratch on
|
||||
# every row of the file
|
||||
tnx_cache = self._cr.cache.setdefault(self._name, {})
|
||||
tnx_cache = self.env.cr.cache.setdefault(self._name, {})
|
||||
if src in tnx_cache:
|
||||
return tnx_cache[src]
|
||||
|
||||
|
|
@ -459,7 +444,7 @@ class IrFieldsConverter(models.AbstractModel):
|
|||
return result
|
||||
|
||||
@api.model
|
||||
def _str_to_selection(self, model, field, value):
|
||||
def _str_to_selection(self, model, field, value, savepoint):
|
||||
# get untranslated values
|
||||
env = self.with_context(lang=None).env
|
||||
selection = field.get_description(env)['selection']
|
||||
|
|
@ -478,19 +463,19 @@ class IrFieldsConverter(models.AbstractModel):
|
|||
if value.lower() == str(item).lower() or any(value.lower() == label.lower() for label in labels):
|
||||
return item, []
|
||||
|
||||
if field.name in self._context.get('import_skip_records', []):
|
||||
if field.name in self.env.context.get('import_skip_records', []):
|
||||
return None, []
|
||||
elif field.name in self._context.get('import_set_empty_fields', []):
|
||||
elif field.name in self.env.context.get('import_set_empty_fields', []):
|
||||
return False, []
|
||||
raise self._format_import_error(
|
||||
ValueError,
|
||||
_(u"Value '%s' not found in selection field '%%(field)s'"),
|
||||
self.env._("Value '%s' not found in selection field '%%(field)s'"),
|
||||
value,
|
||||
{'moreinfo': [_label or str(item) for item, _label in selection if _label or item]}
|
||||
)
|
||||
|
||||
@api.model
|
||||
def db_id_for(self, model, field, subfield, value):
|
||||
def db_id_for(self, model, field, subfield, value, savepoint):
|
||||
""" Finds a database id for the reference ``value`` in the referencing
|
||||
subfield ``subfield`` of the provided field of the provided model.
|
||||
|
||||
|
|
@ -501,6 +486,7 @@ class IrFieldsConverter(models.AbstractModel):
|
|||
``id`` for an external id and ``.id`` for a database
|
||||
id
|
||||
:param value: value of the reference to match to an actual record
|
||||
:param savepoint: savepoint for rollback on errors
|
||||
:return: a pair of the matched database identifier (if any), the
|
||||
translated user-readable name for the field and the list of
|
||||
warnings
|
||||
|
|
@ -508,7 +494,7 @@ class IrFieldsConverter(models.AbstractModel):
|
|||
"""
|
||||
# the function 'flush' comes from BaseModel.load(), and forces the
|
||||
# creation/update of former records (batch creation)
|
||||
flush = self._context.get('import_flush', lambda **kw: None)
|
||||
flush = self.env.context.get('import_flush', lambda **kw: None)
|
||||
|
||||
id = None
|
||||
warnings = []
|
||||
|
|
@ -519,7 +505,7 @@ class IrFieldsConverter(models.AbstractModel):
|
|||
'view_mode': 'list,form',
|
||||
'views': [(False, 'list'), (False, 'form')],
|
||||
'context': {'create': False},
|
||||
'help': _(u"See all possible values")}
|
||||
'help': self.env._("See all possible values")}
|
||||
if subfield is None:
|
||||
action['res_model'] = field.comodel_name
|
||||
elif subfield in ('id', '.id'):
|
||||
|
|
@ -528,33 +514,33 @@ class IrFieldsConverter(models.AbstractModel):
|
|||
|
||||
RelatedModel = self.env[field.comodel_name]
|
||||
if subfield == '.id':
|
||||
field_type = _(u"database id")
|
||||
if isinstance(value, str) and not self._str_to_boolean(model, field, value)[0]:
|
||||
return False, field_type, warnings
|
||||
field_type = self.env._("database id")
|
||||
if isinstance(value, str) and not self._str_to_boolean(model, field, value, savepoint=savepoint)[0]:
|
||||
return False, warnings
|
||||
try:
|
||||
tentative_id = int(value)
|
||||
except ValueError:
|
||||
raise self._format_import_error(
|
||||
ValueError,
|
||||
_(u"Invalid database id '%s' for the field '%%(field)s'"),
|
||||
self.env._("Invalid database id '%s' for the field '%%(field)s'"),
|
||||
value,
|
||||
{'moreinfo': action})
|
||||
if RelatedModel.browse(tentative_id).exists():
|
||||
id = tentative_id
|
||||
elif subfield == 'id':
|
||||
field_type = _(u"external id")
|
||||
if not self._str_to_boolean(model, field, value)[0]:
|
||||
return False, field_type, warnings
|
||||
field_type = self.env._("external id")
|
||||
if not self._str_to_boolean(model, field, value, savepoint=savepoint)[0]:
|
||||
return False, warnings
|
||||
if '.' in value:
|
||||
xmlid = value
|
||||
else:
|
||||
xmlid = "%s.%s" % (self._context.get('_import_current_module', ''), value)
|
||||
xmlid = "%s.%s" % (self.env.context.get('_import_current_module', ''), value)
|
||||
flush(xml_id=xmlid)
|
||||
id = self._xmlid_to_record_id(xmlid, RelatedModel)
|
||||
elif subfield is None:
|
||||
field_type = _(u"name")
|
||||
field_type = self.env._("name")
|
||||
if value == '':
|
||||
return False, field_type, warnings
|
||||
return False, warnings
|
||||
flush(model=field.comodel_name)
|
||||
ids = RelatedModel.name_search(name=value, operator='=')
|
||||
if ids:
|
||||
|
|
@ -569,14 +555,15 @@ class IrFieldsConverter(models.AbstractModel):
|
|||
name_create_enabled_fields = self.env.context.get('name_create_enabled_fields') or {}
|
||||
if name_create_enabled_fields.get(field.name):
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
id, _name = RelatedModel.name_create(name=value)
|
||||
except (Exception, psycopg2.IntegrityError):
|
||||
error_msg = _("Cannot create new '%s' records from their name alone. Please create those records manually and try importing again.", RelatedModel._description)
|
||||
id, _name = RelatedModel.name_create(name=value)
|
||||
RelatedModel.env.flush_all()
|
||||
except Exception: # noqa: BLE001
|
||||
savepoint.rollback()
|
||||
error_msg = self.env._("Cannot create new '%s' records from their name alone. Please create those records manually and try importing again.", RelatedModel._description)
|
||||
else:
|
||||
raise self._format_import_error(
|
||||
Exception,
|
||||
_("Unknown sub-field “%s”", subfield),
|
||||
self.env._("Unknown sub-field “%s”", subfield),
|
||||
)
|
||||
|
||||
set_empty = False
|
||||
|
|
@ -588,9 +575,9 @@ class IrFieldsConverter(models.AbstractModel):
|
|||
skip_record = field_path in self.env.context.get('import_skip_records', [])
|
||||
if id is None and not set_empty and not skip_record:
|
||||
if error_msg:
|
||||
message = _("No matching record found for %(field_type)s '%(value)s' in field '%%(field)s' and the following error was encountered when we attempted to create one: %(error_message)s")
|
||||
message = self.env._("No matching record found for %(field_type)s '%(value)s' in field '%%(field)s' and the following error was encountered when we attempted to create one: %(error_message)s")
|
||||
else:
|
||||
message = _("No matching record found for %(field_type)s '%(value)s' in field '%%(field)s'")
|
||||
message = self.env._("No matching record found for %(field_type)s '%(value)s' in field '%%(field)s'")
|
||||
|
||||
error_info_dict = {'moreinfo': action}
|
||||
if self.env.context.get('import_file'):
|
||||
|
|
@ -604,7 +591,7 @@ class IrFieldsConverter(models.AbstractModel):
|
|||
message,
|
||||
{'field_type': field_type, 'value': value, 'error_message': error_msg},
|
||||
error_info_dict)
|
||||
return id, field_type, warnings
|
||||
return id, warnings
|
||||
|
||||
def _xmlid_to_record_id(self, xmlid, model):
|
||||
""" Return the record id corresponding to the given external id,
|
||||
|
|
@ -645,54 +632,54 @@ class IrFieldsConverter(models.AbstractModel):
|
|||
fieldset = set(record)
|
||||
if fieldset - REFERENCING_FIELDS:
|
||||
raise ValueError(
|
||||
_(u"Can not create Many-To-One records indirectly, import the field separately"))
|
||||
self.env._("Can not create Many-To-One records indirectly, import the field separately"))
|
||||
if len(fieldset) > 1:
|
||||
raise ValueError(
|
||||
_(u"Ambiguous specification for field '%(field)s', only provide one of name, external id or database id"))
|
||||
self.env._("Ambiguous specification for field '%(field)s', only provide one of name, external id or database id"))
|
||||
|
||||
# only one field left possible, unpack
|
||||
[subfield] = fieldset
|
||||
return subfield, []
|
||||
|
||||
@api.model
|
||||
def _str_to_many2one(self, model, field, values):
|
||||
def _str_to_many2one(self, model, field, values, savepoint):
|
||||
# Should only be one record, unpack
|
||||
[record] = values
|
||||
|
||||
subfield, w1 = self._referencing_subfield(record)
|
||||
|
||||
id, _, w2 = self.db_id_for(model, field, subfield, record[subfield])
|
||||
id, w2 = self.db_id_for(model, field, subfield, record[subfield], savepoint)
|
||||
return id, w1 + w2
|
||||
|
||||
@api.model
|
||||
def _str_to_many2one_reference(self, model, field, value):
|
||||
return self._str_to_integer(model, field, value)
|
||||
def _str_to_many2one_reference(self, model, field, value, savepoint):
|
||||
return self._str_to_integer(model, field, value, savepoint)
|
||||
|
||||
@api.model
|
||||
def _str_to_many2many(self, model, field, value):
|
||||
def _str_to_many2many(self, model, field, value, savepoint):
|
||||
[record] = value
|
||||
|
||||
subfield, warnings = self._referencing_subfield(record)
|
||||
|
||||
ids = []
|
||||
for reference in record[subfield].split(','):
|
||||
id, _, ws = self.db_id_for(model, field, subfield, reference)
|
||||
id, ws = self.db_id_for(model, field, subfield, reference, savepoint)
|
||||
ids.append(id)
|
||||
warnings.extend(ws)
|
||||
|
||||
if field.name in self._context.get('import_set_empty_fields', []) and any([id is None for id in ids]):
|
||||
if field.name in self.env.context.get('import_set_empty_fields', []) and any(id is None for id in ids):
|
||||
ids = [id for id in ids if id]
|
||||
elif field.name in self._context.get('import_skip_records', []) and any([id is None for id in ids]):
|
||||
elif field.name in self.env.context.get('import_skip_records', []) and any(id is None for id in ids):
|
||||
return None, warnings
|
||||
|
||||
if self._context.get('update_many2many'):
|
||||
if self.env.context.get('update_many2many'):
|
||||
return [Command.link(id) for id in ids], warnings
|
||||
else:
|
||||
return [Command.set(ids)], warnings
|
||||
|
||||
@api.model
|
||||
def _str_to_one2many(self, model, field, records):
|
||||
name_create_enabled_fields = self._context.get('name_create_enabled_fields') or {}
|
||||
def _str_to_one2many(self, model, field, records, savepoint):
|
||||
name_create_enabled_fields = self.env.context.get('name_create_enabled_fields') or {}
|
||||
prefix = field.name + '/'
|
||||
relative_name_create_enabled_fields = {
|
||||
k[len(prefix):]: v
|
||||
|
|
@ -722,12 +709,12 @@ class IrFieldsConverter(models.AbstractModel):
|
|||
|
||||
# Complete the field hierarchy path
|
||||
# E.g. For "parent/child/subchild", field hierarchy path for "subchild" is ['parent', 'child']
|
||||
parent_fields_hierarchy = self._context.get('parent_fields_hierarchy', []) + [field.name]
|
||||
parent_fields_hierarchy = self.env.context.get('parent_fields_hierarchy', []) + [field.name]
|
||||
|
||||
convert = self.with_context(
|
||||
name_create_enabled_fields=relative_name_create_enabled_fields,
|
||||
parent_fields_hierarchy=parent_fields_hierarchy
|
||||
).for_model(self.env[field.comodel_name])
|
||||
).for_model(self.env[field.comodel_name], savepoint=savepoint)
|
||||
|
||||
for record in records:
|
||||
id = None
|
||||
|
|
@ -737,7 +724,7 @@ class IrFieldsConverter(models.AbstractModel):
|
|||
subfield, w1 = self._referencing_subfield(refs)
|
||||
warnings.extend(w1)
|
||||
try:
|
||||
id, _, w2 = self.db_id_for(model, field, subfield, record[subfield])
|
||||
id, w2 = self.db_id_for(model, field, subfield, record[subfield], savepoint)
|
||||
warnings.extend(w2)
|
||||
except ValueError:
|
||||
if subfield != 'id':
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue