19.0 vanilla

This commit is contained in:
Ernad Husremovic 2025-10-03 18:07:25 +02:00
parent 0a7ae8db93
commit 991d2234ca
416 changed files with 646602 additions and 300844 deletions

File diff suppressed because it is too large Load diff

View file

@ -13,7 +13,7 @@ from datetime import datetime, date
from lxml import etree
import odoo
from odoo import api, fields
from odoo.models import BaseModel
from odoo.fields import Command
from odoo.tools.safe_eval import safe_eval
@ -66,8 +66,8 @@ class Form:
removing records::
with Form(user) as u:
u.groups_id.add(env.ref('account.group_account_manager'))
u.groups_id.remove(id=env.ref('base.group_portal').id)
u.group_ids.add(env.ref('account.group_account_manager'))
u.group_ids.remove(id=env.ref('base.group_portal').id)
Finally :class:`~odoo.fields.One2many` are reified as :class:`~O2MProxy`.
@ -142,7 +142,7 @@ class Form:
self._init_from_defaults()
@classmethod
def from_action(cls, env: odoo.api.Environment, action: dict) -> Form:
def from_action(cls, env: api.Environment, action: dict) -> Form:
assert action['type'] == 'ir.actions.act_window', \
f"only window actions are valid, got {action['type']}"
# ensure the first-requested view is a form view
@ -179,11 +179,13 @@ class Form:
# retrieve <field> nodes at the current level
flevel = tree.xpath('count(ancestor::field)')
daterange_field_names = {}
field_infos = self._models_info.get(model._name, {}).get("fields", {})
for node in tree.xpath(f'.//field[count(ancestor::field) = {flevel}]'):
field_name = node.get('name')
# add field_info into fields
field_info = self._models_info.get(model._name, {}).get("fields", {}).get(field_name) or {'type': None}
field_info = field_infos.get(field_name) or {'type': None}
fields[field_name] = field_info
fields_spec[field_name] = field_spec = {}
@ -255,6 +257,15 @@ class Form:
field_info['type'] = 'many2many'
for related_field, start_field in daterange_field_names.items():
# If the field doesn't exist in the view add it implicitly
if related_field not in modifiers:
field_info = field_infos.get(related_field) or {'type': None}
fields[related_field] = field_info
fields_spec[related_field] = {}
modifiers[related_field] = {
'required': field_info.get('required', False),
'readonly': field_info.get('readonly', False),
}
modifiers[related_field]['invisible'] = modifiers[start_field].get('invisible', False)
return {
@ -601,19 +612,20 @@ class Form:
subfields = field_info['edition_view']['fields']
field_value = values[fname]
for cmd in value:
if cmd[0] == Command.CREATE:
vals = UpdateDict(convert_read_to_form(dict.fromkeys(subfields, False), subfields))
self._apply_onchange_(vals, subfields, cmd[2])
field_value.create(vals)
elif cmd[0] == Command.UPDATE:
vals = field_value.get_vals(cmd[1])
self._apply_onchange_(vals, subfields, cmd[2])
elif cmd[0] in (Command.DELETE, Command.UNLINK):
field_value.remove(cmd[1])
elif cmd[0] == Command.LINK:
field_value.add(cmd[1], convert_read_to_form(cmd[2], subfields))
else:
assert False, "Unexpected onchange() result"
match cmd[0]:
case Command.CREATE:
vals = UpdateDict(convert_read_to_form(dict.fromkeys(subfields, False), subfields))
self._apply_onchange_(vals, subfields, cmd[2])
field_value.create(vals)
case Command.UPDATE:
vals = field_value.get_vals(cmd[1])
self._apply_onchange_(vals, subfields, cmd[2])
case Command.DELETE | Command.UNLINK:
field_value.remove(cmd[1])
case Command.LINK:
field_value.add(cmd[1], convert_read_to_form(cmd[2], subfields))
case c:
raise ValueError(f"Unexpected onchange() o2m command {c!r}")
else:
values[fname] = value
values._changed.add(fname)
@ -969,10 +981,10 @@ class M2MProxy(X2MProxy, collections.abc.Sequence):
self._form._perform_onchange(self._field)
def convert_read_to_form(values, fields):
def convert_read_to_form(values, model_fields):
result = {}
for fname, value in values.items():
field_info = {'type': 'id'} if fname == 'id' else fields[fname]
field_info = {'type': 'id'} if fname == 'id' else model_fields[fname]
if field_info['type'] == 'one2many':
if 'edition_view' in field_info:
subfields = field_info['edition_view']['fields']
@ -982,9 +994,9 @@ def convert_read_to_form(values, fields):
elif field_info['type'] == 'many2many':
value = M2MValue({'id': id_} for id_ in (value or ()))
elif field_info['type'] == 'datetime' and isinstance(value, datetime):
value = odoo.fields.Datetime.to_string(value)
value = fields.Datetime.to_string(value)
elif field_info['type'] == 'date' and isinstance(value, date):
value = odoo.fields.Date.to_string(value)
value = fields.Date.to_string(value)
result[fname] = value
return result
@ -1000,12 +1012,11 @@ def _cleanup_from_default(type_, value):
return value
if type_ == 'one2many':
assert False, "not implemented yet"
return [cmd for cmd in value if cmd[0] != Command.SET]
raise NotImplementedError()
elif type_ == 'datetime' and isinstance(value, datetime):
return odoo.fields.Datetime.to_string(value)
return fields.Datetime.to_string(value)
elif type_ == 'date' and isinstance(value, date):
return odoo.fields.Date.to_string(value)
return fields.Date.to_string(value)
return value
@ -1034,3 +1045,4 @@ class Dotter:
def __getattr__(self, key):
val = self.__values[key]
return Dotter(val) if isinstance(val, dict) else val

View file

@ -3,7 +3,6 @@ import importlib.util
import inspect
import logging
import sys
import threading
from pathlib import Path
from unittest import case
@ -105,18 +104,16 @@ def make_suite(module_names, position='at_install'):
for t in get_module_test_cases(m)
if position_tag.check(t) and config_tags.check(t)
)
return OdooSuite(sorted(tests, key=lambda t: t.test_sequence))
return OdooSuite(sorted(tests, key=lambda t: getattr(t, 'test_sequence', 0)))
def run_suite(suite, global_report=None):
# avoid dependency hell
from ..modules import module
module.current_test = True
threading.current_thread().testing = True
results = OdooTestResult(global_report=global_report)
suite(results)
threading.current_thread().testing = False
module.current_test = False
return results

View file

@ -16,11 +16,11 @@ to minimise the code to maintain
import logging
import sys
import odoo
from . import case
from .common import HttpCase
from .result import stats_logger
from unittest import util, BaseTestSuite, TestCase
from odoo.modules import module
__unittest = True
@ -38,7 +38,7 @@ class TestSuite(BaseTestSuite):
if result.shouldStop:
break
assert isinstance(test, (TestCase))
module.current_test = test
odoo.modules.module.current_test = test
self._tearDownPreviousClass(test, result)
self._handleClassSetUp(test, result)
result._previousTestClass = test.__class__

View file

@ -12,7 +12,8 @@ class TagsSelector(object):
^
([+-]?) # operator_re
(\*|\w*) # tag_re
(?:\/([\w\/]*(?:.py)?))? # module_re
(\/[\w\/\.-]+.py)? # file_re
(?:\/(\w+))? # module_re
(?::(\w*))? # test_class_re
(?:\.(\w*))? # test_method_re
(?:\[(.*)\])? # parameters
@ -32,7 +33,7 @@ class TagsSelector(object):
_logger.error('Invalid tag %s', filter_spec)
continue
sign, tag, module, klass, method, parameters = match.groups()
sign, tag, file_path, module, klass, method, parameters = match.groups()
is_include = sign != '-'
is_exclude = not is_include
@ -42,12 +43,7 @@ class TagsSelector(object):
elif not tag or tag == '*':
# '*' indicates all tests (instead of 'standard' tests only)
tag = None
module_path = None
if module and (module.endswith('.py')):
module_path = module[:-3].replace('/', '.')
module = None
test_filter = (tag, module, klass, method, module_path)
test_filter = (tag, module, klass, method, file_path)
if parameters:
# we could check here that test supports negated parameters
@ -71,19 +67,23 @@ class TagsSelector(object):
return False
test_module = test.test_module
test_class = test.test_class
test_class = test.__class__.__name__
test_tags = test.test_tags | {test_module} # module as test_tags deprecated, keep for retrocompatibility,
test_method = test._testMethodName
test_module_path = test.__module__
for prefix in ('odoo.addons', 'odoo.upgrade'):
test_module_path = test_module_path.removeprefix(prefix)
test_module_path = test_module_path.replace('.', '/') + '.py'
test._test_params = []
def _is_matching(test_filter):
(tag, module, klass, method, module_path) = test_filter
(tag, module, klass, method, file_path) = test_filter
if tag and tag not in test_tags:
return False
elif module_path and not test.__module__.endswith(module_path):
elif file_path and not file_path.endswith(test_module_path):
return False
elif not module_path and module and module != test_module:
elif not file_path and module and module != test_module:
return False
elif klass and klass != test_class:
return False

View file

@ -0,0 +1,133 @@
from __future__ import annotations
from datetime import datetime
import threading
from odoo.sql_db import BaseCursor, Cursor, Savepoint, _logger
import odoo
class TestCursor(BaseCursor):
""" A pseudo-cursor to be used for tests, on top of a real cursor. It keeps
the transaction open across requests, and simulates committing, rolling
back, and closing:
+------------------------+---------------------------------------------------+
| test cursor | queries on actual cursor |
+========================+===================================================+
|``cr = TestCursor(...)``| |
+------------------------+---------------------------------------------------+
| ``cr.execute(query)`` | SAVEPOINT test_cursor_N (if not savepoint) |
| | query |
+------------------------+---------------------------------------------------+
| ``cr.commit()`` | RELEASE SAVEPOINT test_cursor_N (if savepoint) |
+------------------------+---------------------------------------------------+
| ``cr.rollback()`` | ROLLBACK TO SAVEPOINT test_cursor_N (if savepoint)|
+------------------------+---------------------------------------------------+
| ``cr.close()`` | ROLLBACK TO SAVEPOINT test_cursor_N (if savepoint)|
| | RELEASE SAVEPOINT test_cursor_N (if savepoint) |
+------------------------+---------------------------------------------------+
"""
_cursors_stack: list[TestCursor] = []
def __init__(self, cursor: Cursor, lock: threading.RLock, readonly: bool):
assert isinstance(cursor, BaseCursor)
super().__init__()
self._now: datetime | None = None
self._closed: bool = False
self._cursor = cursor
self.readonly = readonly
# we use a lock to serialize concurrent requests
self._lock = lock
current_test = odoo.modules.module.current_test
assert current_test, 'Test Cursor without active test ?'
current_test.assertCanOpenTestCursor()
lock_timeout = current_test.test_cursor_lock_timeout
if not self._lock.acquire(timeout=lock_timeout):
raise Exception(f'Unable to acquire lock for test cursor after {lock_timeout}s')
try:
# Check after acquiring in case current_test has changed.
# This can happen if the request was hanging between two tests.
current_test.assertCanOpenTestCursor()
self._check_cursor_readonly()
except Exception:
self._lock.release()
raise
self._cursors_stack.append(self)
# in order to simulate commit and rollback, the cursor maintains a
# savepoint at its last commit, the savepoint is created lazily
self._savepoint: Savepoint | None = None
def _check_cursor_readonly(self):
last_cursor = self._cursors_stack and self._cursors_stack[-1]
if last_cursor and last_cursor.readonly and not self.readonly and last_cursor._savepoint:
raise Exception('Opening a read/write test cursor from a readonly one')
def _check_savepoint(self) -> None:
if not self._savepoint:
# we use self._cursor._obj for the savepoint to avoid having the
# savepoint queries in the query counts, profiler, ...
# Those queries are tests artefacts and should be invisible.
self._savepoint = Savepoint(self._cursor._obj)
if self.readonly:
# this will simulate a readonly connection
self._cursor._obj.execute('SET TRANSACTION READ ONLY') # use _obj to avoid impacting query count and profiler.
def execute(self, *args, **kwargs) -> None:
assert not self._closed, "Cannot use a closed cursor"
self._check_savepoint()
return self._cursor.execute(*args, **kwargs)
def close(self) -> None:
if not self._closed:
try:
self.rollback()
if self._savepoint:
self._savepoint.close(rollback=False)
finally:
self._closed = True
tos = self._cursors_stack.pop()
if tos is not self:
_logger.warning("Found different un-closed cursor when trying to close %s: %s", self, tos)
self._lock.release()
def commit(self) -> None:
""" Perform an SQL `COMMIT` """
self.flush()
if self._savepoint:
self._savepoint.close(rollback=self.readonly)
self._savepoint = None
self.clear()
self.prerollback.clear()
self.postrollback.clear()
self.postcommit.clear() # TestCursor ignores post-commit hooks by default
def rollback(self) -> None:
""" Perform an SQL `ROLLBACK` """
self.clear()
self.postcommit.clear()
self.prerollback.run()
if self._savepoint:
self._savepoint.close(rollback=True)
self._savepoint = None
self.postrollback.run()
def __getattr__(self, name):
return getattr(self._cursor, name)
def dictfetchone(self):
""" Return the first row as a dict (column_name -> value) or None if no rows are available. """
return self._cursor.dictfetchone()
def dictfetchmany(self, size):
return self._cursor.dictfetchmany(size)
def dictfetchall(self):
return self._cursor.dictfetchall()
def now(self) -> datetime:
""" Return the transaction's timestamp ``datetime.now()``. """
if self._now is None:
self._now = datetime.now()
return self._now

View file

@ -1,5 +1,6 @@
#!/usr/bin/env python3
import argparse
import contextlib
import logging.config
import os
import sys
@ -8,8 +9,8 @@ import time
sys.path.append(os.path.abspath(os.path.join(__file__,'../../../')))
import odoo
from odoo.tools import config, topological_sort, unique
from odoo import api
from odoo.tools import config, topological_sort, unique, profiler
from odoo.modules.registry import Registry
from odoo.netsvc import init_logger
from odoo.tests import standalone_tests
@ -30,7 +31,7 @@ INSTALL_BLACKLIST = {
def install(db_name, module_id, module_name):
with Registry(db_name).cursor() as cr:
env = odoo.api.Environment(cr, odoo.SUPERUSER_ID, {})
env = api.Environment(cr, api.SUPERUSER_ID, {})
module = env['ir.module.module'].browse(module_id)
module.button_immediate_install()
_logger.info('%s installed', module_name)
@ -38,7 +39,7 @@ def install(db_name, module_id, module_name):
def uninstall(db_name, module_id, module_name):
with Registry(db_name).cursor() as cr:
env = odoo.api.Environment(cr, odoo.SUPERUSER_ID, {})
env = api.Environment(cr, api.SUPERUSER_ID, {})
module = env['ir.module.module'].browse(module_id)
module.button_immediate_uninstall()
_logger.info('%s uninstalled', module_name)
@ -50,10 +51,8 @@ def cycle(db_name, module_id, module_name):
install(db_name, module_id, module_name)
class CheckAddons(argparse.Action):
def __call__(self, parser, namespace, values, option_string=None):
self.values = namespace
config._check_addons_path(self, option_string, values, self)
def addons_path(value):
return config._check_addons_path(config.options_index['init'], '-i', value)
def parse_args():
@ -77,7 +76,7 @@ def parse_args():
help="Comma-separated list of modules to skip (they will only be installed)")
parser.add_argument("--resume-at", "-r", type=str,
help="Skip modules (only install) up to the specified one in topological order")
parser.add_argument("--addons-path", "-p", type=str, action=CheckAddons,
parser.add_argument("--addons-path", "-p", type=addons_path,
help="Comma-separated list of paths to directories containing extra Odoo modules")
cmds = parser.add_subparsers(title="subcommands", metavar='')
@ -127,7 +126,7 @@ class StandaloneAction(argparse.Action):
def test_cycle(args):
""" Test full install/uninstall/reinstall cycle for all modules """
with Registry(args.database).cursor() as cr:
env = odoo.api.Environment(cr, odoo.SUPERUSER_ID, {})
env = odoo.api.Environment(cr, odoo.api.SUPERUSER_ID, {})
def valid(module):
return not (
@ -162,7 +161,7 @@ def test_uninstall(args):
""" Tries to uninstall/reinstall one ore more modules"""
for module_name in args.uninstall.split(','):
with Registry(args.database).cursor() as cr:
env = odoo.api.Environment(cr, odoo.SUPERUSER_ID, {})
env = odoo.api.Environment(cr, odoo.api.SUPERUSER_ID, {})
module = env['ir.module.module'].search([('name', '=', module_name)])
module_id, module_state = module.id, module.state
@ -195,7 +194,7 @@ def test_standalone(args):
start_time = time.time()
for index, func in enumerate(funcs, start=1):
with Registry(args.database).cursor() as cr:
env = odoo.api.Environment(cr, odoo.SUPERUSER_ID, {})
env = odoo.api.Environment(cr, odoo.api.SUPERUSER_ID, {})
_logger.info("Executing standalone script: %s (%d / %d)",
func.__name__, index, len(funcs))
try:
@ -212,9 +211,9 @@ if __name__ == '__main__':
config['db_name'] = threading.current_thread().dbname = args.database
# handle paths option
if args.addons_path:
odoo.tools.config['addons_path'] = ','.join([args.addons_path, odoo.tools.config['addons_path']])
config['addons_path'] = args.addons_path + config['addons_path']
if args.data_dir:
odoo.tools.config['data_dir'] = args.data_dir
config['data_dir'] = args.data_dir
odoo.modules.module.initialize_sys_path()
init_logger()
@ -230,8 +229,16 @@ if __name__ == '__main__':
}
})
prof = contextlib.nullcontext()
if os.environ.get('ODOO_PROFILE_PRELOAD'):
interval = float(os.environ.get('ODOO_PROFILE_PRELOAD_INTERVAL', '0.1'))
collectors = [profiler.PeriodicCollector(interval=interval)]
if os.environ.get('ODOO_PROFILE_PRELOAD_SQL'):
collectors.append('sql')
prof = profiler.Profiler(db=args.database, collectors=collectors)
try:
args.func(args)
with prof:
args.func(args)
except Exception:
_logger.error("%s tests failed", args.func.__name__[5:])
raise