17.0 vanilla

This commit is contained in:
Ernad Husremovic 2025-10-03 18:05:14 +02:00
parent 2e65bf056a
commit df627a6bba
328 changed files with 578149 additions and 759311 deletions

View file

@ -10,6 +10,7 @@ from . import test_cache
from . import test_date_utils
from . import test_deprecation
from . import test_db_cursor
from . import test_display_name
from . import test_expression
from . import test_float
from . import test_format_address_mixin
@ -23,6 +24,7 @@ from . import test_ir_cron
from . import test_ir_filters
from . import test_ir_http
from . import test_ir_mail_server
from . import test_ir_mail_server_smtpd
from . import test_ir_model
from . import test_ir_module
from . import test_ir_sequence
@ -36,11 +38,14 @@ from . import test_module
from . import test_orm
from . import test_ormcache
from . import test_osv
from . import test_overrides
from . import test_qweb_field
from . import test_qweb
from . import test_res_config
from . import test_res_lang
from . import test_search
from . import test_split_table
from . import test_sql
from . import test_translate
from . import test_tz
# from . import test_uninstall # loop
@ -57,6 +62,7 @@ from . import test_reports
from . import test_test_retry
from . import test_test_suite
from . import test_tests_tags
from . import test_transactions
from . import test_form_create
from . import test_cloc
from . import test_profiler

View file

@ -1,8 +1,10 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import threading
from contextlib import contextmanager
from unittest.mock import patch
from unittest.mock import patch, Mock
from odoo.tests.common import TransactionCase, HttpCase
from odoo import Command
@ -22,9 +24,6 @@ class BaseCommon(TransactionCase):
def setUpClass(cls):
super().setUpClass()
# Enforce the use of USD as main currency unless modified in inherited class(es)
cls._use_currency('USD')
# Mail logic won't be tested by default in other modules.
# Mail API overrides should be tested with dedicated tests on purpose
# Hack to use with_context and avoid manual context dict modification
@ -33,6 +32,15 @@ class BaseCommon(TransactionCase):
cls.partner = cls.env['res.partner'].create({
'name': 'Test Partner',
})
cls.currency = cls.env.company.currency_id
@classmethod
def _enable_currency(cls, currency_code):
currency = cls.env['res.currency'].with_context(active_test=False).search(
[('name', '=', currency_code.upper())]
)
currency.action_unarchive()
return currency
@classmethod
def _use_currency(cls, currency_code):
@ -45,15 +53,6 @@ class BaseCommon(TransactionCase):
# the value will be written to the database on next flush.
# this was needed because some journal entries may exist when running tests, especially l10n demo data.
@classmethod
def _enable_currency(cls, currency_code):
currency = cls.env['res.currency'].with_context(active_test=False).search(
[('name', '=', currency_code.upper())]
)
currency.action_unarchive()
return currency
class BaseUsersCommon(BaseCommon):
@classmethod
@ -92,7 +91,6 @@ class TransactionCaseWithUserDemo(TransactionCase):
if not cls.user_demo:
cls.env['ir.config_parameter'].sudo().set_param('auth_password_policy.minlength', 4)
# YTI TODO: This could be factorized between the different classes
cls.partner_demo = cls.env['res.partner'].create({
'name': 'Marc Demo',
'email': 'mark.brown23@example.com',
@ -356,32 +354,57 @@ class MockSmtplibCase:
self.testing_smtp_session = TestingSMTPSession()
IrMailServer = self.env['ir.mail_server']
connect_origin = IrMailServer.connect
find_mail_server_origin = IrMailServer._find_mail_server
connect_origin = type(IrMailServer).connect
find_mail_server_origin = type(IrMailServer)._find_mail_server
# custom mock to avoid losing context
def mock_function(func):
mock = Mock()
def _call(*args, **kwargs):
mock(*args[1:], **kwargs)
return func(*args, **kwargs)
_call.mock = mock
return _call
with patch('smtplib.SMTP_SSL', side_effect=lambda *args, **kwargs: self.testing_smtp_session), \
patch('smtplib.SMTP', side_effect=lambda *args, **kwargs: self.testing_smtp_session), \
patch.object(type(IrMailServer), '_is_test_mode', lambda self: False), \
patch.object(type(IrMailServer), 'connect', wraps=IrMailServer, side_effect=connect_origin) as connect_mocked, \
patch.object(type(IrMailServer), '_find_mail_server', side_effect=find_mail_server_origin) as find_mail_server_mocked:
self.connect_mocked = connect_mocked
self.find_mail_server_mocked = find_mail_server_mocked
patch.object(type(IrMailServer), 'connect', mock_function(connect_origin)) as connect_mocked, \
patch.object(type(IrMailServer), '_find_mail_server', mock_function(find_mail_server_origin)) as find_mail_server_mocked:
self.connect_mocked = connect_mocked.mock
self.find_mail_server_mocked = find_mail_server_mocked.mock
yield
def assert_email_sent_smtp(self, smtp_from=None, smtp_to_list=None, message_from=None,
mail_server=None, from_filter=None,
emails_count=1):
"""Check that the given email has been sent.
def _build_email(self, mail_from, return_path=None, **kwargs):
return self.env['ir.mail_server'].build_email(
mail_from,
kwargs.pop('email_to', 'dest@example-é.com'),
kwargs.pop('subject', 'subject'),
kwargs.pop('body', 'body'),
headers={'Return-Path': return_path} if return_path else None,
**kwargs,
)
If one of the parameter is None, it's just ignored and not used to retrieve the email.
def _send_email(self, msg, smtp_session):
with patch.object(threading.current_thread(), 'testing', False):
self.env['ir.mail_server'].send_email(msg, smtp_session=smtp_session)
return smtp_session.messages.pop()
def assertSMTPEmailsSent(self, smtp_from=None, smtp_to_list=None, message_from=None,
mail_server=None, from_filter=None,
emails_count=1):
"""Check that the given email has been sent. If one of the parameter is
None it is just ignored and not used to retrieve the email.
:param smtp_from: FROM used for the authentication to the mail server
:param smtp_to_list: List of destination email address
:param message_from: FROM used in the SMTP headers
:param mail_server: used to compare the 'from_filter' as an alternative
to using the from_filter parameter
:param from_filter: from_filter of the <ir.mail_server> used to send the email
Can use a lambda to check the value
:param from_filter: from_filter of the <ir.mail_server> used to send the
email. False means 'match everything';'
:param emails_count: the number of emails which should match the condition
:return: True if at least one email has been found with those parameters
"""
@ -391,11 +414,7 @@ class MockSmtplibCase:
from_filter = mail_server.from_filter
matching_emails = filter(
lambda email:
(smtp_from is None or (
smtp_from(email['smtp_from'])
if callable(smtp_from)
else smtp_from == email['smtp_from'])
)
(smtp_from is None or smtp_from == email['smtp_from'])
and (smtp_to_list is None or smtp_to_list == email['smtp_to_list'])
and (message_from is None or 'From: %s' % message_from in email['message'])
and (from_filter is None or from_filter == email['from_filter']),
@ -423,13 +442,9 @@ class MockSmtplibCase:
)
@classmethod
def _init_mail_config(cls):
cls.alias_bounce = 'bounce.test'
cls.alias_domain = 'test.com'
cls.default_from = 'notifications'
cls.env['ir.config_parameter'].sudo().set_param('mail.catchall.domain', cls.alias_domain)
cls.env['ir.config_parameter'].sudo().set_param('mail.default.from', cls.default_from)
cls.env['ir.config_parameter'].sudo().set_param('mail.bounce.alias', cls.alias_bounce)
def _init_mail_gateway(cls):
cls.default_from_filter = False
cls.env['ir.config_parameter'].sudo().set_param('mail.default.from_filter', cls.default_from_filter)
@classmethod
def _init_mail_servers(cls):
@ -439,27 +454,30 @@ class MockSmtplibCase:
'smtp_host': 'smtp_host',
'smtp_encryption': 'none',
}
(
cls.server_domain,
cls.server_user,
cls.server_notification,
cls.server_default,
) = cls.env['ir.mail_server'].create([
cls.mail_servers = cls.env['ir.mail_server'].create([
{
'name': 'Domain based server',
'from_filter': 'test.com',
'from_filter': 'test.mycompany.com',
'sequence': 0,
** ir_mail_server_values,
}, {
'name': 'User specific server',
'from_filter': 'specific_user@test.com',
'from_filter': 'specific_user@test.mycompany.com',
'sequence': 1,
** ir_mail_server_values,
}, {
'name': 'Server Notifications',
'from_filter': 'notifications@test.com',
'from_filter': 'notifications.test@test.mycompany.com',
'sequence': 2,
** ir_mail_server_values,
}, {
'name': 'Server No From Filter',
'from_filter': False,
'sequence': 3,
** ir_mail_server_values,
},
])
(
cls.mail_server_domain, cls.mail_server_user,
cls.mail_server_notification, cls.mail_server_default
) = cls.mail_servers

View file

@ -104,6 +104,10 @@ class TestACL(TransactionCaseWithUserDemo):
# Now restrict access to the field and check it's forbidden
self._set_field_groups(partner, 'bank_ids', GROUP_SYSTEM)
with self.assertRaises(AccessError):
partner.search_fetch([], ['bank_ids'])
with self.assertRaises(AccessError):
partner.fetch(['bank_ids'])
with self.assertRaises(AccessError):
partner.read(['bank_ids'])
with self.assertRaises(AccessError):
@ -144,7 +148,7 @@ class TestACL(TransactionCaseWithUserDemo):
# demo not part of the group_system, create edit and delete must be False
for method in methods:
self.assertEqual(view_arch.get(method), 'false')
self.assertEqual(view_arch.get(method), 'False')
# demo part of the group_system, create edit and delete must not be specified
company = self.env['res.company'].with_user(self.env.ref("base.user_admin"))
@ -164,14 +168,14 @@ class TestACL(TransactionCaseWithUserDemo):
field_node = view_arch.xpath("//field[@name='currency_id']")
self.assertTrue(len(field_node), "currency_id field should be in company from view")
for method in methods:
self.assertEqual(field_node[0].get('can_' + method), 'false')
self.assertEqual(field_node[0].get('can_' + method), 'False')
company = self.env['res.company'].with_user(self.env.ref("base.user_admin"))
company_view = company.get_view(False, 'form')
view_arch = etree.fromstring(company_view['arch'])
field_node = view_arch.xpath("//field[@name='currency_id']")
for method in methods:
self.assertEqual(field_node[0].get('can_' + method), 'true')
self.assertEqual(field_node[0].get('can_' + method), 'True')
def test_get_views_fields(self):
""" Tests fields restricted to group_system are not passed when calling `get_views` as demo

View file

@ -70,10 +70,10 @@ class TestAPI(SavepointCaseWithUserDemo):
@mute_logger('odoo.models')
def test_04_query_count(self):
""" Test the search method with count=True. """
""" Test the search_count method. """
self.cr.execute("SELECT COUNT(*) FROM res_partner WHERE active")
count1 = self.cr.fetchone()[0]
count2 = self.env['res.partner'].search([], count=True)
count2 = self.env['res.partner'].search_count([])
self.assertIsInstance(count1, int)
self.assertIsInstance(count2, int)
self.assertEqual(count1, count2)
@ -702,6 +702,34 @@ class TestAPI(SavepointCaseWithUserDemo):
for partner in partners_with_children:
partner.child_ids.sorted('id').mapped('name')
def test_group_on(self):
p0, p1, p2 = self.env['res.partner'].create([
{'name': "bob", 'function': "guest"},
{'name': "james", 'function': "host"},
{'name': "rhod", 'function': "guest"}
])
pn = self.env['res.partner'].new({'name': 'alex', 'function': "host"})
with self.subTest("Should work with mixes of db and new records"):
self.assertEqual(
(p0 | p1 | p2 | pn).grouped('function'),
{'guest': p0 | p2, 'host': p1 | pn}
)
self.assertEqual(
(p0 | p1 | p2 | pn).grouped(lambda r: len(r.name)),
{3: p0, 4: p2 | pn, 5: p1},
)
with self.subTest("Should allow cross-group prefetching"):
byfn = (p0 | p1 | p2).grouped('function')
self.env.invalidate_all(flush=False)
self.assertFalse(self.env.cache._data, "ensure the cache is empty")
self.assertEqual(byfn['guest'].mapped('name'), ['bob', 'rhod'])
# name should have been prefetched by previous statement (on guest
# group), so should be nothing here
with self.assertQueries([]):
_ = byfn['host'].name
class TestExternalAPI(SavepointCaseWithUserDemo):

View file

@ -10,16 +10,17 @@ class TestAvatarMixin(TransactionCase):
""" tests the avatar mixin """
def setUp(self):
super().setUp()
# Set partner manually to fake seed create_date
partner_without_image = self.env['res.partner'].create({'name': 'Marc Demo', 'create_date': '2015-11-12 00:00:00'})
self.user_without_image = self.env['res.users'].create({
'name': 'Marc Demo',
'email': 'mark.brown23@example.com',
'image_1920': False,
'create_date': '2015-11-12 00:00:00',
'login': 'demo_1',
'password': 'demo_1'
'password': 'demo_1',
'partner_id': partner_without_image.id,
})
self.user_without_image.partner_id.create_date = '2015-11-12 00:00:00'
self.user_without_name = self.env['res.users'].create({
'name': '',
'email': 'marc.grey25@example.com',

View file

@ -5,9 +5,7 @@ import ast
from textwrap import dedent
from odoo import SUPERUSER_ID, Command
from odoo.exceptions import RedirectWarning, UserError, ValidationError
from odoo.tests import tagged
from odoo import Command
from odoo.tests.common import TransactionCase, BaseCase
from odoo.tools import mute_logger
from odoo.tools.safe_eval import safe_eval, const_eval, expr_eval
@ -97,654 +95,6 @@ class TestSafeEval(BaseCase):
safe_eval("self.__name__", {'self': self}, mode="exec")
# samples use effective TLDs from the Mozilla public suffix
# list at http://publicsuffix.org
SAMPLES = [
('"Raoul Grosbedon" <raoul@chirurgiens-dentistes.fr> ', 'Raoul Grosbedon', 'raoul@chirurgiens-dentistes.fr'),
('ryu+giga-Sushi@aizubange.fukushima.jp', '', 'ryu+giga-Sushi@aizubange.fukushima.jp'),
('Raoul chirurgiens-dentistes.fr', 'Raoul chirurgiens-dentistes.fr', ''),
(" Raoul O'hara <!@historicalsociety.museum>", "Raoul O'hara", '!@historicalsociety.museum'),
('Raoul Grosbedon <raoul@CHIRURGIENS-dentistes.fr> ', 'Raoul Grosbedon', 'raoul@CHIRURGIENS-dentistes.fr'),
('Raoul megaraoul@chirurgiens-dentistes.fr', 'Raoul', 'megaraoul@chirurgiens-dentistes.fr'),
]
@tagged('res_partner')
class TestBase(TransactionCaseWithUserDemo):
def _check_find_or_create(self, test_string, expected_name, expected_email, check_partner=False, should_create=False):
partner = self.env['res.partner'].find_or_create(test_string)
if should_create and check_partner:
self.assertTrue(partner.id > check_partner.id, 'find_or_create failed - should have found existing')
elif check_partner:
self.assertEqual(partner, check_partner, 'find_or_create failed - should have found existing')
self.assertEqual(partner.name, expected_name)
self.assertEqual(partner.email or '', expected_email)
return partner
def test_00_res_partner_name_create(self):
res_partner = self.env['res.partner']
parse = res_partner._parse_partner_name
for text, expected_name, expected_mail in SAMPLES:
with self.subTest(text=text):
self.assertEqual((expected_name, expected_mail.lower()), parse(text))
partner_id, dummy = res_partner.name_create(text)
partner = res_partner.browse(partner_id)
self.assertEqual(expected_name or expected_mail.lower(), partner.name)
self.assertEqual(expected_mail.lower() or False, partner.email)
# name_create supports default_email fallback
partner = self.env['res.partner'].browse(
self.env['res.partner'].with_context(
default_email='John.Wick@example.com'
).name_create('"Raoulette Vachette" <Raoul@Grosbedon.fr>')[0]
)
self.assertEqual(partner.name, 'Raoulette Vachette')
self.assertEqual(partner.email, 'raoul@grosbedon.fr')
partner = self.env['res.partner'].browse(
self.env['res.partner'].with_context(
default_email='John.Wick@example.com'
).name_create('Raoulette Vachette')[0]
)
self.assertEqual(partner.name, 'Raoulette Vachette')
self.assertEqual(partner.email, 'John.Wick@example.com')
def test_10_res_partner_find_or_create(self):
res_partner = self.env['res.partner']
partner = res_partner.browse(res_partner.name_create(SAMPLES[0][0])[0])
self._check_find_or_create(
SAMPLES[0][0], SAMPLES[0][1], SAMPLES[0][2],
check_partner=partner, should_create=False
)
partner_2 = res_partner.browse(res_partner.name_create('sarah.john@connor.com')[0])
found_2 = self._check_find_or_create(
'john@connor.com', 'john@connor.com', 'john@connor.com',
check_partner=partner_2, should_create=True
)
new = self._check_find_or_create(
SAMPLES[1][0], SAMPLES[1][2].lower(), SAMPLES[1][2].lower(),
check_partner=found_2, should_create=True
)
new2 = self._check_find_or_create(
SAMPLES[2][0], SAMPLES[2][1], SAMPLES[2][2],
check_partner=new, should_create=True
)
new3 = self._check_find_or_create(
SAMPLES[3][0], SAMPLES[3][1], SAMPLES[3][2],
check_partner=new2, should_create=True
)
new4 = self._check_find_or_create(
SAMPLES[4][0], SAMPLES[0][1], SAMPLES[0][2],
check_partner=partner, should_create=False
)
new5 = self._check_find_or_create(
SAMPLES[5][0], SAMPLES[5][1], SAMPLES[5][2],
check_partner=new4, should_create=True
)
def test_15_res_partner_name_search(self):
res_partner = self.env['res.partner']
DATA = [
('"A Raoul Grosbedon" <raoul@chirurgiens-dentistes.fr>', False),
('B Raoul chirurgiens-dentistes.fr', True),
("C Raoul O'hara <!@historicalsociety.museum>", True),
('ryu+giga-Sushi@aizubange.fukushima.jp', True),
]
for name, active in DATA:
partner_id, dummy = res_partner.with_context(default_active=active).name_create(name)
partners = res_partner.name_search('Raoul')
self.assertEqual(len(partners), 2, 'Incorrect search number result for name_search')
partners = res_partner.name_search('Raoul', limit=1)
self.assertEqual(len(partners), 1, 'Incorrect search number result for name_search with a limit')
self.assertEqual(partners[0][1], 'B Raoul chirurgiens-dentistes.fr', 'Incorrect partner returned, should be the first active')
def test_20_res_partner_address_sync(self):
res_partner = self.env['res.partner']
ghoststep = res_partner.create({
'name': 'GhostStep',
'is_company': True,
'street': 'Main Street, 10',
'phone': '123456789',
'email': 'info@ghoststep.com',
'vat': 'BE0477472701',
'type': 'contact',
})
p1 = res_partner.browse(res_partner.name_create('Denis Bladesmith <denis.bladesmith@ghoststep.com>')[0])
self.assertEqual(p1.type, 'contact', 'Default type must be "contact"')
p1phone = '123456789#34'
p1.write({'phone': p1phone,
'parent_id': ghoststep.id})
self.assertEqual(p1.street, ghoststep.street, 'Address fields must be synced')
self.assertEqual(p1.phone, p1phone, 'Phone should be preserved after address sync')
self.assertEqual(p1.type, 'contact', 'Type should be preserved after address sync')
self.assertEqual(p1.email, 'denis.bladesmith@ghoststep.com', 'Email should be preserved after sync')
# turn off sync
p1street = 'Different street, 42'
p1.write({'street': p1street,
'type': 'invoice'})
self.assertEqual(p1.street, p1street, 'Address fields must not be synced after turning sync off')
self.assertNotEqual(ghoststep.street, p1street, 'Parent address must never be touched')
# turn on sync again
p1.write({'type': 'contact'})
self.assertEqual(p1.street, ghoststep.street, 'Address fields must be synced again')
self.assertEqual(p1.phone, p1phone, 'Phone should be preserved after address sync')
self.assertEqual(p1.type, 'contact', 'Type should be preserved after address sync')
self.assertEqual(p1.email, 'denis.bladesmith@ghoststep.com', 'Email should be preserved after sync')
# Modify parent, sync to children
ghoststreet = 'South Street, 25'
ghoststep.write({'street': ghoststreet})
self.assertEqual(p1.street, ghoststreet, 'Address fields must be synced automatically')
self.assertEqual(p1.phone, p1phone, 'Phone should not be synced')
self.assertEqual(p1.email, 'denis.bladesmith@ghoststep.com', 'Email should be preserved after sync')
p1street = 'My Street, 11'
p1.write({'street': p1street})
self.assertEqual(ghoststep.street, ghoststreet, 'Touching contact should never alter parent')
def test_30_res_partner_first_contact_sync(self):
""" Test initial creation of company/contact pair where contact address gets copied to
company """
res_partner = self.env['res.partner']
ironshield = res_partner.browse(res_partner.name_create('IronShield')[0])
self.assertFalse(ironshield.is_company, 'Partners are not companies by default')
self.assertEqual(ironshield.type, 'contact', 'Default type must be "contact"')
ironshield.write({'type': 'contact'})
p1 = res_partner.create({
'name': 'Isen Hardearth',
'street': 'Strongarm Avenue, 12',
'parent_id': ironshield.id,
})
self.assertEqual(p1.type, 'contact', 'Default type must be "contact", not the copied parent type')
self.assertEqual(ironshield.street, p1.street, 'Address fields should be copied to company')
def test_40_res_partner_address_get(self):
""" Test address_get address resolution mechanism: it should first go down through descendants,
stopping when encountering another is_copmany entity, then go up, stopping again at the first
is_company entity or the root ancestor and if nothing matches, it should use the provided partner
itself """
res_partner = self.env['res.partner']
elmtree = res_partner.browse(res_partner.name_create('Elmtree')[0])
branch1 = res_partner.create({'name': 'Branch 1',
'parent_id': elmtree.id,
'is_company': True})
leaf10 = res_partner.create({'name': 'Leaf 10',
'parent_id': branch1.id,
'type': 'invoice'})
branch11 = res_partner.create({'name': 'Branch 11',
'parent_id': branch1.id,
'type': 'other'})
leaf111 = res_partner.create({'name': 'Leaf 111',
'parent_id': branch11.id,
'type': 'delivery'})
branch11.write({'is_company': False}) # force is_company after creating 1rst child
branch2 = res_partner.create({'name': 'Branch 2',
'parent_id': elmtree.id,
'is_company': True})
leaf21 = res_partner.create({'name': 'Leaf 21',
'parent_id': branch2.id,
'type': 'delivery'})
leaf22 = res_partner.create({'name': 'Leaf 22',
'parent_id': branch2.id})
leaf23 = res_partner.create({'name': 'Leaf 23',
'parent_id': branch2.id,
'type': 'contact'})
# go up, stop at branch1
self.assertEqual(leaf111.address_get(['delivery', 'invoice', 'contact', 'other']),
{'delivery': leaf111.id,
'invoice': leaf10.id,
'contact': branch1.id,
'other': branch11.id}, 'Invalid address resolution')
self.assertEqual(branch11.address_get(['delivery', 'invoice', 'contact', 'other']),
{'delivery': leaf111.id,
'invoice': leaf10.id,
'contact': branch1.id,
'other': branch11.id}, 'Invalid address resolution')
# go down, stop at at all child companies
self.assertEqual(elmtree.address_get(['delivery', 'invoice', 'contact', 'other']),
{'delivery': elmtree.id,
'invoice': elmtree.id,
'contact': elmtree.id,
'other': elmtree.id}, 'Invalid address resolution')
# go down through children
self.assertEqual(branch1.address_get(['delivery', 'invoice', 'contact', 'other']),
{'delivery': leaf111.id,
'invoice': leaf10.id,
'contact': branch1.id,
'other': branch11.id}, 'Invalid address resolution')
self.assertEqual(branch2.address_get(['delivery', 'invoice', 'contact', 'other']),
{'delivery': leaf21.id,
'invoice': branch2.id,
'contact': branch2.id,
'other': branch2.id}, 'Invalid address resolution. Company is the first encountered contact, therefore default for unfound addresses.')
# go up then down through siblings
self.assertEqual(leaf21.address_get(['delivery', 'invoice', 'contact', 'other']),
{'delivery': leaf21.id,
'invoice': branch2.id,
'contact': branch2.id,
'other': branch2.id}, 'Invalid address resolution, should scan commercial entity ancestor and its descendants')
self.assertEqual(leaf22.address_get(['delivery', 'invoice', 'contact', 'other']),
{'delivery': leaf21.id,
'invoice': leaf22.id,
'contact': leaf22.id,
'other': leaf22.id}, 'Invalid address resolution, should scan commercial entity ancestor and its descendants')
self.assertEqual(leaf23.address_get(['delivery', 'invoice', 'contact', 'other']),
{'delivery': leaf21.id,
'invoice': leaf23.id,
'contact': leaf23.id,
'other': leaf23.id}, 'Invalid address resolution, `default` should only override if no partner with specific type exists')
# empty adr_pref means only 'contact'
self.assertEqual(elmtree.address_get([]),
{'contact': elmtree.id}, 'Invalid address resolution, no contact means commercial entity ancestor')
self.assertEqual(leaf111.address_get([]),
{'contact': branch1.id}, 'Invalid address resolution, no contact means finding contact in ancestors')
branch11.write({'type': 'contact'})
self.assertEqual(leaf111.address_get([]),
{'contact': branch11.id}, 'Invalid address resolution, branch11 should now be contact')
def test_commercial_partner_nullcompany(self):
""" The commercial partner is the first/nearest ancestor-or-self which
is a company or doesn't have a parent
"""
P = self.env['res.partner']
p0 = P.create({'name': '0', 'email': '0'})
self.assertEqual(p0.commercial_partner_id, p0, "partner without a parent is their own commercial partner")
p1 = P.create({'name': '1', 'email': '1', 'parent_id': p0.id})
self.assertEqual(p1.commercial_partner_id, p0, "partner's parent is their commercial partner")
p12 = P.create({'name': '12', 'email': '12', 'parent_id': p1.id})
self.assertEqual(p12.commercial_partner_id, p0, "partner's GP is their commercial partner")
p2 = P.create({'name': '2', 'email': '2', 'parent_id': p0.id, 'is_company': True})
self.assertEqual(p2.commercial_partner_id, p2, "partner flagged as company is their own commercial partner")
p21 = P.create({'name': '21', 'email': '21', 'parent_id': p2.id})
self.assertEqual(p21.commercial_partner_id, p2, "commercial partner is closest ancestor with themselves as commercial partner")
p3 = P.create({'name': '3', 'email': '3', 'is_company': True})
self.assertEqual(p3.commercial_partner_id, p3, "being both parent-less and company should be the same as either")
notcompanies = p0 | p1 | p12 | p21
self.env.cr.execute('update res_partner set is_company=null where id = any(%s)', [notcompanies.ids])
for parent in notcompanies:
p = P.create({
'name': parent.name + '_sub',
'email': parent.email + '_sub',
'parent_id': parent.id,
})
self.assertEqual(
p.commercial_partner_id,
parent.commercial_partner_id,
"check that is_company=null is properly handled when looking for ancestor"
)
def test_50_res_partner_commercial_sync(self):
res_partner = self.env['res.partner']
p0 = res_partner.create({'name': 'Sigurd Sunknife',
'email': 'ssunknife@gmail.com'})
sunhelm = res_partner.create({'name': 'Sunhelm',
'is_company': True,
'street': 'Rainbow Street, 13',
'phone': '1122334455',
'email': 'info@sunhelm.com',
'vat': 'BE0477472701',
'child_ids': [Command.link(p0.id),
Command.create({'name': 'Alrik Greenthorn',
'email': 'agr@sunhelm.com'})]})
p1 = res_partner.create({'name': 'Otto Blackwood',
'email': 'otto.blackwood@sunhelm.com',
'parent_id': sunhelm.id})
p11 = res_partner.create({'name': 'Gini Graywool',
'email': 'ggr@sunhelm.com',
'parent_id': p1.id})
p2 = res_partner.search([('email', '=', 'agr@sunhelm.com')], limit=1)
sunhelm.write({'child_ids': [Command.create({'name': 'Ulrik Greenthorn',
'email': 'ugr@sunhelm.com'})]})
p3 = res_partner.search([('email', '=', 'ugr@sunhelm.com')], limit=1)
for p in (p0, p1, p11, p2, p3):
self.assertEqual(p.commercial_partner_id, sunhelm, 'Incorrect commercial entity resolution')
self.assertEqual(p.vat, sunhelm.vat, 'Commercial fields must be automatically synced')
sunhelmvat = 'BE0123456749'
sunhelm.write({'vat': sunhelmvat})
for p in (p0, p1, p11, p2, p3):
self.assertEqual(p.vat, sunhelmvat, 'Commercial fields must be automatically and recursively synced')
p1vat = 'BE0987654394'
p1.write({'vat': p1vat})
for p in (sunhelm, p0, p11, p2, p3):
self.assertEqual(p.vat, sunhelmvat, 'Sync to children should only work downstream and on commercial entities')
# promote p1 to commercial entity
p1.write({'parent_id': sunhelm.id,
'is_company': True,
'name': 'Sunhelm Subsidiary'})
self.assertEqual(p1.vat, p1vat, 'Setting is_company should stop auto-sync of commercial fields')
self.assertEqual(p1.commercial_partner_id, p1, 'Incorrect commercial entity resolution after setting is_company')
# writing on parent should not touch child commercial entities
sunhelmvat2 = 'BE0112233453'
sunhelm.write({'vat': sunhelmvat2})
self.assertEqual(p1.vat, p1vat, 'Setting is_company should stop auto-sync of commercial fields')
self.assertEqual(p0.vat, sunhelmvat2, 'Commercial fields must be automatically synced')
def test_60_read_group(self):
title_sir = self.env['res.partner.title'].create({'name': 'Sir...'})
title_lady = self.env['res.partner.title'].create({'name': 'Lady...'})
user_vals_list = [
{'name': 'Alice', 'login': 'alice', 'color': 1, 'function': 'Friend', 'date': '2015-03-28', 'title': title_lady.id},
{'name': 'Alice', 'login': 'alice2', 'color': 0, 'function': 'Friend', 'date': '2015-01-28', 'title': title_lady.id},
{'name': 'Bob', 'login': 'bob', 'color': 2, 'function': 'Friend', 'date': '2015-03-02', 'title': title_sir.id},
{'name': 'Eve', 'login': 'eve', 'color': 3, 'function': 'Eavesdropper', 'date': '2015-03-20', 'title': title_lady.id},
{'name': 'Nab', 'login': 'nab', 'color': -3, 'function': '5$ Wrench', 'date': '2014-09-10', 'title': title_sir.id},
{'name': 'Nab', 'login': 'nab-she', 'color': 6, 'function': '5$ Wrench', 'date': '2014-01-02', 'title': title_lady.id},
]
res_users = self.env['res.users']
users = res_users.create(user_vals_list)
domain = [('id', 'in', users.ids)]
# group on local char field without domain and without active_test (-> empty WHERE clause)
groups_data = res_users.with_context(active_test=False).read_group([], fields=['login'], groupby=['login'], orderby='login DESC')
self.assertGreater(len(groups_data), 6, "Incorrect number of results when grouping on a field")
# group on local char field with limit
groups_data = res_users.read_group(domain, fields=['login'], groupby=['login'], orderby='login DESC', limit=3, offset=3)
self.assertEqual(len(groups_data), 3, "Incorrect number of results when grouping on a field with limit")
self.assertEqual([g['login'] for g in groups_data], ['bob', 'alice2', 'alice'], 'Result mismatch')
# group on inherited char field, aggregate on int field (second groupby ignored on purpose)
groups_data = res_users.read_group(domain, fields=['name', 'color', 'function'], groupby=['function', 'login'])
self.assertEqual(len(groups_data), 3, "Incorrect number of results when grouping on a field")
self.assertEqual(['5$ Wrench', 'Eavesdropper', 'Friend'], [g['function'] for g in groups_data], 'incorrect read_group order')
for group_data in groups_data:
self.assertIn('color', group_data, "Aggregated data for the column 'color' is not present in read_group return values")
self.assertEqual(group_data['color'], 3, "Incorrect sum for aggregated data for the column 'color'")
# group on inherited char field, reverse order
groups_data = res_users.read_group(domain, fields=['name', 'color'], groupby='name', orderby='name DESC')
self.assertEqual([g['name'] for g in groups_data], ['Nab', 'Eve', 'Bob', 'Alice'], 'Incorrect ordering of the list')
# group on int field, default ordering
groups_data = res_users.read_group(domain, fields=['color'], groupby='color')
self.assertEqual([g['color'] for g in groups_data], [-3, 0, 1, 2, 3, 6], 'Incorrect ordering of the list')
# multi group, second level is int field, should still be summed in first level grouping
groups_data = res_users.read_group(domain, fields=['name', 'color'], groupby=['name', 'color'], orderby='name DESC')
self.assertEqual([g['name'] for g in groups_data], ['Nab', 'Eve', 'Bob', 'Alice'], 'Incorrect ordering of the list')
self.assertEqual([g['color'] for g in groups_data], [3, 3, 2, 1], 'Incorrect ordering of the list')
# group on inherited char field, multiple orders with directions
groups_data = res_users.read_group(domain, fields=['name', 'color'], groupby='name', orderby='color DESC, name')
self.assertEqual(len(groups_data), 4, "Incorrect number of results when grouping on a field")
self.assertEqual([g['name'] for g in groups_data], ['Eve', 'Nab', 'Bob', 'Alice'], 'Incorrect ordering of the list')
self.assertEqual([g['name_count'] for g in groups_data], [1, 2, 1, 2], 'Incorrect number of results')
# group on inherited date column (res_partner.date) -> Year-Month, default ordering
groups_data = res_users.read_group(domain, fields=['function', 'color', 'date'], groupby=['date'])
self.assertEqual(len(groups_data), 4, "Incorrect number of results when grouping on a field")
self.assertEqual([g['date'] for g in groups_data], ['January 2014', 'September 2014', 'January 2015', 'March 2015'], 'Incorrect ordering of the list')
self.assertEqual([g['date_count'] for g in groups_data], [1, 1, 1, 3], 'Incorrect number of results')
# group on inherited date column (res_partner.date) specifying the :year -> Year default ordering
groups_data = res_users.read_group(domain, fields=['function', 'color', 'date'], groupby=['date:year'])
self.assertEqual(len(groups_data), 2, "Incorrect number of results when grouping on a field")
self.assertEqual([g['date:year'] for g in groups_data], ['2014', '2015'], 'Incorrect ordering of the list')
self.assertEqual([g['date_count'] for g in groups_data], [2, 4], 'Incorrect number of results')
# group on inherited date column (res_partner.date) -> Year-Month, custom order
groups_data = res_users.read_group(domain, fields=['function', 'color', 'date'], groupby=['date'], orderby='date DESC')
self.assertEqual(len(groups_data), 4, "Incorrect number of results when grouping on a field")
self.assertEqual([g['date'] for g in groups_data], ['March 2015', 'January 2015', 'September 2014', 'January 2014'], 'Incorrect ordering of the list')
self.assertEqual([g['date_count'] for g in groups_data], [3, 1, 1, 1], 'Incorrect number of results')
# group on inherited many2one (res_partner.title), default order
groups_data = res_users.read_group(domain, fields=['function', 'color', 'title'], groupby=['title'])
self.assertEqual(len(groups_data), 2, "Incorrect number of results when grouping on a field")
# m2o is returned as a (id, label) pair
self.assertEqual([g['title'] for g in groups_data], [(title_lady.id, 'Lady...'), (title_sir.id, 'Sir...')], 'Incorrect ordering of the list')
self.assertEqual([g['title_count'] for g in groups_data], [4, 2], 'Incorrect number of results')
self.assertEqual([g['color'] for g in groups_data], [10, -1], 'Incorrect aggregation of int column')
# group on inherited many2one (res_partner.title), reversed natural order
groups_data = res_users.read_group(domain, fields=['function', 'color', 'title'], groupby=['title'], orderby="title desc")
self.assertEqual(len(groups_data), 2, "Incorrect number of results when grouping on a field")
# m2o is returned as a (id, label) pair
self.assertEqual([(title_sir.id, 'Sir...'), (title_lady.id, 'Lady...')], [g['title'] for g in groups_data], 'Incorrect ordering of the list')
self.assertEqual([g['title_count'] for g in groups_data], [2, 4], 'Incorrect number of results')
self.assertEqual([g['color'] for g in groups_data], [-1, 10], 'Incorrect aggregation of int column')
# group on inherited many2one (res_partner.title), multiple orders with m2o in second position
groups_data = res_users.read_group(domain, fields=['function', 'color', 'title'], groupby=['title'], orderby="color desc, title desc")
self.assertEqual(len(groups_data), 2, "Incorrect number of results when grouping on a field")
# m2o is returned as a (id, label) pair
self.assertEqual([g['title'] for g in groups_data], [(title_lady.id, 'Lady...'), (title_sir.id, 'Sir...')], 'Incorrect ordering of the result')
self.assertEqual([g['title_count'] for g in groups_data], [4, 2], 'Incorrect number of results')
self.assertEqual([g['color'] for g in groups_data], [10, -1], 'Incorrect aggregation of int column')
# group on inherited many2one (res_partner.title), ordered by other inherited field (color)
groups_data = res_users.read_group(domain, fields=['function', 'color', 'title'], groupby=['title'], orderby='color')
self.assertEqual(len(groups_data), 2, "Incorrect number of results when grouping on a field")
# m2o is returned as a (id, label) pair
self.assertEqual([g['title'] for g in groups_data], [(title_sir.id, 'Sir...'), (title_lady.id, 'Lady...')], 'Incorrect ordering of the list')
self.assertEqual([g['title_count'] for g in groups_data], [2, 4], 'Incorrect number of results')
self.assertEqual([g['color'] for g in groups_data], [-1, 10], 'Incorrect aggregation of int column')
def test_61_private_read_group(self):
"""
the _read_group should behave exactly like read_group (public method) except for sorting the one2many on ID
instead of name, so avoiding the join on the "to many" table to get the name
"""
title_sir = self.env['res.partner.title'].create({'name': 'Sir...'})
title_lady = self.env['res.partner.title'].create({'name': 'Lady...'})
user_vals_list = [
{'name': 'Alice', 'login': 'alice', 'color': 1, 'function': 'Friend', 'date': '2015-03-28', 'title': title_lady.id},
{'name': 'Alice', 'login': 'alice2', 'color': 0, 'function': 'Friend', 'date': '2015-01-28', 'title': title_lady.id},
{'name': 'Bob', 'login': 'bob', 'color': 2, 'function': 'Friend', 'date': '2015-03-02', 'title': title_sir.id},
{'name': 'Eve', 'login': 'eve', 'color': 3, 'function': 'Eavesdropper', 'date': '2015-03-20', 'title': title_lady.id},
{'name': 'Nab', 'login': 'nab', 'color': -3, 'function': '5$ Wrench', 'date': '2014-09-10', 'title': title_sir.id},
{'name': 'Nab', 'login': 'nab-she', 'color': 6, 'function': '5$ Wrench', 'date': '2014-01-02', 'title': title_lady.id},
]
res_users = self.env['res.users']
users = res_users.create(user_vals_list)
domain = [('id', 'in', users.ids)]
# group on local char field without domain and without active_test (-> empty WHERE clause)
groups_data = res_users.with_context(active_test=False)._read_group([], fields=['login'], groupby=['login'], orderby='login DESC')
self.assertGreater(len(groups_data), 6, "Incorrect number of results when grouping on a field")
# group on local char field with limit
groups_data = res_users._read_group(domain, fields=['login'], groupby=['login'], orderby='login DESC', limit=3, offset=3)
self.assertEqual(len(groups_data), 3, "Incorrect number of results when grouping on a field with limit")
self.assertEqual(['bob', 'alice2', 'alice'], [g['login'] for g in groups_data], 'Result mismatch')
# group on inherited char field, aggregate on int field (second groupby ignored on purpose)
groups_data = res_users._read_group(domain, fields=['name', 'color', 'function'], groupby=['function', 'login'])
self.assertEqual(len(groups_data), 3, "Incorrect number of results when grouping on a field")
self.assertEqual([g['function'] for g in groups_data], ['5$ Wrench', 'Eavesdropper', 'Friend'], 'incorrect _read_group order')
for group_data in groups_data:
self.assertIn('color', group_data, "Aggregated data for the column 'color' is not present in _read_group return values")
self.assertEqual(group_data['color'], 3, "Incorrect sum for aggregated data for the column 'color'")
# group on inherited char field, reverse order
groups_data = res_users._read_group(domain, fields=['name', 'color'], groupby='name', orderby='name DESC')
self.assertEqual([g['name'] for g in groups_data], ['Nab', 'Eve', 'Bob', 'Alice'], 'Incorrect ordering of the list')
# group on int field, default ordering
groups_data = res_users._read_group(domain, fields=['color'], groupby='color')
self.assertEqual([g['color'] for g in groups_data], [-3, 0, 1, 2, 3, 6], 'Incorrect ordering of the list')
# multi group, second level is int field, should still be summed in first level grouping
groups_data = res_users._read_group(domain, fields=['name', 'color'], groupby=['name', 'color'], orderby='name DESC')
self.assertEqual([g['name'] for g in groups_data], ['Nab', 'Eve', 'Bob', 'Alice'], 'Incorrect ordering of the list')
self.assertEqual([g['color'] for g in groups_data], [3, 3, 2, 1], 'Incorrect ordering of the list')
# group on inherited char field, multiple orders with directions
groups_data = res_users._read_group(domain, fields=['name', 'color'], groupby='name', orderby='color DESC, name')
self.assertEqual(len(groups_data), 4, "Incorrect number of results when grouping on a field")
self.assertEqual([g['name'] for g in groups_data], ['Eve', 'Nab', 'Bob', 'Alice'], 'Incorrect ordering of the list')
self.assertEqual([g['name_count'] for g in groups_data], [1, 2, 1, 2], 'Incorrect number of results')
# group on inherited date column (res_partner.date) -> Year-Month, default ordering
groups_data = res_users._read_group(domain, fields=['function', 'color', 'date'], groupby=['date'])
self.assertEqual(len(groups_data), 4, "Incorrect number of results when grouping on a field")
self.assertEqual([g['date'] for g in groups_data], ['January 2014', 'September 2014', 'January 2015', 'March 2015'], 'Incorrect ordering of the list')
self.assertEqual([g['date_count'] for g in groups_data], [1, 1, 1, 3], 'Incorrect number of results')
# group on inherited date column (res_partner.date) specifying the :year -> Year default ordering
groups_data = res_users._read_group(domain, fields=['function', 'color', 'date'], groupby=['date:year'])
self.assertEqual(len(groups_data), 2, "Incorrect number of results when grouping on a field")
self.assertEqual([g['date:year'] for g in groups_data], ['2014', '2015'], 'Incorrect ordering of the list')
self.assertEqual([g['date_count'] for g in groups_data], [2, 4], 'Incorrect number of results')
# group on inherited date column (res_partner.date) -> Year-Month, custom order
groups_data = res_users._read_group(domain, fields=['function', 'color', 'date'], groupby=['date'], orderby='date DESC')
self.assertEqual(len(groups_data), 4, "Incorrect number of results when grouping on a field")
self.assertEqual([g['date'] for g in groups_data], ['March 2015', 'January 2015', 'September 2014', 'January 2014'], 'Incorrect ordering of the list')
self.assertEqual([g['date_count'] for g in groups_data], [3, 1, 1, 1], 'Incorrect number of results')
# group on inherited many2one (res_partner.title), default order
groups_data = res_users._read_group(domain, fields=['function', 'color', 'title'], groupby=['title'])
self.assertEqual(len(groups_data), 2, "Incorrect number of results when grouping on a field")
# m2o is returned as a (id, label) pair
# here the order of the titles is by ID
self.assertEqual([g['title'] for g in groups_data], [(title_sir.id, 'Sir...'), (title_lady.id, 'Lady...')], 'Incorrect ordering of the list')
self.assertEqual([g['title_count'] for g in groups_data], [2, 4], 'Incorrect number of results')
self.assertEqual([g['color'] for g in groups_data], [-1, 10], 'Incorrect aggregation of int column')
# group on inherited many2one (res_partner.title), reversed natural order
groups_data = res_users._read_group(domain, fields=['function', 'color', 'title'], groupby=['title'], orderby="title desc")
self.assertEqual(len(groups_data), 2, "Incorrect number of results when grouping on a field")
# m2o is returned as a (id, label) pair
# here the order of the titles is by ID DESC
self.assertEqual([g['title'] for g in groups_data], [(title_sir.id, 'Sir...'), (title_lady.id, 'Lady...')], 'Incorrect ordering of the list')
self.assertEqual([g['title_count'] for g in groups_data], [2, 4], 'Incorrect number of results')
self.assertEqual([g['color'] for g in groups_data], [-1, 10], 'Incorrect aggregation of int column')
# group on inherited many2one (res_partner.title), multiple orders with m2o in second position
groups_data = res_users._read_group(domain, fields=['function', 'color', 'title'], groupby=['title'], orderby="color desc, title desc")
self.assertEqual(len(groups_data), 2, "Incorrect number of results when grouping on a field")
# m2o is returned as a (id, label) pair
self.assertEqual([g['title'] for g in groups_data], [(title_lady.id, 'Lady...'), (title_sir.id, 'Sir...')], 'Incorrect ordering of the list')
self.assertEqual([g['title_count'] for g in groups_data], [4, 2], 'Incorrect number of results')
self.assertEqual([g['color'] for g in groups_data], [10, -1], 'Incorrect aggregation of int column')
# group on inherited many2one (res_partner.title), ordered by other inherited field (color)
groups_data = res_users._read_group(domain, fields=['function', 'color', 'title'], groupby=['title'], orderby='color')
self.assertEqual(len(groups_data), 2, "Incorrect number of results when grouping on a field")
# m2o is returned as a (id, label) pair
self.assertEqual([g['title'] for g in groups_data], [(title_sir.id, 'Sir...'), (title_lady.id, 'Lady...')], 'Incorrect ordering of the list')
self.assertEqual([g['title_count'] for g in groups_data], [2, 4], 'Incorrect number of results')
self.assertEqual([g['color'] for g in groups_data], [-1, 10], 'Incorrect aggregation of int column')
def test_70_archive_internal_partners(self):
test_partner = self.env['res.partner'].create({'name':'test partner'})
test_user = self.env['res.users'].create({
'login': 'test@odoo.com',
'partner_id': test_partner.id,
})
# Cannot archive the partner
with self.assertRaises(RedirectWarning):
test_partner.with_user(self.env.ref('base.user_admin')).toggle_active()
with self.assertRaises(ValidationError):
test_partner.with_user(self.user_demo).toggle_active()
# Can archive the user but the partner stays active
test_user.toggle_active()
self.assertTrue(test_partner.active, 'Parter related to user should remain active')
# Now we can archive the partner
test_partner.toggle_active()
# Activate the user should reactivate the partner
test_user.toggle_active()
self.assertTrue(test_partner.active, 'Activating user must active related partner')
def test_display_name_translation(self):
self.env['res.lang']._activate_lang('fr_FR')
self.env.ref('base.module_base')._update_translations(['fr_FR'])
res_partner = self.env['res.partner']
parent_contact = res_partner.create({
'name': 'Parent',
'type': 'contact',
})
child_contact = res_partner.create({
'type': 'other',
'parent_id': parent_contact.id,
})
self.assertEqual(child_contact.display_name, 'Parent, Other Address')
self.assertEqual(child_contact.with_context(lang='en_US').translated_display_name, 'Parent, Other Address')
self.assertEqual(child_contact.with_context(lang='fr_FR').translated_display_name, 'Parent, Autre adresse')
class TestPartnerRecursion(TransactionCase):
def setUp(self):
super(TestPartnerRecursion,self).setUp()
res_partner = self.env['res.partner']
self.p1 = res_partner.browse(res_partner.name_create('Elmtree')[0])
self.p2 = res_partner.create({'name': 'Elmtree Child 1', 'parent_id': self.p1.id})
self.p3 = res_partner.create({'name': 'Elmtree Grand-Child 1.1', 'parent_id': self.p2.id})
def test_100_res_partner_recursion(self):
self.assertTrue(self.p3._check_recursion())
self.assertTrue((self.p1 + self.p2 + self.p3)._check_recursion())
# split 101, 102, 103 tests to force SQL rollback between them
def test_101_res_partner_recursion(self):
with self.assertRaises(ValidationError):
self.p1.write({'parent_id': self.p3.id})
def test_102_res_partner_recursion(self):
with self.assertRaises(ValidationError):
self.p2.write({'parent_id': self.p3.id})
def test_103_res_partner_recursion(self):
with self.assertRaises(ValidationError):
self.p3.write({'parent_id': self.p3.id})
def test_104_res_partner_recursion_indirect_cycle(self):
""" Indirect hacky write to create cycle in children """
p3b = self.p1.create({'name': 'Elmtree Grand-Child 1.2', 'parent_id': self.p2.id})
with self.assertRaises(ValidationError):
self.p2.write({'child_ids': [Command.update(self.p3.id, {'parent_id': p3b.id}),
Command.update(p3b.id, {'parent_id': self.p3.id})]})
def test_110_res_partner_recursion_multi_update(self):
""" multi-write on several partners in same hierarchy must not trigger a false cycle detection """
ps = self.p1 + self.p2 + self.p3
self.assertTrue(ps.write({'phone': '123456'}))
def test_111_res_partner_recursion_infinite_loop(self):
""" The recursion check must not loop forever """
self.p2.parent_id = False
self.p3.parent_id = False
self.p1.parent_id = self.p2
with self.assertRaises(ValidationError):
(self.p3|self.p2).write({'parent_id': self.p1.id})
class TestParentStore(TransactionCase):
""" Verify that parent_store computation is done right """
@ -812,21 +162,24 @@ class TestGroups(TransactionCase):
def test_res_groups_fullname_search(self):
all_groups = self.env['res.groups'].search([])
groups = all_groups.search([('full_name', 'like', '%Sale%')])
groups = all_groups.search([('full_name', 'like', 'Sale')])
self.assertItemsEqual(groups.ids, [g.id for g in all_groups if 'Sale' in g.full_name],
"did not match search for 'Sale'")
groups = all_groups.search([('full_name', 'like', '%Technical%')])
groups = all_groups.search([('full_name', 'like', 'Technical')])
self.assertItemsEqual(groups.ids, [g.id for g in all_groups if 'Technical' in g.full_name],
"did not match search for 'Technical'")
groups = all_groups.search([('full_name', 'like', '%Sales /%')])
groups = all_groups.search([('full_name', 'like', 'Sales /')])
self.assertItemsEqual(groups.ids, [g.id for g in all_groups if 'Sales /' in g.full_name],
"did not match search for 'Sales /'")
groups = all_groups.search([('full_name', 'in', ['Administration / Access Rights','Contact Creation'])])
self.assertTrue(groups, "did not match search for 'Administration / Access Rights' and 'Contact Creation'")
groups = all_groups.search([('full_name', 'like', '/')])
self.assertTrue(groups, "did not match search for '/'")
def test_res_group_recursion(self):
# four groups with no cycle, check them all together
a = self.env['res.groups'].create({'name': 'A'})
@ -902,12 +255,3 @@ class TestGroups(TransactionCase):
self.assertIn(u2, e.users)
self.assertIn(default, e.with_context(active_test=False).users)
self.assertNotIn(p, e.users)
class TestUsers(TransactionCase):
def test_superuser(self):
""" The superuser is inactive and must remain as such. """
user = self.env['res.users'].browse(SUPERUSER_ID)
self.assertFalse(user.active)
with self.assertRaises(UserError):
user.write({'active': True})

View file

@ -27,3 +27,12 @@ class TestModelDeprecations(TransactionCase):
if module:
msg += f" in {module}"
self.fail(msg)
def test_name_get(self):
for model_name, Model in self.registry.items():
with self.subTest(model=model_name):
# name_get should exist but define by BaseModel
module = inspect.getmodule(Model.name_get)
if module.__name__ == 'odoo.models':
continue
self.fail(f"Deprecated name_get method found on {model_name} in {module.__name__}, you should override `_compute_display_name` instead")

View file

@ -71,8 +71,10 @@ class TestFloatPrecision(TransactionCase):
result = float_repr(value, precision_digits=digits)
self.assertEqual(result, expected, 'Rounding error: got %s, expected %s' % (result, expected))
try_round(2.6745, '2.675')
try_round(-2.6745, '-2.675')
try_round(2.6735, '2.674') # Tie rounds away from 0
try_round(-2.6735, '-2.674') # Tie rounds away from 0
try_round(2.6745, '2.675') # Tie rounds away from 0
try_round(-2.6745, '-2.675') # Tie rounds away from 0
try_round(2.6744, '2.674')
try_round(-2.6744, '-2.674')
try_round(0.0004, '0.000')
@ -82,6 +84,34 @@ class TestFloatPrecision(TransactionCase):
try_round(457.4554, '457.455')
try_round(-457.4554, '-457.455')
# Try some rounding value with rounding method HALF-DOWN instead of HALF-UP
try_round(2.6735, '2.673', method='HALF-DOWN') # Tie rounds towards 0
try_round(-2.6735, '-2.673', method='HALF-DOWN') # Tie rounds towards 0
try_round(2.6745, '2.674', method='HALF-DOWN') # Tie rounds towards 0
try_round(-2.6745, '-2.674', method='HALF-DOWN') # Tie rounds towards 0
try_round(2.6744, '2.674', method='HALF-DOWN')
try_round(-2.6744, '-2.674', method='HALF-DOWN')
try_round(0.0004, '0.000', method='HALF-DOWN')
try_round(-0.0004, '-0.000', method='HALF-DOWN')
try_round(357.4555, '357.455', method='HALF-DOWN')
try_round(-357.4555, '-357.455', method='HALF-DOWN')
try_round(457.4554, '457.455', method='HALF-DOWN')
try_round(-457.4554, '-457.455', method='HALF-DOWN')
# Try some rounding value with rounding method HALF-EVEN instead of HALF-UP
try_round(2.6735, '2.674', method='HALF-EVEN') # Tie rounds to the closest even number (i.e. up here)
try_round(-2.6735, '-2.674', method='HALF-EVEN') # Tie rounds to the closest even number (i.e. up here)
try_round(2.6745, '2.674', method='HALF-EVEN') # Tie rounds to the closest even number (i.e. down here)
try_round(-2.6745, '-2.674', method='HALF-EVEN') # Tie rounds to the closest even number (i.e. down here)
try_round(2.6744, '2.674', method='HALF-EVEN')
try_round(-2.6744, '-2.674', method='HALF-EVEN')
try_round(0.0004, '0.000', method='HALF-EVEN')
try_round(-0.0004, '-0.000', method='HALF-EVEN')
try_round(357.4555, '357.455', method='HALF-EVEN')
try_round(-357.4555, '-357.455', method='HALF-EVEN')
try_round(457.4554, '457.455', method='HALF-EVEN')
try_round(-457.4554, '-457.455', method='HALF-EVEN')
# Try some rounding value with rounding method UP instead of HALF-UP
# We use 8.175 because when normalizing 8.175 with precision_digits=3 it gives
# us 8175,0000000001234 as value, and if not handle correctly the rounding UP

View file

@ -16,10 +16,10 @@ class TestFormCreate(TransactionCase):
if hasattr(self.env['res.partner'], 'property_account_payable_id'):
# Required for `property_account_payable_id`, `property_account_receivable_id` to be visible in the view
# By default, it's the `group` `group_account_readonly` which is required to see it, in the `account` module
# But once `account_accountant` gets installed, it becomes `account.group_account_manager`
# https://github.com/odoo/enterprise/blob/bfa643278028da0bfabded2f87ccb7e323d697c1/account_accountant/views/product_views.xml#L9
# But once `account_accountant` gets installed, it becomes `account.group_account_user`
# https://github.com/odoo/enterprise/commit/68f6c1f9fd3ff6762c98e1a405ade035129efce0
self.env.user.groups_id += self.env.ref('account.group_account_readonly')
self.env.user.groups_id += self.env.ref('account.group_account_manager')
self.env.user.groups_id += self.env.ref('account.group_account_user')
partner_form = Form(self.env['res.partner'])
partner_form.name = 'a partner'
# YTI: Clean that brol
@ -64,11 +64,12 @@ class TestFormCreate(TransactionCase):
def test_create_res_country(self):
country_form = Form(self.env['res.country'])
country_form.name = 'a country'
country_form.code = 'AA'
country_form.code = 'ZX'
country_form.save()
def test_create_res_lang(self):
lang_form = Form(self.env['res.lang'])
# lang_form.url_code = 'LANG' # invisible field, tested in http_routing
lang_form.name = 'a lang name'
lang_form.code = 'a lang code'
lang_form.save()

View file

@ -56,26 +56,6 @@ class TestHttpCase(HttpCase):
console_log_count += 1
self.assertEqual(console_log_count, 1)
@patch.dict(config.options, {"dev_mode": []})
def test_404_assets(self):
IrAttachment = self.env['ir.attachment']
# Ensure no assets exists
IrAttachment.search([('url', '=like', '/web/assets/%')]).unlink()
response = self.url_open('/NoSuchPage')
self.assertEqual(response.status_code, 404, "Page should not exist")
self.assertFalse(
IrAttachment.search_count([('url', '=like', '/web/assets/%')]),
"Assets should not have been generated because the transaction was rolled back"
# Well, they should - but this is part of a compromise to avoid
# being in the way of the read-only mode.
)
response = self.url_open('/')
self.assertEqual(response.status_code, 200, "Page should exist")
self.assertTrue(
IrAttachment.search_count([('url', '=like', '/web/assets/%')]),
"Assets should have been generated"
)
@tagged('-at_install', 'post_install')
class TestChromeBrowser(HttpCase):
@ -85,7 +65,6 @@ class TestChromeBrowser(HttpCase):
with patch.dict(config.options, {'screencasts': screencasts_dir, 'screenshots': config['screenshots']}):
self.browser = ChromeBrowser(self)
self.addCleanup(self.browser.stop)
self.addCleanup(self.browser.clear)
def test_screencasts(self):
self.browser.start_screencast()
@ -131,8 +110,8 @@ class TestRequestRemaining(HttpCase):
# but this makes the test more clear and robust
_logger.info('B finish')
self.env.registry.clear_caches()
self.addCleanup(self.env.registry.clear_caches)
self.env.registry.clear_cache('routing')
self.addCleanup(self.env.registry.clear_cache, 'routing')
def late_request_thread():
# In some rare case the request may arrive after _wait_remaining_requests.
@ -147,9 +126,9 @@ class TestRequestRemaining(HttpCase):
def test_requests_b(self):
self.env.cr.execute('SELECT 1')
with self.assertLogs('odoo.tests.common', level="ERROR") as lc:
with self.assertLogs('odoo.tests.common') as lc:
self.main_lock.release()
_logger.info('B started, waiting for A to finish')
self.thread_a.join()
self.assertEqual(lc.output, ['ERROR:odoo.tests.common:Request with path /web/concurrent has been ignored during test as it it does not contain the test_cursor cookie or it is expired. (required "/base/tests/test_http_case.py:TestRequestRemaining.test_requests_b", got "/base/tests/test_http_case.py:TestRequestRemaining.test_requests_a")'])
self.assertEqual(lc.output[0].split(':', 1)[1], 'odoo.tests.common:Request with path /web/concurrent has been ignored during test as it it does not contain the test_cursor cookie or it is expired. (required "/base/tests/test_http_case.py:TestRequestRemaining.test_requests_b", got "/base/tests/test_http_case.py:TestRequestRemaining.test_requests_a")')
self.env.cr.fetchall()

View file

@ -177,6 +177,19 @@ class TestImage(TransactionCase):
res = tools.image_process(self.img_1920x1080_jpeg)
self.assertLessEqual(len(res), len(self.img_1920x1080_jpeg))
# CASE: JPEG optimize + bigger size => original
pil_image = Image.new('RGB', (1920, 1080), color=self.bg_color)
# Drawing non trivial content so that optimization matters.
ImageDraw.Draw(pil_image).ellipse(xy=[
(400, 0),
(1500, 1080)
], fill=self.fill_color, outline=(240, 25, 40), width=10)
image = tools.image_apply_opt(pil_image, 'JPEG')
res = tools.image_process(image, quality=50)
self.assertLess(len(res), len(image), "Low quality image should be smaller than original")
res = tools.image_process(image, quality=99)
self.assertEqual(len(res), len(image), "Original should be returned if size increased")
# CASE: GIF doesn't apply quality, just optimize
image = tools.image_apply_opt(Image.new('RGB', (1080, 1920)), 'GIF')
res = tools.image_process(image)
@ -278,6 +291,21 @@ class TestImage(TransactionCase):
image = img_open(tools.image_process(image_1080_1920_tiff, quality=95))
self.assertEqual(image.format, 'JPEG', "unsupported format to JPEG")
def test_17_get_webp_size(self):
# Using 32 bytes image headers as data.
# Lossy webp: 550x368
webp_lossy = b'RIFFhv\x00\x00WEBPVP8 \\v\x00\x00\xd2\xbe\x01\x9d\x01*&\x02p\x01>\xd5'
size = tools.get_webp_size(webp_lossy)
self.assertEqual((550, 368), size, "Wrong resolution for lossy webp")
# Lossless webp: 421x163
webp_lossless = b'RIFF\xba\x84\x00\x00WEBPVP8L\xad\x84\x00\x00/\xa4\x81(\x10MHr\x1bI\x92\xa4'
size = tools.get_webp_size(webp_lossless)
self.assertEqual((421, 163), size, "Wrong resolution for lossless webp")
# Extended webp: 800x600
webp_extended = b'RIFF\x80\xce\x00\x00WEBPVP8X\n\x00\x00\x00\x10\x00\x00\x00\x1f\x03\x00W\x02\x00AL'
size = tools.get_webp_size(webp_extended)
self.assertEqual((800, 600), size, "Wrong resolution for extended webp")
def test_20_image_data_uri(self):
"""Test that image_data_uri is working as expected."""
self.assertEqual(tools.image_data_uri(base64.b64encode(self.img_1x1_png)), 'data:image/png;base64,' + base64.b64encode(self.img_1x1_png).decode('ascii'))

View file

@ -2,12 +2,15 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import date
import json
from psycopg2 import IntegrityError, ProgrammingError
import requests
from unittest.mock import patch
import odoo
from odoo.exceptions import UserError, ValidationError, AccessError
from odoo.tools import mute_logger
from odoo.tests import common
from odoo.tests import common, tagged
from odoo.addons.base.tests.common import TransactionCaseWithUserDemo
from odoo import Command
@ -22,6 +25,7 @@ class TestServerActionsBase(TransactionCaseWithUserDemo):
'name': 'TestingCountry',
'code': 'TY',
'address_format': 'SuperFormat',
'name_position': 'before',
})
self.test_partner = self.env['res.partner'].create({
'city': 'OrigCity',
@ -45,10 +49,10 @@ class TestServerActionsBase(TransactionCaseWithUserDemo):
self.res_partner_parent_field = Fields.search([('model', '=', 'res.partner'), ('name', '=', 'parent_id')])
self.res_partner_children_field = Fields.search([('model', '=', 'res.partner'), ('name', '=', 'child_ids')])
self.res_partner_category_field = Fields.search([('model', '=', 'res.partner'), ('name', '=', 'category_id')])
self.res_partner_latitude_field = Fields.search([('model', '=', 'res.partner'), ('name', '=', 'partner_latitude')])
self.res_country_model = Model.search([('model', '=', 'res.country')])
self.res_country_name_field = Fields.search([('model', '=', 'res.country'), ('name', '=', 'name')])
self.res_country_code_field = Fields.search([('model', '=', 'res.country'), ('name', '=', 'code')])
self.res_country_name_position_field = Fields.search([('model', '=', 'res.country'), ('name', '=', 'name_position')])
self.res_partner_category_model = Model.search([('model', '=', 'res.partner.category')])
self.res_partner_category_name_field = Fields.search([('model', '=', 'res.partner.category'), ('name', '=', 'name')])
@ -61,8 +65,40 @@ class TestServerActionsBase(TransactionCaseWithUserDemo):
'code': 'record.write({"comment": "%s"})' % self.comment_html,
})
server_action_model = Model.search([('model', '=', 'ir.actions.server')])
self.test_server_action = self.env['ir.actions.server'].create({
'name': 'TestDummyServerAction',
'model_id': server_action_model.id,
'state': 'code',
'code':
"""
_logger.log(10, "This is a %s debug %s", "test", "log")
_logger.info("This is a %s info %s", "test", "log")
_logger.warning("This is a %s warning %s", "test", "log")
_logger.error("This is a %s error %s", "test", "log")
try:
0/0
except:
_logger.exception("This is a %s exception %s", "test", "log")
""",
})
class TestServerActions(TestServerActionsBase):
def test_00_server_action(self):
with self.assertLogs('odoo.addons.base.models.ir_actions.server_action_safe_eval',
level='DEBUG') as log_catcher:
self.test_server_action.run()
self.assertEqual(log_catcher.output, [
'DEBUG:odoo.addons.base.models.ir_actions.server_action_safe_eval:This is a test debug log',
'INFO:odoo.addons.base.models.ir_actions.server_action_safe_eval:This is a test info log',
'WARNING:odoo.addons.base.models.ir_actions.server_action_safe_eval:This is a test warning log',
'ERROR:odoo.addons.base.models.ir_actions.server_action_safe_eval:This is a test error log',
"""ERROR:odoo.addons.base.models.ir_actions.server_action_safe_eval:This is a test exception log
Traceback (most recent call last):
File "ir.actions.server(%d,)", line 6, in <module>
ZeroDivisionError: division by zero""" % self.test_server_action.id
])
def test_00_action(self):
self.action.with_context(self.context).run()
@ -93,56 +129,48 @@ class TestServerActions(TestServerActionsBase):
# Do: create a new record in another model
self.action.write({
'state': 'object_create',
'crud_model_id': self.res_country_model.id,
'crud_model_id': self.res_partner_model.id,
'link_field_id': False,
'fields_lines': [Command.clear(),
Command.create({'col1': self.res_country_name_field.id, 'value': 'record.name', 'evaluation_type': 'equation'}),
Command.create({'col1': self.res_country_code_field.id, 'value': 'record.name[0:2]', 'evaluation_type': 'equation'})],
'value': 'TestingPartner2'
})
run_res = self.action.with_context(self.context).run()
self.assertFalse(run_res, 'ir_actions_server: create record action correctly finished should return False')
# Test: new country created
country = self.test_country.search([('name', 'ilike', 'TestingPartner')])
self.assertEqual(len(country), 1, 'ir_actions_server: TODO')
self.assertEqual(country.code, 'TE', 'ir_actions_server: TODO')
# Test: new partner created
partner = self.test_partner.search([('name', 'ilike', 'TestingPartner2')])
self.assertEqual(len(partner), 1, 'ir_actions_server: TODO')
def test_20_crud_create_link_many2one(self):
_city = 'TestCity'
_name = 'TestNew'
# Do: create a new record in the same model and link it with a many2one
self.action.write({
'state': 'object_create',
'crud_model_id': self.action.model_id.id,
'crud_model_id': self.res_partner_model.id,
'link_field_id': self.res_partner_parent_field.id,
'fields_lines': [Command.create({'col1': self.res_partner_name_field.id, 'value': _name}),
Command.create({'col1': self.res_partner_city_field.id, 'value': _city})],
'value': "TestNew"
})
run_res = self.action.with_context(self.context).run()
self.assertFalse(run_res, 'ir_actions_server: create record action correctly finished should return False')
# Test: new partner created
partner = self.test_partner.search([('name', 'ilike', _name)])
partner = self.test_partner.search([('name', 'ilike', 'TestNew')])
self.assertEqual(len(partner), 1, 'ir_actions_server: TODO')
self.assertEqual(partner.city, _city, 'ir_actions_server: TODO')
# Test: new partner linked
self.assertEqual(self.test_partner.parent_id, partner, 'ir_actions_server: TODO')
def test_20_crud_create_link_one2many(self):
_name = 'TestNew'
# Do: create a new record in the same model and link it with a one2many
self.action.write({
'state': 'object_create',
'crud_model_id': self.action.model_id.id,
'crud_model_id': self.res_partner_model.id,
'link_field_id': self.res_partner_children_field.id,
'fields_lines': [Command.create({'col1': self.res_partner_name_field.id, 'value': _name})],
'value': 'TestNew',
})
run_res = self.action.with_context(self.context).run()
self.assertFalse(run_res, 'ir_actions_server: create record action correctly finished should return False')
# Test: new partner created
partner = self.test_partner.search([('name', 'ilike', _name)])
partner = self.test_partner.search([('name', 'ilike', 'TestNew')])
self.assertEqual(len(partner), 1, 'ir_actions_server: TODO')
self.assertEqual(partner.name, _name, 'ir_actions_server: TODO')
self.assertEqual(partner.name, 'TestNew', 'ir_actions_server: TODO')
# Test: new partner linked
self.assertIn(partner, self.test_partner.child_ids, 'ir_actions_server: TODO')
@ -152,7 +180,7 @@ class TestServerActions(TestServerActionsBase):
'state': 'object_create',
'crud_model_id': self.res_partner_category_model.id,
'link_field_id': self.res_partner_category_field.id,
'fields_lines': [Command.create({'col1': self.res_partner_category_name_field.id, 'value': 'record.name', 'evaluation_type': 'equation'})],
'value': 'TestingPartner'
})
run_res = self.action.with_context(self.context).run()
self.assertFalse(run_res, 'ir_actions_server: create record action correctly finished should return False')
@ -162,17 +190,16 @@ class TestServerActions(TestServerActionsBase):
self.assertIn(category, self.test_partner.category_id)
def test_30_crud_write(self):
_name = 'TestNew'
# Do: update partner name
self.action.write({
'state': 'object_write',
'fields_lines': [Command.create({'col1': self.res_partner_name_field.id, 'value': _name})],
'update_path': 'name',
'value': 'TestNew',
})
run_res = self.action.with_context(self.context).run()
self.assertFalse(run_res, 'ir_actions_server: create record action correctly finished should return False')
# Test: partner updated
partner = self.test_partner.search([('name', 'ilike', _name)])
partner = self.test_partner.search([('name', 'ilike', 'TestNew')])
self.assertEqual(len(partner), 1, 'ir_actions_server: TODO')
self.assertEqual(partner.city, 'OrigCity', 'ir_actions_server: TODO')
@ -180,11 +207,9 @@ class TestServerActions(TestServerActionsBase):
# Do: update partners city
self.action.write({
'state': 'object_write',
'fields_lines': [Command.create({
'col1': self.res_partner_city_field.id,
'evaluation_type': 'equation',
'value': 'record.id',
})],
'update_path': 'city',
'evaluation_type': 'equation',
'value': 'record.id',
})
partners = self.test_partner + self.test_partner.copy()
self.action.with_context(self.context, active_ids=partners.ids).run()
@ -192,6 +217,140 @@ class TestServerActions(TestServerActionsBase):
self.assertEqual(partners[0].city, str(partners[0].id))
self.assertEqual(partners[1].city, str(partners[1].id))
def test_35_crud_write_selection(self):
# Don't want to use res.partner because no 'normal selection field' exists there
# we'll use a speficic action for this test instead of the one from the test setup
# Do: update country name_position field
selection_value = self.res_country_name_position_field.selection_ids.filtered(lambda s: s.value == 'after')
action = self.env['ir.actions.server'].create({
'name': 'TestAction',
'model_id': self.res_country_model.id,
'model_name': 'res.country',
'state': 'object_write',
'update_path': 'name_position',
'selection_value': selection_value.id,
})
action._set_selection_value() # manual onchange
self.assertEqual(action.value, selection_value.value)
context = {
'active_model': 'res.country',
'active_id': self.test_country.id,
}
run_res = action.with_context(context).run()
self.assertFalse(run_res, 'ir_actions_server: update record action correctly finished should return False')
# Test: country updated
self.assertEqual(self.test_country.name_position, 'after')
def test_36_crud_write_m2m_ops(self):
""" Test that m2m operations work as expected """
categ_1 = self.env['res.partner.category'].create({'name': 'TestCateg1'})
categ_2 = self.env['res.partner.category'].create({'name': 'TestCateg2'})
# set partner category
self.action.write({
'state': 'object_write',
'update_path': 'category_id',
'update_m2m_operation': 'set',
'resource_ref': categ_1,
})
run_res = self.action.with_context(self.context).run()
self.assertFalse(run_res, 'ir_actions_server: update record action correctly finished should return False')
# Test: partner updated
self.assertIn(categ_1, self.test_partner.category_id, 'ir_actions_server: tag should have been set')
# add partner category
self.action.write({
'state': 'object_write',
'update_path': 'category_id',
'update_m2m_operation': 'add',
'resource_ref': categ_2,
})
run_res = self.action.with_context(self.context).run()
self.assertFalse(run_res, 'ir_actions_server: update record action correctly finished should return False')
# Test: partner updated
self.assertIn(categ_2, self.test_partner.category_id, 'ir_actions_server: new tag should have been added')
self.assertIn(categ_1, self.test_partner.category_id, 'ir_actions_server: old tag should still be there')
# remove partner category
self.action.write({
'state': 'object_write',
'update_path': 'category_id',
'update_m2m_operation': 'remove',
'resource_ref': categ_1,
})
run_res = self.action.with_context(self.context).run()
self.assertFalse(run_res, 'ir_actions_server: update record action correctly finished should return False')
# Test: partner updated
self.assertNotIn(categ_1, self.test_partner.category_id, 'ir_actions_server: tag should have been removed')
self.assertIn(categ_2, self.test_partner.category_id, 'ir_actions_server: tag should still be there')
# clear partner category
self.action.write({
'state': 'object_write',
'update_path': 'category_id',
'update_m2m_operation': 'clear',
})
run_res = self.action.with_context(self.context).run()
self.assertFalse(run_res, 'ir_actions_server: update record action correctly finished should return False')
# Test: partner updated
self.assertFalse(self.test_partner.category_id, 'ir_actions_server: tags should have been cleared')
def test_37_field_path_traversal(self):
""" Test the update_path field traversal - allowing records to be updated along relational links """
# update the country's name via the partner
self.action.write({
'state': 'object_write',
'update_path': 'country_id.name',
'value': 'TestUpdatedCountry',
})
run_res = self.action.with_context(self.context).run()
self.assertFalse(run_res, 'ir_actions_server: update record action correctly finished should return False')
# Test: partner updated
self.assertEqual(self.test_partner.country_id.name, 'TestUpdatedCountry', 'ir_actions_server: country name should have been updated through relation')
# update a readonly field
self.action.write({
'state': 'object_write',
'update_path': 'country_id.image_url',
'value': "/base/static/img/country_flags/be.png",
})
self.assertEqual(self.test_partner.country_id.image_url, "/base/static/img/country_flags/ty.png", 'ir_actions_server: country flag has this value before the update')
run_res = self.action.with_context(self.context).run()
self.assertFalse(run_res, 'ir_actions_server: update record action correctly finished should return False')
# Test: partner updated
self.assertEqual(self.test_partner.country_id.image_url, "/base/static/img/country_flags/be.png", 'ir_actions_server: country should have been updated through a readonly field')
self.assertEqual(self.test_partner.country_id.code, "TY", 'ir_actions_server: country code is still TY')
# input an invalid path
with self.assertRaises(ValidationError):
self.action.write({
'state': 'object_write',
'update_path': 'country_id.name.foo',
'value': 'DoesNotMatter',
})
self.action.flush_recordset(['update_path', 'update_field_id'])
def test_39_boolean_update(self):
""" Test that boolean fields can be updated """
# update the country's name via the partner
self.action.write({
'state': 'object_write',
'update_path': 'active',
'update_boolean_value': 'false',
})
run_res = self.action.with_context(self.context).run()
self.assertFalse(run_res, 'ir_actions_server: update record action correctly finished should return False')
# Test: partner updated
self.assertFalse(self.test_partner.active, 'ir_actions_server: partner should have been deactivated')
self.action.write({
'state': 'object_write',
'update_path': 'active',
'update_boolean_value': 'true',
})
run_res = self.action.with_context(self.context).run()
self.assertFalse(run_res, 'ir_actions_server: update record action correctly finished should return False')
# Test: partner updated
self.assertTrue(self.test_partner.active, 'ir_actions_server: partner should have been reactivated')
@mute_logger('odoo.addons.base.models.ir_model', 'odoo.models')
def test_40_multi(self):
# Data: 2 server actions that will be nested
@ -208,19 +367,26 @@ class TestServerActions(TestServerActionsBase):
'model_id': self.res_partner_model.id,
'crud_model_id': self.res_partner_model.id,
'state': 'object_create',
'fields_lines': [Command.create({'col1': self.res_partner_name_field.id, 'value': 'RaoulettePoiluchette'}),
Command.create({'col1': self.res_partner_city_field.id, 'value': 'TestingCity'})],
'value': 'RaoulettePoiluchette',
})
action3 = self.action.create({
'name': 'Subaction3',
'name': 'Subaction2',
'sequence': 3,
'model_id': self.res_partner_model.id,
'state': 'object_write',
'update_path': 'city',
'value': 'RaoulettePoiluchette',
})
action4 = self.action.create({
'name': 'Subaction3',
'sequence': 4,
'model_id': self.res_partner_model.id,
'state': 'code',
'code': 'action = {"type": "ir.actions.act_url"}',
})
self.action.write({
'state': 'multi',
'child_ids': [Command.set([action1.id, action2.id, action3.id])],
'child_ids': [Command.set([action1.id, action2.id, action3.id, action4.id])],
})
# Do: run the action
@ -325,25 +491,55 @@ class TestServerActions(TestServerActionsBase):
self_demo.with_context(self.context).run()
self.assertEqual(self.test_partner.date, date.today())
# but can not write on private address
self.test_partner.type = "private"
with self.assertRaises(AccessError):
self.test_partner.with_user(user_demo.id).check_access_rule("write")
# nor execute a server action on it
with self.assertRaises(AccessError), mute_logger('odoo.addons.base.models.ir_actions'):
self_demo.with_context(self.context).run()
def test_90_webhook(self):
self.action.write({
'state': 'webhook',
'webhook_field_ids': [
Command.link(self.res_partner_name_field.id),
Command.link(self.res_partner_city_field.id),
Command.link(self.res_partner_country_field.id),
],
'webhook_url': 'http://example.com/webhook',
})
# write a mock for the requests.post method that checks the data
# and returns a 200 response
num_requests = 0
def _patched_post(*args, **kwargs):
nonlocal num_requests
response = requests.Response()
response.status_code = 200 if num_requests == 0 else 400
self.assertEqual(args[0], 'http://example.com/webhook')
self.assertEqual(kwargs['data'], json.dumps({
'_action': "%s(#%s)" % (self.action.name, self.action.id),
'_id': self.test_partner.id,
'_model': self.test_partner._name,
'city': self.test_partner.city,
'country_id': self.test_partner.country_id.id,
'id': self.test_partner.id,
'name': self.test_partner.name,
}))
num_requests += 1
return response
with patch.object(requests, 'post', _patched_post), mute_logger('odoo.addons.base.models.ir_actions'):
# first run: 200
self.action.with_context(self.context).run()
# second run: 400, should *not* raise but
# should warn in logs (hence mute_logger)
self.action.with_context(self.context).run()
self.assertEqual(num_requests, 2)
def test_90_convert_to_float(self):
# make sure eval_value convert the value into float for float-type fields
self.action.write({
'state': 'object_write',
'fields_lines': [Command.create({'col1': self.res_partner_latitude_field.id, 'value': '20.99'})],
'update_path': 'partner_latitude',
'value': '20.99',
})
line = self.action.fields_lines[0]
self.assertEqual(line.eval_value()[line.id], 20.99)
self.assertEqual(self.action._eval_value()[self.action.id], 20.99)
class TestCustomFields(common.TransactionCase):
class TestCommonCustomFields(common.TransactionCase):
MODEL = 'res.partner'
COMODEL = 'res.users'
@ -356,7 +552,7 @@ class TestCustomFields(common.TransactionCase):
assert set(self.registry[self.MODEL]._fields) == fnames
self.addCleanup(self.registry.reset_changes)
self.addCleanup(self.registry.clear_caches)
self.addCleanup(self.registry.clear_all_caches)
super().setUp()
@ -380,16 +576,18 @@ class TestCustomFields(common.TransactionCase):
'arch': '<tree string="X"><field name="%s"/></tree>' % name,
})
class TestCustomFields(TestCommonCustomFields):
def test_create_custom(self):
""" custom field names must be start with 'x_' """
with self.assertRaises(ValidationError):
self.create_field('foo')
with self.assertRaises(IntegrityError), mute_logger('odoo.sql_db'):
self.create_field('xyz')
def test_rename_custom(self):
""" custom field names must be start with 'x_' """
field = self.create_field('x_foo')
with self.assertRaises(ValidationError):
field.name = 'foo'
field = self.create_field('x_xyz')
with self.assertRaises(IntegrityError), mute_logger('odoo.sql_db'):
field.name = 'xyz'
def test_create_valid(self):
""" field names must be valid pg identifiers """
@ -567,9 +765,9 @@ class TestCustomFields(common.TransactionCase):
# create a non-computed field, and assert how many queries it takes
model_id = self.env['ir.model']._get_id('res.partner')
query_count = 41
query_count = 48
with self.assertQueryCount(query_count):
self.env.registry.clear_caches()
self.env.registry.clear_cache()
self.env['ir.model.fields'].create({
'model_id': model_id,
'name': 'x_oh_box',
@ -580,7 +778,7 @@ class TestCustomFields(common.TransactionCase):
# same with a related field, it only takes 8 extra queries
with self.assertQueryCount(query_count + 8):
self.env.registry.clear_caches()
self.env.registry.clear_cache()
self.env['ir.model.fields'].create({
'model_id': model_id,
'name': 'x_oh_boy',
@ -663,3 +861,26 @@ class TestCustomFields(common.TransactionCase):
self.assertEqual(rec1.x_sel, False)
self.assertEqual(rec2.x_sel, 'quux')
self.assertEqual(rec3.x_sel, 'baz')
@tagged('post_install', '-at_install')
class TestCustomFieldsPostInstall(TestCommonCustomFields):
def test_add_field_valid(self):
""" custom field names must start with 'x_', even when bypassing the constraints
If a user bypasses all constraints to add a custom field not starting by `x_`,
it must not be loaded in the registry.
This is to forbid users to override class attributes.
"""
field = self.create_field('x_foo')
# Drop the SQL constraint, to bypass it,
# as a user could do through a SQL shell or a `cr.execute` in a server action
self.env.cr.execute("ALTER TABLE ir_model_fields DROP CONSTRAINT ir_model_fields_name_manual_field")
self.env.cr.execute("UPDATE ir_model_fields SET name = 'foo' WHERE id = %s", [field.id])
with self.assertLogs('odoo.addons.base.models.ir_model') as log_catcher:
# Trick to reload the registry. The above rename done through SQL didn't reload the registry. This will.
self.env.registry.setup_models(self.cr)
self.assertIn(
f'The field `{field.name}` is not defined in the `{field.model}` Python class', log_catcher.output[0]
)

View file

@ -4,13 +4,14 @@ import base64
import hashlib
import io
import os
from unittest.mock import patch
from PIL import Image
import odoo
from odoo.exceptions import AccessError
from odoo.addons.base.tests.common import TransactionCaseWithUserDemo
from odoo.tools import image_to_base64
from odoo.tools import image_to_base64, mute_logger
HASH_SPLIT = 2 # FIXME: testing implementations detail is not a good idea
@ -262,6 +263,28 @@ class TestIrAttachment(TransactionCaseWithUserDemo):
self.Attachment._gc_file_store_unsafe()
self.assertFalse(os.path.isfile(store_path), 'file removed')
def test_14_invalid_mimetype_with_correct_file_extension_no_post_processing(self):
# test with fake svg with png mimetype
unique_blob = b'<svg xmlns="http://www.w3.org/2000/svg"></svg>'
a1 = self.Attachment.create({'name': 'a1', 'raw': unique_blob, 'mimetype': 'image/png'})
self.assertEqual(a1.raw, unique_blob)
self.assertEqual(a1.mimetype, 'image/png')
def test_15_read_bin_size_doesnt_read_datas(self):
self.env.invalidate_all()
IrAttachment = self.registry['ir.attachment']
main_partner = self.env.ref('base.main_partner')
with patch.object(
IrAttachment,
'_file_read',
side_effect=IrAttachment._file_read,
autospec=True,
) as patch_file_read:
self.env['res.partner'].with_context(bin_size=True).search_read(
[('id', 'in', main_partner.ids)], ['image_128']
)
self.assertEqual(patch_file_read.call_count, 0)
class TestPermissions(TransactionCaseWithUserDemo):
def setUp(self):
@ -286,8 +309,10 @@ class TestPermissions(TransactionCaseWithUserDemo):
self.env.flush_all()
a.invalidate_recordset()
def test_no_read_permission(self):
def test_read_permission(self):
"""If the record can't be read, the attachment can't be read either
If the attachment is public, the attachment can be read even if the record can't be read
If the attachment has no res_model/res_id, it can be read by its author and admins only
"""
# check that the information can be read out of the box
self.attachment.datas
@ -297,6 +322,57 @@ class TestPermissions(TransactionCaseWithUserDemo):
with self.assertRaises(AccessError):
self.attachment.datas
# Make the attachment public
self.attachment.sudo().public = True
# Check the information can be read again
self.attachment.datas
# Remove the public access
self.attachment.sudo().public = False
# Check the record can no longer be accessed
with self.assertRaises(AccessError):
self.attachment.datas
# Create an attachment as user without res_model/res_id
attachment_user = self.Attachments.create({'name': 'foo'})
# Check the user can access his own attachment
attachment_user.datas
# Create an attachment as superuser without res_model/res_id
attachment_admin = self.Attachments.with_user(odoo.SUPERUSER_ID).create({'name': 'foo'})
# Check the record cannot be accessed by a regular user
with self.assertRaises(AccessError):
attachment_admin.with_user(self.env.user).datas
# Check the record can be accessed by an admin (other than superuser)
admin_user = self.env.ref('base.user_admin')
# Safety assert that base.user_admin is not the superuser, otherwise the test is useless
self.assertNotEqual(odoo.SUPERUSER_ID, admin_user.id)
attachment_admin.with_user(admin_user).datas
@mute_logger("odoo.addons.base.models.ir_rule", "odoo.models")
def test_field_read_permission(self):
"""If the record field can't be read,
e.g. `groups="base.group_system"` on the field,
the attachment can't be read either.
"""
# check that the information can be read out of the box
main_partner = self.env.ref('base.main_partner')
self.assertTrue(main_partner.image_128)
attachment = self.env['ir.attachment'].search([
('res_model', '=', 'res.partner'),
('res_id', '=', main_partner.id),
('res_field', '=', 'image_128')
])
self.assertTrue(attachment.datas)
# Patch the field `res.partner.image_128` to make it unreadable by the demo user
self.patch(self.env.registry['res.partner']._fields['image_128'], 'groups', 'base.group_system')
# Assert the field can't be read
with self.assertRaises(AccessError):
main_partner.image_128
# Assert the attachment related to the field can't be read
with self.assertRaises(AccessError):
attachment.datas
def test_with_write_permissions(self):
"""With write permissions to the linked record, attachment can be
created, updated, or deleted (or copied).
@ -326,7 +402,7 @@ class TestPermissions(TransactionCaseWithUserDemo):
wrinkles as the ACLs may diverge a lot more
"""
# create an other unwritable record in a different model
unwritable = self.env['res.users.log'].create({})
unwritable = self.env['res.users.apikeys.description'].create({'name': 'Unwritable'})
with self.assertRaises(AccessError):
unwritable.write({}) # checks unwritability
# create a writable record in the same model

View file

@ -1,12 +1,19 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import collections
import secrets
import textwrap
import threading
from concurrent.futures import ThreadPoolExecutor
from datetime import timedelta
from unittest.mock import patch
from unittest.mock import call, patch
from freezegun import freeze_time
from odoo import fields
from odoo.tests.common import TransactionCase, RecordCapturer, get_db_name
import odoo
from odoo import api, fields
from odoo.tests.common import BaseCase, TransactionCase, RecordCapturer, get_db_name, tagged
from odoo.tools import mute_logger
class CronMixinCase:
@ -31,6 +38,31 @@ class CronMixinCase:
domain=[('cron_id', '=', cron_id)] if cron_id else []
)
@classmethod
def _get_cron_data(cls, env, priority=5):
unique = secrets.token_urlsafe(8)
return {
'name': f'Dummy cron for TestIrCron {unique}',
'state': 'code',
'code': '',
'model_id': env.ref('base.model_res_partner').id,
'model_name': 'res.partner',
'user_id': env.uid,
'active': True,
'interval_number': 1,
'interval_type': 'days',
'numbercall': -1,
'doall': False,
'nextcall': fields.Datetime.now() + timedelta(hours=1),
'lastcall': False,
'priority': priority,
}
@classmethod
def _get_partner_data(cls, env):
unique = secrets.token_urlsafe(8)
return {'name': f'Dummy partner for TestIrCron {unique}'}
class TestIrCron(TransactionCase, CronMixinCase):
@ -42,40 +74,90 @@ class TestIrCron(TransactionCase, CronMixinCase):
cls.frozen_datetime = freezer.start()
cls.addClassCleanup(freezer.stop)
def setUp(self):
super(TestIrCron, self).setUp()
cls.cron = cls.env['ir.cron'].create(cls._get_cron_data(cls.env))
cls.partner = cls.env['res.partner'].create(cls._get_partner_data(cls.env))
self.cron = self.env['ir.cron'].create({
'name': 'TestCron',
'model_id': self.env.ref('base.model_res_partner').id,
'state': 'code',
'code': 'model.search([("name", "=", "TestCronRecord")]).write({"name": "You have been CRONWNED"})',
'interval_number': 1,
'interval_type': 'days',
'numbercall': -1,
'doall': False,
})
self.test_partner = self.env['res.partner'].create({
'name': 'TestCronRecord'
})
self.test_partner2 = self.env['res.partner'].create({
'name': 'NotTestCronRecord'
})
def setUp(self):
self.partner.write(self._get_partner_data(self.env))
self.cron.write(self._get_cron_data(self.env))
self.env['ir.cron.trigger'].search(
[('cron_id', '=', self.cron.id)]
).unlink()
def test_cron_direct_trigger(self):
self.assertFalse(self.cron.lastcall)
self.assertEqual(self.test_partner.name, 'TestCronRecord')
self.assertEqual(self.test_partner2.name, 'NotTestCronRecord')
self.cron.code = textwrap.dedent(f"""\
model.search(
[("id", "=", {self.partner.id})]
).write(
{{"name": "You have been CRONWNED"}}
)
""")
def patched_now(*args, **kwargs):
return '2020-10-22 08:00:00'
self.cron.method_direct_trigger()
with patch('odoo.fields.Datetime.now', patched_now):
self.cron.method_direct_trigger()
self.assertEqual(self.cron.lastcall, fields.Datetime.now())
self.assertEqual(self.partner.name, 'You have been CRONWNED')
self.assertEqual(fields.Datetime.to_string(self.cron.lastcall), '2020-10-22 08:00:00')
self.assertEqual(self.test_partner.name, 'You have been CRONWNED')
self.assertEqual(self.test_partner2.name, 'NotTestCronRecord')
def test_cron_no_job_ready(self):
self.cron.nextcall = fields.Datetime.now() + timedelta(days=1)
self.cron.flush_recordset()
ready_jobs = self.registry['ir.cron']._get_all_ready_jobs(self.cr)
self.assertNotIn(self.cron.id, [job['id'] for job in ready_jobs])
def test_cron_ready_by_nextcall(self):
self.cron.nextcall = fields.Datetime.now()
self.cron.flush_recordset()
ready_jobs = self.registry['ir.cron']._get_all_ready_jobs(self.cr)
self.assertIn(self.cron.id, [job['id'] for job in ready_jobs])
def test_cron_ready_by_trigger(self):
self.cron._trigger()
self.env['ir.cron.trigger'].flush_model()
ready_jobs = self.registry['ir.cron']._get_all_ready_jobs(self.cr)
self.assertIn(self.cron.id, [job['id'] for job in ready_jobs])
def test_cron_unactive_never_ready(self):
self.cron.active = False
self.cron.nextcall = fields.Datetime.now()
self.cron._trigger()
self.cron.flush_recordset()
self.env['ir.cron.trigger'].flush_model()
ready_jobs = self.registry['ir.cron']._get_all_ready_jobs(self.cr)
self.assertNotIn(self.cron.id, [job['id'] for job in ready_jobs])
def test_cron_numbercall0_never_ready(self):
self.cron.numbercall = 0
self.cron.nextcall = fields.Datetime.now()
self.cron._trigger()
self.cron.flush_recordset()
self.env['ir.cron.trigger'].flush_model()
ready_jobs = self.registry['ir.cron']._get_all_ready_jobs(self.cr)
self.assertNotIn(self.cron.id, [job['id'] for job in ready_jobs])
def test_cron_ready_jobs_order(self):
cron_avg = self.cron.copy()
cron_avg.priority = 5 # average priority
cron_high = self.cron.copy()
cron_high.priority = 0 # highest priority
cron_low = self.cron.copy()
cron_low.priority = 10 # lowest priority
crons = cron_high | cron_avg | cron_low # order is important
crons.write({'nextcall': fields.Datetime.now()})
crons.flush_recordset()
ready_jobs = self.registry['ir.cron']._get_all_ready_jobs(self.cr)
self.assertEqual(
[job['id'] for job in ready_jobs if job['id'] in crons._ids],
list(crons._ids),
)
def test_cron_skip_unactive_triggers(self):
# Situation: an admin disable the cron and another user triggers
@ -122,9 +204,203 @@ class TestIrCron(TransactionCase, CronMixinCase):
"cron should be ready")
self.assertTrue(capture.records, "trigger should has been kept")
def test_cron_process_job(self):
Setup = collections.namedtuple('Setup', ['doall', 'numbercall', 'missedcall', 'trigger'])
Expect = collections.namedtuple('Expect', ['call_count', 'call_left', 'active'])
matrix = [
(Setup(doall=False, numbercall=-1, missedcall=2, trigger=False),
Expect(call_count=1, call_left=-1, active=True)),
(Setup(doall=True, numbercall=-1, missedcall=2, trigger=False),
Expect(call_count=2, call_left=-1, active=True)),
(Setup(doall=False, numbercall=3, missedcall=2, trigger=False),
Expect(call_count=1, call_left=2, active=True)),
(Setup(doall=True, numbercall=3, missedcall=2, trigger=False),
Expect(call_count=2, call_left=1, active=True)),
(Setup(doall=True, numbercall=3, missedcall=4, trigger=False),
Expect(call_count=3, call_left=0, active=False)),
(Setup(doall=True, numbercall=3, missedcall=0, trigger=True),
Expect(call_count=1, call_left=2, active=True)),
]
for setup, expect in matrix:
with self.subTest(setup=setup, expect=expect):
self.cron.write({
'active': True,
'doall': setup.doall,
'numbercall': setup.numbercall,
'nextcall': fields.Datetime.now() - timedelta(days=setup.missedcall - 1),
})
with self.capture_triggers(self.cron.id) as capture:
if setup.trigger:
self.cron._trigger()
self.cron.flush_recordset()
capture.records.flush_recordset()
self.registry.enter_test_mode(self.cr)
try:
with patch.object(self.registry['ir.cron'], '_callback') as callback:
self.registry['ir.cron']._process_job(
self.registry.db_name,
self.registry.cursor(),
self.cron.read(load=None)[0]
)
finally:
self.registry.leave_test_mode()
self.cron.invalidate_recordset()
capture.records.invalidate_recordset()
self.assertEqual(callback.call_count, expect.call_count)
self.assertEqual(self.cron.numbercall, expect.call_left)
self.assertEqual(self.cron.active, expect.active)
self.assertEqual(self.cron.lastcall, fields.Datetime.now())
self.assertEqual(self.cron.nextcall, fields.Datetime.now() + timedelta(days=1))
self.assertEqual(self.env['ir.cron.trigger'].search_count([
('cron_id', '=', self.cron.id),
('call_at', '<=', fields.Datetime.now())]
), 0)
def test_cron_null_interval(self):
self.cron.interval_number = 0
self.cron.flush_recordset()
with self.assertLogs('odoo.addons.base.models.ir_cron', 'ERROR'):
self.cron._process_job(get_db_name(), self.env.cr, self.cron.read(load=False)[0])
self.cron.invalidate_recordset(['active'])
self.assertFalse(self.cron.active)
@tagged('-standard', '-at_install', 'post_install', 'database_breaking')
class TestIrCronConcurrent(BaseCase, CronMixinCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
# Keep a reference on the real cron methods, those without patch
cls.registry = odoo.registry(get_db_name())
cls.cron_process_job = cls.registry['ir.cron']._process_job
cls.cron_process_jobs = cls.registry['ir.cron']._process_jobs
cls.cron_get_all_ready_jobs = cls.registry['ir.cron']._get_all_ready_jobs
cls.cron_acquire_one_job = cls.registry['ir.cron']._acquire_one_job
cls.cron_callback = cls.registry['ir.cron']._callback
def setUp(self):
super().setUp()
with self.registry.cursor() as cr:
env = api.Environment(cr, odoo.SUPERUSER_ID, {})
env['ir.cron'].search([]).unlink()
env['ir.cron.trigger'].search([]).unlink()
self.cron1_data = env['ir.cron'].create(self._get_cron_data(env, priority=1)).read(load=None)[0]
self.cron2_data = env['ir.cron'].create(self._get_cron_data(env, priority=2)).read(load=None)[0]
self.partner_data = env['res.partner'].create(self._get_partner_data(env)).read(load=None)[0]
self.cron_ids = [self.cron1_data['id'], self.cron2_data['id']]
def test_cron_concurrency_1(self):
"""
Two cron threads "th1" and "th2" wake up at the same time and
see two jobs "job1" and "job2" that are ready (setup).
Th1 acquire job1, before it can process and release its job, th2
acquire a job too (setup). Th2 shouldn't be able to acquire job1
as another thread is processing it, it should skips job1 and
should acquire job2 instead (test). Both thread then process
their job, update its `nextcall` and release it (setup).
All the threads update and release their job before any thread
attempt to acquire another job. (setup)
The two thread each attempt to acquire a new job (setup), they
should both fail to acquire any as each job's nextcall is in the
future* (test).
*actually, in their own transaction, the other job's nextcall is
still "in the past" but any attempt to use that information
would result in a serialization error. This tests ensure that
that serialization error is correctly handled and ignored.
"""
lock = threading.Lock()
barrier = threading.Barrier(2)
###
# Setup
###
# Watchdog, if a thread was waiting at the barrier when the
# other exited, it receives a BrokenBarrierError and exits too.
def process_jobs(*args, **kwargs):
try:
self.cron_process_jobs(*args, **kwargs)
finally:
barrier.reset()
# The two threads get the same list of jobs
def get_all_ready_jobs(*args, **kwargs):
jobs = self.cron_get_all_ready_jobs(*args, **kwargs)
barrier.wait()
return jobs
# When a thread acquire a job, it processes it till the end
# before another thread can acquire one.
def acquire_one_job(*args, **kwargs):
lock.acquire(timeout=1)
try:
with mute_logger('odoo.sql_db'):
job = self.cron_acquire_one_job(*args, **kwargs)
except Exception:
lock.release()
raise
if not job:
lock.release()
return job
# When a thread is done processing its job, it waits for the
# other thread to catch up.
def process_job(*args, **kwargs):
try:
return_value = self.cron_process_job(*args, **kwargs)
finally:
lock.release()
barrier.wait(timeout=1)
return return_value
# Set 2 jobs ready, process them in 2 different threads.
with self.registry.cursor() as cr:
env = api.Environment(cr, odoo.SUPERUSER_ID, {})
env['ir.cron'].browse(self.cron_ids).write({
'nextcall': fields.Datetime.now() - timedelta(hours=1)
})
###
# Run
###
with patch.object(self.registry['ir.cron'], '_process_jobs', process_jobs), \
patch.object(self.registry['ir.cron'], '_get_all_ready_jobs', get_all_ready_jobs), \
patch.object(self.registry['ir.cron'], '_acquire_one_job', acquire_one_job), \
patch.object(self.registry['ir.cron'], '_process_job', process_job), \
patch.object(self.registry['ir.cron'], '_callback') as callback, \
ThreadPoolExecutor(max_workers=2) as executor:
fut1 = executor.submit(self.registry['ir.cron']._process_jobs, self.registry.db_name)
fut2 = executor.submit(self.registry['ir.cron']._process_jobs, self.registry.db_name)
fut1.result(timeout=2)
fut2.result(timeout=2)
###
# Validation
###
self.assertEqual(len(callback.call_args_list), 2, 'Two jobs must have been processed.')
self.assertEqual(callback.call_args_list, [
call(
self.cron1_data['name'],
self.cron1_data['ir_actions_server_id'],
self.cron1_data['id'],
),
call(
self.cron2_data['name'],
self.cron2_data['ir_actions_server_id'],
self.cron2_data['id'],
),
])

View file

@ -25,29 +25,29 @@ class TestIrDefault(TransactionCase):
# set a default value for all users
IrDefault1.search([('field_id.model', '=', 'res.partner')]).unlink()
IrDefault1.set('res.partner', 'ref', 'GLOBAL', user_id=False, company_id=False)
self.assertEqual(IrDefault1.get_model_defaults('res.partner'), {'ref': 'GLOBAL'},
self.assertEqual(IrDefault1._get_model_defaults('res.partner'), {'ref': 'GLOBAL'},
"Can't retrieve the created default value for all users.")
self.assertEqual(IrDefault2.get_model_defaults('res.partner'), {'ref': 'GLOBAL'},
self.assertEqual(IrDefault2._get_model_defaults('res.partner'), {'ref': 'GLOBAL'},
"Can't retrieve the created default value for all users.")
self.assertEqual(IrDefault3.get_model_defaults('res.partner'), {'ref': 'GLOBAL'},
self.assertEqual(IrDefault3._get_model_defaults('res.partner'), {'ref': 'GLOBAL'},
"Can't retrieve the created default value for all users.")
# set a default value for current company (behavior of 'set default' from debug mode)
IrDefault1.set('res.partner', 'ref', 'COMPANY', user_id=False, company_id=True)
self.assertEqual(IrDefault1.get_model_defaults('res.partner'), {'ref': 'COMPANY'},
self.assertEqual(IrDefault1._get_model_defaults('res.partner'), {'ref': 'COMPANY'},
"Can't retrieve the created default value for company.")
self.assertEqual(IrDefault2.get_model_defaults('res.partner'), {'ref': 'COMPANY'},
self.assertEqual(IrDefault2._get_model_defaults('res.partner'), {'ref': 'COMPANY'},
"Can't retrieve the created default value for company.")
self.assertEqual(IrDefault3.get_model_defaults('res.partner'), {'ref': 'GLOBAL'},
self.assertEqual(IrDefault3._get_model_defaults('res.partner'), {'ref': 'GLOBAL'},
"Unexpected default value for company.")
# set a default value for current user (behavior of 'set default' from debug mode)
IrDefault2.set('res.partner', 'ref', 'USER', user_id=True, company_id=True)
self.assertEqual(IrDefault1.get_model_defaults('res.partner'), {'ref': 'COMPANY'},
self.assertEqual(IrDefault1._get_model_defaults('res.partner'), {'ref': 'COMPANY'},
"Can't retrieve the created default value for user.")
self.assertEqual(IrDefault2.get_model_defaults('res.partner'), {'ref': 'USER'},
self.assertEqual(IrDefault2._get_model_defaults('res.partner'), {'ref': 'USER'},
"Unexpected default value for user.")
self.assertEqual(IrDefault3.get_model_defaults('res.partner'), {'ref': 'GLOBAL'},
self.assertEqual(IrDefault3._get_model_defaults('res.partner'), {'ref': 'GLOBAL'},
"Unexpected default value for company.")
# check default values on partners
@ -65,20 +65,20 @@ class TestIrDefault(TransactionCase):
# default without condition
IrDefault.search([('field_id.model', '=', 'res.partner')]).unlink()
IrDefault.set('res.partner', 'ref', 'X')
self.assertEqual(IrDefault.get_model_defaults('res.partner'),
self.assertEqual(IrDefault._get_model_defaults('res.partner'),
{'ref': 'X'})
self.assertEqual(IrDefault.get_model_defaults('res.partner', condition='name=Agrolait'),
self.assertEqual(IrDefault._get_model_defaults('res.partner', condition='name=Agrolait'),
{})
# default with a condition
IrDefault.search([('field_id.model', '=', 'res.partner.title')]).unlink()
IrDefault.set('res.partner.title', 'shortcut', 'X')
IrDefault.set('res.partner.title', 'shortcut', 'Mr', condition='name=Mister')
self.assertEqual(IrDefault.get_model_defaults('res.partner.title'),
self.assertEqual(IrDefault._get_model_defaults('res.partner.title'),
{'shortcut': 'X'})
self.assertEqual(IrDefault.get_model_defaults('res.partner.title', condition='name=Miss'),
self.assertEqual(IrDefault._get_model_defaults('res.partner.title', condition='name=Miss'),
{})
self.assertEqual(IrDefault.get_model_defaults('res.partner.title', condition='name=Mister'),
self.assertEqual(IrDefault._get_model_defaults('res.partner.title', condition='name=Mister'),
{'shortcut': 'Mr'})
def test_invalid(self):
@ -103,11 +103,11 @@ class TestIrDefault(TransactionCase):
# set a record as a default value
title = self.env['res.partner.title'].create({'name': 'President'})
IrDefault.set('res.partner', 'title', title.id)
self.assertEqual(IrDefault.get_model_defaults('res.partner'), {'title': title.id})
self.assertEqual(IrDefault._get_model_defaults('res.partner'), {'title': title.id})
# delete the record, and check the presence of the default value
title.unlink()
self.assertEqual(IrDefault.get_model_defaults('res.partner'), {})
self.assertEqual(IrDefault._get_model_defaults('res.partner'), {})
def test_multi_company_defaults(self):
"""Check defaults in multi-company environment."""
@ -126,23 +126,23 @@ class TestIrDefault(TransactionCase):
IrDefault.with_context(allowed_company_ids=company_b.ids).set(
'res.partner', 'ref', 'CBDefault', user_id=True, company_id=True)
self.assertEqual(
IrDefault.get_model_defaults('res.partner')['ref'],
IrDefault._get_model_defaults('res.partner')['ref'],
'CADefault',
)
self.assertEqual(
IrDefault.with_context(allowed_company_ids=company_a.ids).get_model_defaults('res.partner')['ref'],
IrDefault.with_context(allowed_company_ids=company_a.ids)._get_model_defaults('res.partner')['ref'],
'CADefault',
)
self.assertEqual(
IrDefault.with_context(allowed_company_ids=company_b.ids).get_model_defaults('res.partner')['ref'],
IrDefault.with_context(allowed_company_ids=company_b.ids)._get_model_defaults('res.partner')['ref'],
'CBDefault',
)
self.assertEqual(
IrDefault.with_context(allowed_company_ids=company_a_b.ids).get_model_defaults('res.partner')['ref'],
IrDefault.with_context(allowed_company_ids=company_a_b.ids)._get_model_defaults('res.partner')['ref'],
'CADefault',
)
self.assertEqual(
IrDefault.with_context(allowed_company_ids=company_b_a.ids).get_model_defaults('res.partner')['ref'],
IrDefault.with_context(allowed_company_ids=company_b_a.ids)._get_model_defaults('res.partner')['ref'],
'CBDefault',
)

View file

@ -12,12 +12,12 @@ _logger = logging.getLogger(__name__)
class TestIrHttpPerformances(TransactionCase):
def test_routing_map_performance(self):
self.env['ir.http']._clear_routing_map()
self.env.registry.clear_cache('routing')
# if the routing map was already generated it is possible that some compiled regex are in cache.
# we want to mesure the cold state, when the worker just spawned, we need to empty the re cache
re._cache.clear()
self.env['ir.http']._clear_routing_map()
self.env.registry.clear_cache('routing')
start = time.time()
self.env['ir.http'].routing_map()
duration = time.time() - start

View file

@ -1,61 +1,107 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import email.message
import email.policy
from unittest.mock import patch
from odoo import tools
from odoo.addons.base.tests import test_mail_examples
from odoo.addons.base.tests.common import MockSmtplibCase
from odoo.tests import tagged
from odoo.tests import tagged, users
from odoo.tests.common import TransactionCase
from odoo.tools import mute_logger
from odoo.tools import config
class _FakeSMTP:
"""SMTP stub"""
def __init__(self):
self.messages = []
self.from_filter = 'example.com'
# Python 3 before 3.7.4
def sendmail(self, smtp_from, smtp_to_list, message_str,
mail_options=(), rcpt_options=()):
self.messages.append(message_str)
# Python 3.7.4+
def send_message(self, message, smtp_from, smtp_to_list,
mail_options=(), rcpt_options=()):
self.messages.append(message.as_string())
@tagged('mail_server')
class EmailConfigCase(TransactionCase):
@patch.dict(config.options, {"email_from": "settings@example.com"})
def test_default_email_from(self):
""" Email from setting is respected and comes from configuration. """
message = self.env["ir.mail_server"].build_email(
False, "recipient@example.com", "Subject",
"The body of an email",
)
self.assertEqual(message["From"], "settings@example.com")
@tagged('mail_server')
class TestIrMailServer(TransactionCase, MockSmtplibCase):
def setUp(self):
self._init_mail_config()
self._init_mail_servers()
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.env['ir.config_parameter'].sudo().set_param('mail.default.from_filter', False)
cls._init_mail_servers()
def _build_email(self, mail_from, return_path=None):
return self.env['ir.mail_server'].build_email(
email_from=mail_from,
email_to='dest@example-é.com',
subject='subject', body='body',
headers={'Return-Path': return_path} if return_path else None
def test_assert_base_values(self):
self.assertFalse(self.env['ir.mail_server']._get_default_bounce_address())
self.assertFalse(self.env['ir.mail_server']._get_default_from_address())
def test_bpo_34424_35805(self):
"""Ensure all email sent are bpo-34424 and bpo-35805 free"""
fake_smtp = _FakeSMTP()
msg = email.message.EmailMessage(policy=email.policy.SMTP)
msg['From'] = '"Joé Doe" <joe@example.com>'
msg['To'] = '"Joé Doe" <joe@example.com>'
# Message-Id & References fields longer than 77 chars (bpo-35805)
msg['Message-Id'] = '<929227342217024.1596730490.324691772460938-example-30661-some.reference@test-123.example.com>'
msg['References'] = '<345227342212345.1596730777.324691772483620-example-30453-other.reference@test-123.example.com>'
msg_on_the_wire = self._send_email(msg, fake_smtp)
self.assertEqual(msg_on_the_wire,
'From: =?utf-8?q?Jo=C3=A9?= Doe <joe@example.com>\r\n'
'To: =?utf-8?q?Jo=C3=A9?= Doe <joe@example.com>\r\n'
'Message-Id: <929227342217024.1596730490.324691772460938-example-30661-some.reference@test-123.example.com>\r\n'
'References: <345227342212345.1596730777.324691772483620-example-30453-other.reference@test-123.example.com>\r\n'
'\r\n'
)
def test_match_from_filter(self):
"""Test the from_filter field on the "ir.mail_server"."""
match_from_filter = self.env['ir.mail_server']._match_from_filter
def test_content_alternative_correct_order(self):
"""
RFC-1521 7.2.3. The Multipart/alternative subtype
> the alternatives appear in an order of increasing faithfulness
> to the original content. In general, the best choice is the
> LAST part of a type supported by the recipient system's local
> environment.
# Should match
tests = [
('admin@mail.example.com', 'mail.example.com'),
('admin@mail.example.com', 'mail.EXAMPLE.com'),
('admin@mail.example.com', 'admin@mail.example.com'),
('admin@mail.example.com', False),
('"fake@test.com" <admin@mail.example.com>', 'mail.example.com'),
('"fake@test.com" <ADMIN@mail.example.com>', 'mail.example.com'),
]
for email, from_filter in tests:
self.assertTrue(match_from_filter(email, from_filter))
Also, the MIME-Version header should be present in BOTH the
enveloppe AND the parts
"""
fake_smtp = _FakeSMTP()
msg = self._build_email("test@example.com", body='<p>Hello world</p>', subtype='html')
msg_on_the_wire = self._send_email(msg, fake_smtp)
# Should not match
tests = [
('admin@mail.example.com', 'test@mail.example.com'),
('admin@mail.example.com', 'test.com'),
('admin@mail.example.com', 'mail.éxample.com'),
('admin@mmail.example.com', 'mail.example.com'),
('admin@mail.example.com', 'mmail.example.com'),
('"admin@mail.example.com" <fake@test.com>', 'mail.example.com'),
]
for email, from_filter in tests:
self.assertFalse(match_from_filter(email, from_filter))
self.assertGreater(msg_on_the_wire.index('text/html'), msg_on_the_wire.index('text/plain'),
"The html part should be preferred (=appear after) to the text part")
self.assertEqual(msg_on_the_wire.count('==============='), 2 + 2, # +2 for the header and the footer
"There should be 2 parts: one text and one html")
self.assertEqual(msg_on_the_wire.count('MIME-Version: 1.0'), 3,
"There should be 3 headers MIME-Version: one on the enveloppe, "
"one on the html part, one on the text part")
def test_mail_body(self):
def test_content_mail_body(self):
bodies = [
'content',
'<p>content</p>',
@ -90,190 +136,185 @@ class TestIrMailServer(TransactionCase, MockSmtplibCase):
body_alternative = body_alternative.strip('\n')
self.assertEqual(body_alternative, expected)
@users('admin')
def test_mail_server_get_test_email_from(self):
""" Test the email used to test the mail server connection. Check
from_filter parsing / default fallback value. """
test_server = self.env['ir.mail_server'].create({
'from_filter': 'example_2.com, example_3.com',
'name': 'Test Server',
'smtp_host': 'smtp_host',
'smtp_encryption': 'none',
})
for from_filter, expected_test_email in zip(
[
'example_2.com, example_3.com',
'dummy.com, full_email@example_2.com, dummy2.com',
# fallback on user's email
' ',
',',
False,
], [
'noreply@example_2.com',
'full_email@example_2.com',
self.env.user.email,
self.env.user.email,
self.env.user.email,
],
):
with self.subTest(from_filter=from_filter):
test_server.from_filter = from_filter
email_from = test_server._get_test_email_from()
self.assertEqual(email_from, expected_test_email)
def test_mail_server_match_from_filter(self):
""" Test the from_filter field on the "ir.mail_server". """
# Should match
tests = [
('admin@mail.example.com', 'mail.example.com'),
('admin@mail.example.com', 'mail.EXAMPLE.com'),
('admin@mail.example.com', 'admin@mail.example.com'),
('admin@mail.example.com', False),
('"fake@test.mycompany.com" <admin@mail.example.com>', 'mail.example.com'),
('"fake@test.mycompany.com" <ADMIN@mail.example.com>', 'mail.example.com'),
('"fake@test.mycompany.com" <ADMIN@mail.example.com>', 'test.mycompany.com, mail.example.com, test2.com'),
]
for email, from_filter in tests:
self.assertTrue(self.env['ir.mail_server']._match_from_filter(email, from_filter))
# Should not match
tests = [
('admin@mail.example.com', 'test@mail.example.com'),
('admin@mail.example.com', 'test.mycompany.com'),
('admin@mail.example.com', 'mail.éxample.com'),
('admin@mmail.example.com', 'mail.example.com'),
('admin@mail.example.com', 'mmail.example.com'),
('"admin@mail.example.com" <fake@test.mycompany.com>', 'mail.example.com'),
('"fake@test.mycompany.com" <ADMIN@mail.example.com>', 'test.mycompany.com, wrong.mail.example.com, test3.com'),
]
for email, from_filter in tests:
self.assertFalse(self.env['ir.mail_server']._match_from_filter(email, from_filter))
@mute_logger('odoo.models.unlink')
def test_mail_server_priorities(self):
"""Test if we choose the right mail server to send an email.
""" Test if we choose the right mail server to send an email. Simulates
simple Odoo DB so we have to spoof the FROM otherwise we cannot send
any email. """
for email_from, (expected_mail_server, expected_email_from) in zip(
[
'specific_user@test.mycompany.com',
'unknown_email@test.mycompany.com',
# no notification set, must be forced to spoof the FROM
'"Test" <test@unknown_domain.com>',
], [
(self.mail_server_user, 'specific_user@test.mycompany.com'),
(self.mail_server_domain, 'unknown_email@test.mycompany.com'),
(self.mail_server_default, '"Test" <test@unknown_domain.com>'),
],
):
with self.subTest(email_from=email_from):
mail_server, mail_from = self.env['ir.mail_server']._find_mail_server(email_from=email_from)
self.assertEqual(mail_server, expected_mail_server)
self.assertEqual(mail_from, expected_email_from)
Priorities are
1. Forced mail server (e.g.: in mass mailing)
- If the "from_filter" of the mail server match the notification email
use the notifications email in the "From header"
- Otherwise spoof the "From" (because we force the mail server but we don't
know which email use to send it)
2. A mail server for which the "from_filter" match the "From" header
3. A mail server for which the "from_filter" match the domain of the "From" header
4. The mail server used for notifications
5. A mail server without "from_filter" (and so spoof the "From" header because we
do not know for which email address it can be used)
"""
# sanity checks
self.assertTrue(self.env['ir.mail_server']._get_default_from_address(), 'Notifications email must be set for testing')
self.assertTrue(self.env['ir.mail_server']._get_default_bounce_address(), 'Bounce email must be set for testing')
mail_server, mail_from = self.env['ir.mail_server']._find_mail_server(email_from='specific_user@test.com')
self.assertEqual(mail_server, self.server_user)
self.assertEqual(mail_from, 'specific_user@test.com')
mail_server, mail_from = self.env['ir.mail_server']._find_mail_server(email_from='"Name name@strange.name" <specific_user@test.com>')
self.assertEqual(mail_server, self.server_user, 'Must extract email from full name')
self.assertEqual(mail_from, '"Name name@strange.name" <specific_user@test.com>', 'Must keep the given mail from')
# Should not be case sensitive
mail_server, mail_from = self.env['ir.mail_server']._find_mail_server(email_from='specific_user@test.com')
self.assertEqual(mail_server, self.server_user, 'Mail from is case insensitive')
self.assertEqual(mail_from, 'specific_user@test.com', 'Should not change the mail from')
mail_server, mail_from = self.env['ir.mail_server']._find_mail_server(email_from='unknown_email@test.com')
self.assertEqual(mail_server, self.server_domain)
self.assertEqual(mail_from, 'unknown_email@test.com')
# Cover a different condition that the "email case insensitive" test
mail_server, mail_from = self.env['ir.mail_server']._find_mail_server(email_from='unknown_email@TEST.COM')
self.assertEqual(mail_server, self.server_domain, 'Domain is case insensitive')
self.assertEqual(mail_from, 'unknown_email@TEST.COM', 'Domain is case insensitive')
mail_server, mail_from = self.env['ir.mail_server']._find_mail_server(email_from='"Test" <test@unknown_domain.com>')
self.assertEqual(mail_server, self.server_notification, 'Should take the notification email')
self.assertEqual(mail_from, 'notifications@test.com')
# test if notification server is selected if email_from = False
mail_server, mail_from = self.env['ir.mail_server']._find_mail_server(email_from=False)
self.assertEqual(mail_server, self.server_notification,
'Should select the notification email server if passed FROM address was False')
self.assertEqual(mail_from, 'notifications@test.com')
# remove the notifications email to simulate a mis-configured Odoo database
# so we do not have the choice, we have to spoof the FROM
# (otherwise we can not send the email)
self.env['ir.config_parameter'].sudo().set_param('mail.catchall.domain', False)
with mute_logger('odoo.addons.base.models.ir_mail_server'):
mail_server, mail_from = self.env['ir.mail_server']._find_mail_server(email_from='test@unknown_domain.com')
self.assertEqual(mail_server.from_filter, False, 'No notifications email set, must be forced to spoof the FROM')
self.assertEqual(mail_from, 'test@unknown_domain.com')
@mute_logger('odoo.models.unlink', 'odoo.addons.base.models.ir_mail_server')
@mute_logger('odoo.models.unlink')
def test_mail_server_send_email(self):
""" Test main 'send_email' usage: check mail_server choice based on from
filters, encapsulation, spoofing. """
IrMailServer = self.env['ir.mail_server']
default_bounce_adress = self.env['ir.mail_server']._get_default_bounce_address()
# A mail server is configured for the email
with self.mock_smtplib_connection():
message = self._build_email(mail_from='specific_user@test.com')
IrMailServer.send_email(message)
for mail_from, (expected_smtp_from, expected_msg_from, expected_mail_server) in zip(
[
'specific_user@test.mycompany.com',
'"Name" <test@unknown_domain.com>',
'test@unknown_domain.com',
'"Name" <unknown_name@test.mycompany.com>'
], [
# A mail server is configured for the email
('specific_user@test.mycompany.com', 'specific_user@test.mycompany.com', self.mail_server_user),
# No mail server are configured for the email address, so it will use the
# notifications email instead and encapsulate the old email
('test@unknown_domain.com', '"Name" <test@unknown_domain.com>', self.mail_server_default),
# same situation, but the original email has no name part
('test@unknown_domain.com', 'test@unknown_domain.com', self.mail_server_default),
# A mail server is configured for the entire domain name, so we can use the bounce
# email address because the mail server supports it
('unknown_name@test.mycompany.com', '"Name" <unknown_name@test.mycompany.com>', self.mail_server_domain),
]
):
# test with and without providing an SMTP session, which should not impact test
for provide_smtp in [False, True]:
with self.subTest(mail_from=mail_from, provide_smtp=provide_smtp):
with self.mock_smtplib_connection():
if provide_smtp:
smtp_session = IrMailServer.connect(smtp_from=mail_from)
message = self._build_email(mail_from=mail_from)
IrMailServer.send_email(message, smtp_session=smtp_session)
else:
message = self._build_email(mail_from=mail_from)
IrMailServer.send_email(message)
self.assertEqual(len(self.emails), 1)
self.assert_email_sent_smtp(
smtp_from='specific_user@test.com',
message_from='specific_user@test.com',
from_filter='specific_user@test.com',
)
# No mail server are configured for the email address,
# so it will use the notifications email instead and encapsulate the old email
with self.mock_smtplib_connection():
message = self._build_email(mail_from='"Name" <test@unknown_domain.com>')
IrMailServer.send_email(message)
self.assertEqual(len(self.emails), 1)
self.assert_email_sent_smtp(
smtp_from='notifications@test.com',
message_from='"Name" <notifications@test.com>',
from_filter='notifications@test.com',
)
# Same situation, but the original email has no name part
with self.mock_smtplib_connection():
message = self._build_email(mail_from='test@unknown_domain.com')
IrMailServer.send_email(message)
self.assertEqual(len(self.emails), 1)
self.assert_email_sent_smtp(
smtp_from='notifications@test.com',
message_from='"test" <notifications@test.com>',
from_filter='notifications@test.com',
)
# A mail server is configured for the entire domain name, so we can use the bounce
# email address because the mail server supports it
with self.mock_smtplib_connection():
message = self._build_email(mail_from='unknown_name@test.com')
IrMailServer.send_email(message)
self.assertEqual(len(self.emails), 1)
self.assert_email_sent_smtp(
smtp_from=default_bounce_adress,
message_from='unknown_name@test.com',
from_filter='test.com',
)
self.connect_mocked.assert_called_once()
self.assertEqual(len(self.emails), 1)
self.assertSMTPEmailsSent(
smtp_from=expected_smtp_from,
message_from=expected_msg_from,
mail_server=expected_mail_server,
)
# remove the notification server
# so <notifications@test.com> will use the <test.com> mail server
self.server_notification.unlink()
# so <notifications.test@test.mycompany.com> will use the <test.mycompany.com> mail server
# The mail server configured for the notifications email has been removed
# but we can still use the mail server configured for test.com
# but we can still use the mail server configured for test.mycompany.com
# and so we will be able to use the bounce address
# because we use the mail server for "test.com"
# because we use the mail server for "test.mycompany.com"
self.mail_server_notification.unlink()
for provide_smtp in [False, True]:
with self.mock_smtplib_connection():
if provide_smtp:
smtp_session = IrMailServer.connect(smtp_from='"Name" <test@unknown_domain.com>')
message = self._build_email(mail_from='"Name" <test@unknown_domain.com>')
IrMailServer.send_email(message, smtp_session=smtp_session)
else:
message = self._build_email(mail_from='"Name" <test@unknown_domain.com>')
IrMailServer.send_email(message)
self.connect_mocked.assert_called_once()
self.assertEqual(len(self.emails), 1)
self.assertSMTPEmailsSent(
smtp_from='test@unknown_domain.com',
message_from='"Name" <test@unknown_domain.com>',
from_filter=False,
)
@mute_logger('odoo.models.unlink', 'odoo.addons.base.models.ir_mail_server')
def test_mail_server_send_email_context_force(self):
""" Allow to force notifications_email / bounce_address from context
to allow higher-level apps to send values until end of mail stack
without hacking too much models. """
# custom notification / bounce email from context
context_server = self.env['ir.mail_server'].create({
'from_filter': 'context.example.com',
'name': 'context',
'smtp_host': 'test',
})
IrMailServer = self.env["ir.mail_server"].with_context(
domain_notifications_email="notification@context.example.com",
domain_bounce_address="bounce@context.example.com",
)
with self.mock_smtplib_connection():
mail_server, smtp_from = IrMailServer._find_mail_server(email_from='"Name" <test@unknown_domain.com>')
self.assertEqual(mail_server, context_server)
self.assertEqual(smtp_from, "notification@context.example.com")
smtp_session = IrMailServer.connect(smtp_from=smtp_from)
message = self._build_email(mail_from='"Name" <test@unknown_domain.com>')
IrMailServer.send_email(message)
IrMailServer.send_email(message, smtp_session=smtp_session)
self.assertEqual(len(self.emails), 1)
self.assert_email_sent_smtp(
smtp_from=default_bounce_adress,
message_from='"Name" <notifications@test.com>',
from_filter='test.com',
)
# Test that the mail from / recipient envelop are encoded using IDNA
self.server_domain.from_filter = 'ééééééé.com'
self.env['ir.config_parameter'].sudo().set_param('mail.catchall.domain', 'ééééééé.com')
with self.mock_smtplib_connection():
message = self._build_email(mail_from='test@ééééééé.com')
IrMailServer.send_email(message)
self.assertEqual(len(self.emails), 1)
self.assert_email_sent_smtp(
smtp_from='bounce.test@xn--9caaaaaaa.com',
smtp_to_list=['dest@xn--example--i1a.com'],
message_from='test@=?utf-8?b?w6nDqcOpw6nDqcOpw6k=?=.com',
from_filter='ééééééé.com',
)
# Test the case when the "mail.default.from" contains a full email address and not just the local part
# the domain of this default email address can be different than the catchall domain
self.env['ir.config_parameter'].sudo().set_param('mail.default.from', 'test@custom_domain.com')
self.server_default.from_filter = 'custom_domain.com'
with self.mock_smtplib_connection():
message = self._build_email(mail_from='"Name" <test@unknown_domain.com>')
IrMailServer.send_email(message)
self.assert_email_sent_smtp(
smtp_from='test@custom_domain.com',
smtp_to_list=['dest@xn--example--i1a.com'],
message_from='"Name" <test@custom_domain.com>',
from_filter='custom_domain.com',
)
# Test when forcing the mail server and when smtp_encryption is "starttls"
self.server_domain.smtp_encryption = "starttls"
self.server_domain.from_filter = "test.com"
with self.mock_smtplib_connection():
message = self._build_email(mail_from='specific_user@test.com')
IrMailServer.send_email(message, mail_server_id=self.server_domain.id)
self.connect_mocked.assert_called_once()
self.assert_email_sent_smtp(
smtp_from='specific_user@test.com',
message_from='specific_user@test.com',
from_filter='test.com',
self.assertSMTPEmailsSent(
smtp_from="bounce@context.example.com",
message_from='"Name" <notification@context.example.com>',
from_filter=context_server.from_filter,
)
# miss-configured database, no mail servers from filter
@ -281,230 +322,146 @@ class TestIrMailServer(TransactionCase, MockSmtplibCase):
self.env['ir.mail_server'].search([]).from_filter = "random.domain"
with self.mock_smtplib_connection():
message = self._build_email(mail_from='specific_user@test.com')
IrMailServer.send_email(message)
IrMailServer.with_context(domain_notifications_email='test@custom_domain.com').send_email(message)
self.connect_mocked.assert_called_once()
self.assert_email_sent_smtp(
self.assertSMTPEmailsSent(
smtp_from='test@custom_domain.com',
message_from='"specific_user" <test@custom_domain.com>',
from_filter='random.domain',
)
@mute_logger('odoo.models.unlink')
def test_mail_server_send_email_smtp_session(self):
"""Test all the cases when we provide the SMTP session.
def test_mail_server_send_email_IDNA(self):
""" Test that the mail from / recipient envelop are encoded using IDNA """
with self.mock_smtplib_connection():
message = self._build_email(mail_from='test@ééééééé.com')
self.env['ir.mail_server'].send_email(message)
The results must be the same as passing directly the parameter to "send_email".
self.assertEqual(len(self.emails), 1)
self.assertSMTPEmailsSent(
smtp_from='test@xn--9caaaaaaa.com',
smtp_to_list=['dest@xn--example--i1a.com'],
message_from='test@=?utf-8?b?w6nDqcOpw6nDqcOpw6k=?=.com',
from_filter=False,
)
@mute_logger('odoo.models.unlink', 'odoo.addons.base.models.ir_mail_server')
@patch.dict(config.options, {
"from_filter": "dummy@example.com, test.mycompany.com, dummy2@example.com",
"smtp_server": "example.com",
})
def test_mail_server_config_bin(self):
""" Test the configuration provided in the odoo-bin arguments. This config
is used when no mail server exists. Test with and without giving a
pre-configured SMTP session, should not impact results.
Also check "mail.default.from_filter" parameter usage that should overwrite
odoo-bin argument "--from-filter".
"""
IrMailServer = self.env['ir.mail_server']
default_bounce_adress = self.env['ir.mail_server']._get_default_bounce_address()
# A mail server is configured for the email
with self.mock_smtplib_connection():
smtp_session = IrMailServer.connect(smtp_from='specific_user@test.com')
message = self._build_email(mail_from='specific_user@test.com')
IrMailServer.send_email(message, smtp_session=smtp_session)
self.connect_mocked.assert_called_once()
self.assert_email_sent_smtp(
smtp_from='specific_user@test.com',
message_from='specific_user@test.com',
from_filter='specific_user@test.com',
)
# No mail server are configured for the email address,
# so it will use the notifications email instead and encapsulate the old email
with self.mock_smtplib_connection():
smtp_session = IrMailServer.connect(smtp_from='"Name" <test@unknown_domain.com>')
message = self._build_email(mail_from='"Name" <test@unknown_domain.com>')
IrMailServer.send_email(message, smtp_session=smtp_session)
self.connect_mocked.assert_called_once()
self.assert_email_sent_smtp(
smtp_from='notifications@test.com',
message_from='"Name" <notifications@test.com>',
from_filter='notifications@test.com',
)
# A mail server is configured for the entire domain name, so we can use the bounce
# email address because the mail server supports it
with self.mock_smtplib_connection():
smtp_session = IrMailServer.connect(smtp_from='unknown_name@test.com')
message = self._build_email(mail_from='unknown_name@test.com')
IrMailServer.send_email(message, smtp_session=smtp_session)
self.connect_mocked.assert_called_once()
self.assert_email_sent_smtp(
smtp_from=default_bounce_adress,
message_from='unknown_name@test.com',
from_filter='test.com',
)
# remove the notification server
# so <notifications@test.com> will use the <test.com> mail server
self.server_notification.unlink()
# The mail server configured for the notifications email has been removed
# but we can still use the mail server configured for test.com
with self.mock_smtplib_connection():
smtp_session = IrMailServer.connect(smtp_from='"Name" <test@unknown_domain.com>')
message = self._build_email(mail_from='"Name" <test@unknown_domain.com>')
IrMailServer.send_email(message, smtp_session=smtp_session)
self.connect_mocked.assert_called_once()
self.assert_email_sent_smtp(
smtp_from=default_bounce_adress,
message_from='"Name" <notifications@test.com>',
from_filter='test.com',
)
@mute_logger('odoo.models.unlink')
@patch.dict(config.options, {"from_filter": "test.com", "smtp_server": "example.com"})
def test_mail_server_binary_arguments_domain(self):
"""Test the configuration provided in the odoo-bin arguments.
This config is used when no mail server exists.
"""
IrMailServer = self.env['ir.mail_server']
default_bounce_adress = self.env['ir.mail_server']._get_default_bounce_address()
# Remove all mail server so we will use the odoo-bin arguments
self.env['ir.mail_server'].search([]).unlink()
self.assertFalse(self.env['ir.mail_server'].search([]))
# Use an email in the domain of the "from_filter"
with self.mock_smtplib_connection():
message = self._build_email(mail_from='specific_user@test.com')
IrMailServer.send_email(message)
self.connect_mocked.assert_called_once()
self.assert_email_sent_smtp(
smtp_from=default_bounce_adress,
message_from='specific_user@test.com',
from_filter='test.com',
)
# Test if the domain name is normalized before comparison
with self.mock_smtplib_connection():
message = self._build_email(mail_from='specific_user@test.com')
IrMailServer.send_email(message)
self.connect_mocked.assert_called_once()
self.assert_email_sent_smtp(
smtp_from=default_bounce_adress,
message_from='specific_user@test.com',
from_filter='test.com',
)
# Use an email outside of the domain of the "from_filter"
# So we will use the notifications email in the headers and the bounce address
# in the envelop because the "from_filter" allows to use the entire domain
with self.mock_smtplib_connection():
message = self._build_email(mail_from='test@unknown_domain.com')
IrMailServer.send_email(message)
self.connect_mocked.assert_called_once()
self.assert_email_sent_smtp(
smtp_from=default_bounce_adress,
message_from='"test" <notifications@test.com>',
from_filter='test.com',
)
@mute_logger('odoo.models.unlink')
@patch.dict(config.options, {"from_filter": "test.com", "smtp_server": "example.com"})
def test_mail_server_binary_arguments_domain_smtp_session(self):
"""Test the configuration provided in the odoo-bin arguments.
This config is used when no mail server exists.
Use a pre-configured SMTP session.
"""
IrMailServer = self.env['ir.mail_server']
default_bounce_adress = self.env['ir.mail_server']._get_default_bounce_address()
# Remove all mail server so we will use the odoo-bin arguments
self.env['ir.mail_server'].search([]).unlink()
self.assertFalse(self.env['ir.mail_server'].search([]))
# Use an email in the domain of the "from_filter"
with self.mock_smtplib_connection():
smtp_session = IrMailServer.connect(smtp_from='specific_user@test.com')
message = self._build_email(mail_from='specific_user@test.com')
IrMailServer.send_email(message, smtp_session=smtp_session)
self.connect_mocked.assert_called_once()
self.assert_email_sent_smtp(
smtp_from=default_bounce_adress,
message_from='specific_user@test.com',
from_filter='test.com',
)
# Use an email outside of the domain of the "from_filter"
# So we will use the notifications email in the headers and the bounce address
# in the envelop because the "from_filter" allows to use the entire domain
with self.mock_smtplib_connection():
smtp_session = IrMailServer.connect(smtp_from='test@unknown_domain.com')
message = self._build_email(mail_from='test@unknown_domain.com')
IrMailServer.send_email(message, smtp_session=smtp_session)
self.connect_mocked.assert_called_once()
self.assert_email_sent_smtp(
smtp_from=default_bounce_adress,
message_from='"test" <notifications@test.com>',
from_filter='test.com',
)
def test_mail_server_get_email_addresses(self):
"""Test the email used to test the mail server connection."""
self.server_notification.from_filter = 'example_2.com'
self.env['ir.config_parameter'].set_param('mail.default.from', 'notifications@example.com')
email_from = self.server_notification._get_test_email_addresses()[0]
self.assertEqual(email_from, 'noreply@example_2.com')
self.env['ir.config_parameter'].set_param('mail.default.from', 'notifications')
email_from = self.server_notification._get_test_email_addresses()[0]
self.assertEqual(email_from, 'notifications@example_2.com')
self.server_notification.from_filter = 'full_email@example_2.com'
self.env['ir.config_parameter'].set_param('mail.default.from', 'notifications')
email_from = self.server_notification._get_test_email_addresses()[0]
self.assertEqual(email_from, 'full_email@example_2.com')
self.env['ir.config_parameter'].set_param('mail.default.from', 'notifications@example.com')
email_from = self.server_notification._get_test_email_addresses()[0]
self.assertEqual(email_from, 'full_email@example_2.com')
self.env['ir.config_parameter'].set_param('mail.default.from', 'notifications@example.com')
self.server_notification.from_filter = 'example.com'
email_from = self.server_notification._get_test_email_addresses()[0]
self.assertEqual(email_from, 'notifications@example.com')
@mute_logger('odoo.models.unlink')
@patch.dict(config.options, {'from_filter': 'test.com', 'smtp_server': 'example.com'})
def test_mail_server_mail_default_from_filter(self):
"""Test that the config parameter "mail.default.from_filter" overwrite the odoo-bin
argument "--from-filter"
"""
self.env['ir.config_parameter'].sudo().set_param('mail.default.from_filter', 'example.com')
IrMailServer = self.env['ir.mail_server']
# Remove all mail server so we will use the odoo-bin arguments
IrMailServer.search([]).unlink()
self.assertFalse(IrMailServer.search([]))
for mail_from, (expected_smtp_from, expected_msg_from) in zip(
[
# inside "from_filter" domain
'specific_user@test.mycompany.com',
'"Formatted Name" <specific_user@test.mycompany.com>',
'"Formatted Name" <specific_user@test.MYCOMPANY.com>',
'"Formatted Name" <SPECIFIC_USER@test.mycompany.com>',
# outside "from_filter" domain
'test@unknown_domain.com',
'"Formatted Name" <test@unknown_domain.com>',
], [
# inside "from_filter" domain: no rewriting
('specific_user@test.mycompany.com', 'specific_user@test.mycompany.com'),
('specific_user@test.mycompany.com', '"Formatted Name" <specific_user@test.mycompany.com>'),
('specific_user@test.MYCOMPANY.com', '"Formatted Name" <specific_user@test.MYCOMPANY.com>'),
('SPECIFIC_USER@test.mycompany.com', '"Formatted Name" <SPECIFIC_USER@test.mycompany.com>'),
# outside "from_filter" domain: spoofing, as fallback email can be found
('test@unknown_domain.com', 'test@unknown_domain.com'),
('test@unknown_domain.com', '"Formatted Name" <test@unknown_domain.com>'),
]
):
for provide_smtp in [False, True]: # providing smtp session should ont impact test
with self.subTest(mail_from=mail_from, provide_smtp=provide_smtp):
with self.mock_smtplib_connection():
if provide_smtp:
smtp_session = IrMailServer.connect(smtp_from=mail_from)
message = self._build_email(mail_from=mail_from)
IrMailServer.send_email(message, smtp_session=smtp_session)
else:
message = self._build_email(mail_from=mail_from)
IrMailServer.send_email(message)
self.connect_mocked.assert_called_once()
self.assertEqual(len(self.emails), 1)
self.assertSMTPEmailsSent(
smtp_from=expected_smtp_from,
message_from=expected_msg_from,
from_filter="dummy@example.com, test.mycompany.com, dummy2@example.com",
)
# for from_filter in ICP, overwrite the one from odoo-bin
self.env['ir.config_parameter'].sudo().set_param('mail.default.from_filter', 'icp.example.com')
# Use an email in the domain of the config parameter "mail.default.from_filter"
with self.mock_smtplib_connection():
message = self._build_email(mail_from='specific_user@example.com')
message = self._build_email(mail_from='specific_user@icp.example.com')
IrMailServer.send_email(message)
self.assert_email_sent_smtp(
smtp_from='specific_user@example.com',
message_from='specific_user@example.com',
from_filter='example.com',
self.assertSMTPEmailsSent(
smtp_from='specific_user@icp.example.com',
message_from='specific_user@icp.example.com',
from_filter='icp.example.com',
)
@mute_logger('odoo.models.unlink')
@patch.dict(config.options, {'from_filter': 'fake.com', 'smtp_server': 'cli_example.com'})
def test_mail_server_config_cli(self):
""" Test the mail server configuration when the "smtp_authentication" is
"cli". It should take the configuration from the odoo-bin argument. The
"from_filter" of the mail server should overwrite the one set in the CLI
arguments.
"""
IrMailServer = self.env['ir.mail_server']
# should be ignored by the mail server
self.env['ir.config_parameter'].sudo().set_param('mail.default.from_filter', 'fake.com')
server_other = IrMailServer.create([{
'name': 'Server No From Filter',
'smtp_host': 'smtp_host',
'smtp_encryption': 'none',
'smtp_authentication': 'cli',
'from_filter': 'dummy@example.com, cli_example.com, dummy2@example.com',
}])
for mail_from, (expected_smtp_from, expected_msg_from, expected_mail_server) in zip(
[
# check that the CLI server take the configuration in the odoo-bin argument
# except the from_filter which is taken on the mail server
'test@cli_example.com',
# other mail servers still work
'specific_user@test.mycompany.com',
], [
('test@cli_example.com', 'test@cli_example.com', server_other),
('specific_user@test.mycompany.com', 'specific_user@test.mycompany.com', self.mail_server_user),
],
):
with self.subTest(mail_from=mail_from):
with self.mock_smtplib_connection():
message = self._build_email(mail_from=mail_from)
IrMailServer.send_email(message)
self.assertSMTPEmailsSent(
smtp_from=expected_smtp_from,
message_from=expected_msg_from,
mail_server=expected_mail_server,
)
def test_eml_attachment_encoding(self):
"""Test that message/rfc822 attachments are encoded using 7bit, 8bit, or binary encoding."""
IrMailServer = self.env['ir.mail_server']

View file

@ -1,7 +1,8 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from psycopg2 import IntegrityError, Error as Psycopg2Error
from psycopg2 import IntegrityError
from psycopg2.errors import NotNullViolation
from odoo.exceptions import ValidationError
from odoo.tests.common import Form, TransactionCase, HttpCase, tagged
@ -172,6 +173,56 @@ class TestXMLID(TransactionCase):
with self.assertRaisesRegex(IntegrityError, 'ir_model_data_name_nospaces'):
model._load_records(data_list)
def test_update_xmlid(self):
def assert_xmlid(xmlid, value, message):
expected_values = (value._name, value.id)
with self.assertQueryCount(0):
self.assertEqual(self.env['ir.model.data']._xmlid_lookup(xmlid), expected_values, message)
module, name = xmlid.split('.')
self.env.cr.execute("SELECT model, res_id FROM ir_model_data where module=%s and name=%s", [module, name])
self.assertEqual((value._name, value.id), self.env.cr.fetchone(), message)
xmlid = 'base.test_xmlid'
records = self.env['ir.model.data'].search([], limit=6)
with self.assertQueryCount(1):
self.env['ir.model.data']._update_xmlids([
{'xml_id': xmlid, 'record': records[0]},
])
assert_xmlid(xmlid, records[0], f'The xmlid {xmlid} should have been created with record {records[0]}')
with self.assertQueryCount(1):
self.env['ir.model.data']._update_xmlids([
{'xml_id': xmlid, 'record': records[1]},
], update=True)
assert_xmlid(xmlid, records[1], f'The xmlid {xmlid} should have been updated with record {records[1]}')
with self.assertQueryCount(1):
self.env['ir.model.data']._update_xmlids([
{'xml_id': xmlid, 'record': records[2]},
])
assert_xmlid(xmlid, records[2], f'The xmlid {xmlid} should have been updated with record {records[1]}')
# noupdate case
# note: this part is mainly there to avoid breaking the current behaviour, not asserting that it makes sence
xmlid = 'base.test_xmlid_noupdates'
with self.assertQueryCount(1):
self.env['ir.model.data']._update_xmlids([
{'xml_id': xmlid, 'record': records[3], 'noupdate':True}, # record created as noupdate
])
assert_xmlid(xmlid, records[3], f'The xmlid {xmlid} should have been created for record {records[2]}')
with self.assertQueryCount(1):
self.env['ir.model.data']._update_xmlids([
{'xml_id': xmlid, 'record': records[4]},
], update=True)
assert_xmlid(xmlid, records[3], f'The xmlid {xmlid} should not have been updated (update mode)')
with self.assertQueryCount(1):
self.env['ir.model.data']._update_xmlids([
{'xml_id': xmlid, 'record': records[5]},
])
assert_xmlid(xmlid, records[5], f'The xmlid {xmlid} should have been updated with record (not an update) {records[1]}')
class TestIrModel(TransactionCase):
@ -330,14 +381,64 @@ class TestIrModel(TransactionCase):
self.assertEqual(self.registry.field_depends[type(record).display_name], ())
self.assertEqual(record.display_name, f"x_bananas,{record.id}")
def test_monetary_currency_field(self):
fields_value = [
Command.create({'name': 'x_monetary', 'ttype': 'monetary', 'field_description': 'Monetary', 'currency_field': 'test'}),
]
with self.assertRaises(ValidationError):
self.env['ir.model'].create({
'name': 'Paper Company Model',
'model': 'x_paper_model',
'field_id': fields_value,
})
fields_value = [
Command.create({'name': 'x_monetary', 'ttype': 'monetary', 'field_description': 'Monetary', 'currency_field': 'x_falsy_currency'}),
Command.create({'name': 'x_falsy_currency', 'ttype': 'one2many', 'field_description': 'Currency', 'relation': 'res.currency'}),
]
with self.assertRaises(ValidationError):
self.env['ir.model'].create({
'name': 'Paper Company Model',
'model': 'x_paper_model',
'field_id': fields_value,
})
fields_value = [
Command.create({'name': 'x_monetary', 'ttype': 'monetary', 'field_description': 'Monetary', 'currency_field': 'x_falsy_currency'}),
Command.create({'name': 'x_falsy_currency', 'ttype': 'many2one', 'field_description': 'Currency', 'relation': 'res.partner'}),
]
with self.assertRaises(ValidationError):
self.env['ir.model'].create({
'name': 'Paper Company Model',
'model': 'x_paper_model',
'field_id': fields_value,
})
fields_value = [
Command.create({'name': 'x_monetary', 'ttype': 'monetary', 'field_description': 'Monetary', 'currency_field': 'x_good_currency'}),
Command.create({'name': 'x_good_currency', 'ttype': 'many2one', 'field_description': 'Currency', 'relation': 'res.currency'}),
]
model = self.env['ir.model'].create({
'name': 'Paper Company Model',
'model': 'x_paper_model',
'field_id': fields_value,
})
monetary_field = model.field_id.search([['name', 'ilike', 'x_monetary']])
self.assertEqual(len(monetary_field), 1,
"Should have the monetary field in the created ir.model")
self.assertEqual(monetary_field.currency_field, "x_good_currency",
"The currency field in monetary should have x_good_currency as name")
@tagged('-at_install', 'post_install')
class TestIrModelEdition(TransactionCase):
def test_new_ir_model_fields_related(self):
"""Check that related field are handled correctly on new field"""
model = self.env['ir.model'].create({
'name': 'Bananas',
'model': 'x_bananas'
})
with self.debug_mode():
form = Form(
self.env['ir.model.fields'].with_context(
default_model_id=self.bananas_model.id
)
)
form = Form(self.env['ir.model.fields'].with_context(default_model_id=model.id))
form.related = 'id'
self.assertEqual(form.ttype, 'integer')
@ -381,18 +482,19 @@ class TestIrModel(TransactionCase):
@mute_logger('odoo.sql_db')
def test_ir_model_fields_name_create(self):
NotNullViolationPgCode = '23502'
model = self.env['ir.model'].create({
'name': 'Bananas',
'model': 'x_bananas'
})
# Quick create an ir_model_field should not be possible
# It should be raise a ValidationError
with self.assertRaises(Psycopg2Error) as error:
with self.assertRaises(NotNullViolation):
self.env['ir.model.fields'].name_create("field_name")
self.assertEqual(error.exception.pgcode, NotNullViolationPgCode)
# But with default_ we should be able to name_create
self.env['ir.model.fields'].with_context(
default_model_id=self.bananas_model.id,
default_model=self.bananas_model.name,
default_model_id=model.id,
default_model=model.name,
default_ttype="char"
).name_create("field_name")

View file

@ -192,6 +192,20 @@ class TestIrSequenceGenerate(BaseCase):
with self.assertRaises(UserError):
env['ir.sequence'].next_by_code('test_sequence_type_7')
def test_ir_sequence_suffix(self):
""" test whether a user error is raised for an invalid sequence """
# try to create a sequence with invalid suffix
with environment() as env:
env['ir.sequence'].create({
'code': 'test_sequence_type_8',
'name': 'Test sequence',
'prefix': '',
'suffix': '/%(invalid)s',
})
with self.assertRaisesRegex(UserError, "Invalid prefix or suffix"):
env['ir.sequence'].next_by_code('test_sequence_type_8')
@classmethod
def tearDownClass(cls):
drop_sequence('test_sequence_type_5')

View file

@ -1,30 +1,48 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from markupsafe import Markup
from unittest.mock import patch
import email.policy
import email.message
import re
import threading
from odoo.addons.base.models.ir_mail_server import extract_rfc2822_addresses
from odoo.tests.common import BaseCase, TransactionCase
from odoo.addons.base.models.ir_qweb_fields import nl2br_enclose
from odoo.tests import tagged
from odoo.tests.common import BaseCase
from odoo.tools import (
is_html_empty, html_to_inner_content, html_sanitize, append_content_to_html, plaintext2html,
email_domain_normalize, email_normalize, email_split, email_split_and_format, html2plaintext,
email_domain_normalize, email_normalize, email_re,
email_split, email_split_and_format, email_split_tuples,
single_email_re, html2plaintext,
misc, formataddr, email_anonymize,
prepend_html_content,
config,
)
from . import test_mail_examples
@tagged('mail_sanitize')
class TestSanitizer(BaseCase):
""" Test the html sanitizer that filters html to remove unwanted attributes """
def test_abrupt_close(self):
payload = """<!--> <script> alert(1) </script> -->"""
html_result = html_sanitize(payload)
self.assertNotIn('alert(1)', html_result)
payload = """<!---> <script> alert(1) </script> -->"""
html_result = html_sanitize(payload)
self.assertNotIn('alert(1)', html_result)
def test_abrut_malformed(self):
payload = """<!--!> <script> alert(1) </script> -->"""
html_result = html_sanitize(payload)
self.assertNotIn('alert(1)', html_result)
payload = """<!---!> <script> alert(1) </script> -->"""
html_result = html_sanitize(payload)
self.assertNotIn('alert(1)', html_result)
def test_basic_sanitizer(self):
cases = [
("yop", "<p>yop</p>"), # simple
@ -36,6 +54,20 @@ class TestSanitizer(BaseCase):
html = html_sanitize(content)
self.assertEqual(html, expected, 'html_sanitize is broken')
def test_comment_malformed(self):
html = '''<!-- malformed-close --!> <img src='x' onerror='alert(1)'></img> --> comment <!-- normal comment --> --> out of context balise --!>'''
html_result = html_sanitize(html)
self.assertNotIn('alert(1)', html_result)
def test_comment_multiline(self):
payload = """
<div> <!--
multi line comment
--!> </div> <script> alert(1) </script> -->
"""
html_result = html_sanitize(payload)
self.assertNotIn('alert(1)', html_result)
def test_evil_malicious_code(self):
# taken from https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#Tests
cases = [
@ -334,6 +366,7 @@ class TestSanitizer(BaseCase):
# self.assertNotIn(ext, new_html)
@tagged('mail_sanitize')
class TestHtmlTools(BaseCase):
""" Test some of our generic utility functions about html """
@ -404,6 +437,30 @@ class TestHtmlTools(BaseCase):
for content in valid_html_samples:
self.assertFalse(is_html_empty(content))
def test_nl2br_enclose(self):
""" Test formatting of nl2br when using Markup: consider new <br> tags
as trusted without validating the whole input content. """
source_all = [
'coucou',
'<p>coucou</p>',
'coucou\ncoucou',
'coucou\n\ncoucou',
'<p>coucou\ncoucou\n\nzbouip</p>\n',
]
expected_all = [
Markup('<div>coucou</div>'),
Markup('<div>&lt;p&gt;coucou&lt;/p&gt;</div>'),
Markup('<div>coucou<br>\ncoucou</div>'),
Markup('<div>coucou<br>\n<br>\ncoucou</div>'),
Markup('<div>&lt;p&gt;coucou<br>\ncoucou<br>\n<br>\nzbouip&lt;/p&gt;<br>\n</div>'),
]
for source, expected in zip(source_all, expected_all):
with self.subTest(source=source, expected=expected):
self.assertEqual(
nl2br_enclose(source, "div"),
expected,
)
def test_prepend_html_content(self):
body = """
<html>
@ -460,6 +517,34 @@ class TestHtmlTools(BaseCase):
class TestEmailTools(BaseCase):
""" Test some of our generic utility functions for emails """
@classmethod
def setUpClass(cls):
super(TestEmailTools, cls).setUpClass()
cls.sources = [
# single email
'alfred.astaire@test.example.com',
' alfred.astaire@test.example.com ',
'Fredo The Great <alfred.astaire@test.example.com>',
'"Fredo The Great" <alfred.astaire@test.example.com>',
'Fredo "The Great" <alfred.astaire@test.example.com>',
# multiple emails
'alfred.astaire@test.example.com, evelyne.gargouillis@test.example.com',
'Fredo The Great <alfred.astaire@test.example.com>, Evelyne The Goat <evelyne.gargouillis@test.example.com>',
'"Fredo The Great" <alfred.astaire@test.example.com>, evelyne.gargouillis@test.example.com',
'"Fredo The Great" <alfred.astaire@test.example.com>, <evelyne.gargouillis@test.example.com>',
# text containing email
'Hello alfred.astaire@test.example.com how are you ?',
'<p>Hello alfred.astaire@test.example.com</p>',
# text containing emails
'Hello "Fredo" <alfred.astaire@test.example.com>, evelyne.gargouillis@test.example.com',
'Hello "Fredo" <alfred.astaire@test.example.com> and evelyne.gargouillis@test.example.com',
# falsy
'<p>Hello Fredo</p>',
'j\'adore écrire des @gmail.com ou "@gmail.com" a bit randomly',
'',
]
def test_email_domain_normalize(self):
cases = [
("Test.Com", "test.com", "Should have normalized domain"),
@ -524,6 +609,37 @@ class TestEmailTools(BaseCase):
# sending emails (see extract_rfc2822_addresses)
self.assertEqual(formataddr((format_name, (expected or '')), charset='ascii'), expected_ascii_fmt)
def test_email_re(self):
""" Test 'email_re', finding emails in a given text """
expected = [
# single email
['alfred.astaire@test.example.com'],
['alfred.astaire@test.example.com'],
['alfred.astaire@test.example.com'],
['alfred.astaire@test.example.com'],
['alfred.astaire@test.example.com'],
# multiple emails
['alfred.astaire@test.example.com', 'evelyne.gargouillis@test.example.com'],
['alfred.astaire@test.example.com', 'evelyne.gargouillis@test.example.com'],
['alfred.astaire@test.example.com', 'evelyne.gargouillis@test.example.com'],
['alfred.astaire@test.example.com', 'evelyne.gargouillis@test.example.com'],
# text containing email
['alfred.astaire@test.example.com'],
['alfred.astaire@test.example.com'],
# text containing emails
['alfred.astaire@test.example.com', 'evelyne.gargouillis@test.example.com'],
['alfred.astaire@test.example.com', 'evelyne.gargouillis@test.example.com'],
# falsy
[], [], [],
]
for src, exp in zip(self.sources, expected):
res = email_re.findall(src)
self.assertEqual(
res, exp,
'Seems email_re is broken with %s (expected %r, received %r)' % (src, exp, res)
)
def test_email_split(self):
""" Test 'email_split' """
cases = [
@ -629,7 +745,40 @@ class TestEmailTools(BaseCase):
with self.subTest(source=source):
self.assertEqual(email_split_and_format(source), expected)
def test_email_split_tuples(self):
""" Test 'email_split_and_format' that returns (name, email) pairs
found in text input """
expected = [
# single email
[('', 'alfred.astaire@test.example.com')],
[('', 'alfred.astaire@test.example.com')],
[('Fredo The Great', 'alfred.astaire@test.example.com')],
[('Fredo The Great', 'alfred.astaire@test.example.com')],
[('Fredo The Great', 'alfred.astaire@test.example.com')],
# multiple emails
[('', 'alfred.astaire@test.example.com'), ('', 'evelyne.gargouillis@test.example.com')],
[('Fredo The Great', 'alfred.astaire@test.example.com'), ('Evelyne The Goat', 'evelyne.gargouillis@test.example.com')],
[('Fredo The Great', 'alfred.astaire@test.example.com'), ('', 'evelyne.gargouillis@test.example.com')],
[('Fredo The Great', 'alfred.astaire@test.example.com'), ('', 'evelyne.gargouillis@test.example.com')],
# text containing email -> fallback on parsing to extract text from email
[('Hello', 'alfred.astaire@test.example.comhowareyou?')],
[('Hello', 'alfred.astaire@test.example.com')],
[('Hello Fredo', 'alfred.astaire@test.example.com'), ('', 'evelyne.gargouillis@test.example.com')],
[('Hello Fredo', 'alfred.astaire@test.example.com'), ('and', 'evelyne.gargouillis@test.example.com')],
# falsy -> probably not designed for that
[],
[('j\'adore écrire', "des@gmail.comou"), ('', '@gmail.com')], [],
]
for src, exp in zip(self.sources, expected):
res = email_split_tuples(src)
self.assertEqual(
res, exp,
'Seems email_split_tuples is broken with %s (expected %r, received %r)' % (src, exp, res)
)
def test_email_formataddr(self):
""" Test custom 'formataddr', notably with IDNA support """
email_base = 'joe@example.com'
email_idna = 'joe@examplé.com'
cases = [
@ -681,6 +830,29 @@ class TestEmailTools(BaseCase):
with self.subTest(source=source):
self.assertEqual(extract_rfc2822_addresses(source), expected)
def test_single_email_re(self):
""" Test 'single_email_re', matching text input containing only one email """
expected = [
# single email
['alfred.astaire@test.example.com'],
[], [], [], [], # formatting issue for single email re
# multiple emails -> couic
[], [], [], [],
# text containing email -> couic
[], [],
# text containing emails -> couic
[], [],
# falsy
[], [], [],
]
for src, exp in zip(self.sources, expected):
res = single_email_re.findall(src)
self.assertEqual(
res, exp,
'Seems single_email_re is broken with %s (expected %r, received %r)' % (src, exp, res)
)
def test_email_anonymize(self):
cases = [
# examples
@ -710,136 +882,6 @@ class TestEmailTools(BaseCase):
)
class EmailConfigCase(TransactionCase):
@patch.dict(config.options, {"email_from": "settings@example.com"})
def test_default_email_from(self, *args):
"""Email from setting is respected."""
# ICP setting is more important
ICP = self.env["ir.config_parameter"].sudo()
ICP.set_param("mail.catchall.domain", "example.org")
ICP.set_param("mail.default.from", "icp")
message = self.env["ir.mail_server"].build_email(
False, "recipient@example.com", "Subject",
"The body of an email",
)
self.assertEqual(message["From"], "icp@example.org")
# Without ICP, the config file/CLI setting is used
ICP.set_param("mail.default.from", False)
message = self.env["ir.mail_server"].build_email(
False, "recipient@example.com", "Subject",
"The body of an email",
)
self.assertEqual(message["From"], "settings@example.com")
class _FakeSMTP:
"""SMTP stub"""
def __init__(self):
self.messages = []
self.from_filter = 'example.com'
# Python 3 before 3.7.4
def sendmail(self, smtp_from, smtp_to_list, message_str,
mail_options=(), rcpt_options=()):
self.messages.append(message_str)
# Python 3.7.4+
def send_message(self, message, smtp_from, smtp_to_list,
mail_options=(), rcpt_options=()):
self.messages.append(message.as_string())
class TestEmailMessage(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls._fake_smtp = _FakeSMTP()
def build_email(self, **kwargs):
kwargs.setdefault('email_from', 'from@example.com')
kwargs.setdefault('email_to', 'to@example.com')
kwargs.setdefault('subject', 'subject')
return self.env['ir.mail_server'].build_email(**kwargs)
def send_email(self, msg):
with patch.object(threading.current_thread(), 'testing', False):
self.env['ir.mail_server'].send_email(msg, smtp_session=self._fake_smtp)
return self._fake_smtp.messages.pop()
def test_bpo_34424_35805(self):
"""Ensure all email sent are bpo-34424 and bpo-35805 free"""
msg = email.message.EmailMessage(policy=email.policy.SMTP)
msg['From'] = '"Joé Doe" <joe@example.com>'
msg['To'] = '"Joé Doe" <joe@example.com>'
# Message-Id & References fields longer than 77 chars (bpo-35805)
msg['Message-Id'] = '<929227342217024.1596730490.324691772460938-example-30661-some.reference@test-123.example.com>'
msg['References'] = '<345227342212345.1596730777.324691772483620-example-30453-other.reference@test-123.example.com>'
msg_on_the_wire = self.send_email(msg)
self.assertEqual(msg_on_the_wire,
'From: =?utf-8?q?Jo=C3=A9?= Doe <joe@example.com>\r\n'
'To: =?utf-8?q?Jo=C3=A9?= Doe <joe@example.com>\r\n'
'Message-Id: <929227342217024.1596730490.324691772460938-example-30661-some.reference@test-123.example.com>\r\n'
'References: <345227342212345.1596730777.324691772483620-example-30453-other.reference@test-123.example.com>\r\n'
'\r\n'
)
def test_alternative_correct_order(self):
"""
RFC-1521 7.2.3. The Multipart/alternative subtype
> the alternatives appear in an order of increasing faithfulness
> to the original content. In general, the best choice is the
> LAST part of a type supported by the recipient system's local
> environment.
Also, the MIME-Version header should be present in BOTH the
enveloppe AND the parts
"""
msg = self.build_email(body='<p>Hello world</p>', subtype='html')
msg_on_the_wire = self.send_email(msg)
self.assertGreater(msg_on_the_wire.index('text/html'), msg_on_the_wire.index('text/plain'),
"The html part should be preferred (=appear after) to the text part")
self.assertEqual(msg_on_the_wire.count('==============='), 2 + 2, # +2 for the header and the footer
"There should be 2 parts: one text and one html")
self.assertEqual(msg_on_the_wire.count('MIME-Version: 1.0'), 3,
"There should be 3 headers MIME-Version: one on the enveloppe, "
"one on the html part, one on the text part")
def test_comment_malformed(self):
html = '''<!-- malformed-close --!> <img src='x' onerror='alert(1)'></img> --> comment <!-- normal comment --> --> out of context balise --!>'''
html_result = html_sanitize(html)
self.assertNotIn('alert(1)', html_result)
def test_multiline(self):
payload = """
<div> <!--
multi line comment
--!> </div> <script> alert(1) </script> -->
"""
html_result = html_sanitize(payload)
self.assertNotIn('alert(1)', html_result)
def test_abrupt_close(self):
payload = """<!--> <script> alert(1) </script> -->"""
html_result = html_sanitize(payload)
self.assertNotIn('alert(1)', html_result)
payload = """<!---> <script> alert(1) </script> -->"""
html_result = html_sanitize(payload)
self.assertNotIn('alert(1)', html_result)
def test_abrut_malformed(self):
payload = """<!--!> <script> alert(1) </script> -->"""
html_result = html_sanitize(payload)
self.assertNotIn('alert(1)', html_result)
payload = """<!---!> <script> alert(1) </script> -->"""
html_result = html_sanitize(payload)
self.assertNotIn('alert(1)', html_result)
class TestMailTools(BaseCase):
""" Test mail utility methods. """

View file

@ -239,7 +239,7 @@ QUOTE_THUNDERBIRD_1 = u"""<div>On 11/08/2012 05:29 PM,
<li>9.45 AM: summary</li>
<li>10 AM: meeting with Fabien to present our app</li>
</ul></div>
<div>Is everything ok for you ?</div>
<div>Is everything ok for you?</div>
<div>
<p>--<br>
Administrator</p>
@ -250,7 +250,7 @@ QUOTE_THUNDERBIRD_1 = u"""<div>On 11/08/2012 05:29 PM,
</div>
</blockquote>
Ok for me. I am replying directly below your mail, using Thunderbird, with a signature.<br><br>
Did you receive my email about my new laptop, by the way ?<br><br>
Did you receive my email about my new laptop, by the way?<br><br>
Raoul.<br><pre>--
Raoul Grosbedonn&#233;e
</pre>"""
@ -304,7 +304,7 @@ TEXT_1 = u"""I contact you about our meeting tomorrow. Here is the schedule I pr
9 AM: brainstorming about our new amazing business app
9.45 AM: summary
10 AM: meeting with Ignasse to present our app
Is everything ok for you ?
Is everything ok for you?
--
MySignature"""
@ -312,7 +312,7 @@ TEXT_1_IN = [u"""I contact you about our meeting tomorrow. Here is the schedule
9 AM: brainstorming about our new amazing business app
9.45 AM: summary
10 AM: meeting with Ignasse to present our app
Is everything ok for you ?"""]
Is everything ok for you?"""]
TEXT_1_OUT = [u"""
--
MySignature"""]
@ -409,7 +409,7 @@ HOTMAIL_1 = u"""<div>
<br>
You indicated that you wish to use OpenERP in your own company.
We would like to know more about your your business needs and requirements, and see how
we can help you. When would you be available to discuss your project ?<br>
we can help you. When would you be available to discuss your project?<br>
Best regards,<br>
<pre>
<a href="http://openerp.com" target="_blank">http://openerp.com</a>

View file

@ -25,6 +25,11 @@ SVG = b"""PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iaXNvLTg4NTktMSI/PjwhRE9DVFlQRS
NAMESPACED_SVG = b"""<svg:svg xmlns:svg="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<svg:rect x="10" y="10" width="80" height="80" fill="green" />
</svg:svg>"""
# single pixel webp image
WEBP = b"""UklGRjoAAABXRUJQVlA4IC4AAAAwAQCdASoBAAEAAUAmJaAAA3AA/u/uY//8s//2W/7LeM///5Bj
/dl/pJxGAAAA"""
# minimal zip file with an empty `t.txt` file
ZIP = b"""UEsDBBQACAAIAGFva1AAAAAAAAAAAAAAAAAFACAAdC50eHRVVA0AB5bgaF6W4GheluBoXnV4CwABBOgDAAAE6AMAAA
MAUEsHCAAAAAACAAAAAAAAAFBLAQIUAxQACAAIAGFva1AAAAAAAgAAAAAAAAAFACAAAAAAAAAAAACkgQAAAAB0LnR4dFVUDQAHlu
@ -105,6 +110,11 @@ class test_guess_mimetype(BaseCase):
self.assertNotIn("svg", mimetype)
def test_mimetype_webp(self):
content = base64.b64decode(WEBP)
mimetype = guess_mimetype(content, default='test')
self.assertEqual(mimetype, 'image/webp')
def test_mimetype_zip(self):
content = base64.b64decode(ZIP)
mimetype = guess_mimetype(content, default='test')

View file

@ -106,6 +106,29 @@ class TestDateRangeFunction(BaseCase):
self.assertEqual(dates, expected)
def test_date_range_with_date(self):
""" Check date_range with naive datetimes. """
start = datetime.date(1985, 1, 1)
end = datetime.date(1986, 1, 1)
expected = [
datetime.date(1985, 1, 1),
datetime.date(1985, 2, 1),
datetime.date(1985, 3, 1),
datetime.date(1985, 4, 1),
datetime.date(1985, 5, 1),
datetime.date(1985, 6, 1),
datetime.date(1985, 7, 1),
datetime.date(1985, 8, 1),
datetime.date(1985, 9, 1),
datetime.date(1985, 10, 1),
datetime.date(1985, 11, 1),
datetime.date(1985, 12, 1),
datetime.date(1986, 1, 1),
]
self.assertEqual(list(date_utils.date_range(start, end)), expected)
def test_date_range_with_timezone_aware_datetimes_other_than_utc(self):
""" Check date_range with timezone-aware datetimes other than UTC."""
timezone = pytz.timezone('Europe/Brussels')
@ -495,6 +518,94 @@ class TestDictTools(BaseCase):
dict.update(d, {'baz': 'xyz'})
class TestFormatLang(TransactionCase):
def test_value_and_digits(self):
self.assertEqual(misc.formatLang(self.env, 100.23, digits=1), '100.2')
self.assertEqual(misc.formatLang(self.env, 100.23, digits=3), '100.230')
self.assertEqual(misc.formatLang(self.env, ''), '', 'If value is an empty string, it should return an empty string (not 0)')
self.assertEqual(misc.formatLang(self.env, 100), '100.00', 'If digits is None (default value), it should default to 2')
# Default rounding is 'HALF_EVEN'
self.assertEqual(misc.formatLang(self.env, 100.205), '100.20')
self.assertEqual(misc.formatLang(self.env, 100.215), '100.22')
def test_grouping(self):
self.env["res.lang"].create({
"name": "formatLang Lang",
"code": "fLT",
"grouping": "[3,2,-1]",
"decimal_point": "!",
"thousands_sep": "?",
})
self.env['res.lang']._activate_lang('fLT')
self.assertEqual(misc.formatLang(self.env['res.lang'].with_context(lang='fLT').env, 1000000000, grouping=True), '10000?00?000!00')
self.assertEqual(misc.formatLang(self.env['res.lang'].with_context(lang='fLT').env, 1000000000, grouping=False), '1000000000.00')
def test_decimal_precision(self):
decimal_precision = self.env['decimal.precision'].create({
'name': 'formatLang Decimal Precision',
'digits': 3, # We want .001 decimals to make sure the decimal precision parameter 'dp' is chosen.
})
self.assertEqual(misc.formatLang(self.env, 100, dp=decimal_precision.name), '100.000')
def test_currency_object(self):
currency_object = self.env['res.currency'].create({
'name': 'formatLang Currency',
'symbol': 'fL',
'rounding': 0.1, # We want .1 decimals to make sure 'currency_obj' is chosen.
'position': 'after',
})
self.assertEqual(misc.formatLang(self.env, 100, currency_obj=currency_object), '100.0%sfL' % u'\N{NO-BREAK SPACE}')
currency_object.write({'position': 'before'})
self.assertEqual(misc.formatLang(self.env, 100, currency_obj=currency_object), 'fL%s100.0' % u'\N{NO-BREAK SPACE}')
def test_decimal_precision_and_currency_object(self):
decimal_precision = self.env['decimal.precision'].create({
'name': 'formatLang Decimal Precision',
'digits': 3,
})
currency_object = self.env['res.currency'].create({
'name': 'formatLang Currency',
'symbol': 'fL',
'rounding': 0.1,
'position': 'after',
})
# If we have a 'dp' and 'currency_obj', we use the decimal precision of 'dp' and the format of 'currency_obj'.
self.assertEqual(misc.formatLang(self.env, 100, dp=decimal_precision.name, currency_obj=currency_object), '100.000%sfL' % u'\N{NO-BREAK SPACE}')
def test_rounding_method(self):
self.assertEqual(misc.formatLang(self.env, 100.205), '100.20') # Default is 'HALF-EVEN'
self.assertEqual(misc.formatLang(self.env, 100.215), '100.22') # Default is 'HALF-EVEN'
self.assertEqual(misc.formatLang(self.env, 100.205, rounding_method='HALF-UP'), '100.21')
self.assertEqual(misc.formatLang(self.env, 100.215, rounding_method='HALF-UP'), '100.22')
self.assertEqual(misc.formatLang(self.env, 100.205, rounding_method='HALF-DOWN'), '100.20')
self.assertEqual(misc.formatLang(self.env, 100.215, rounding_method='HALF-DOWN'), '100.21')
def test_rounding_unit(self):
self.assertEqual(misc.formatLang(self.env, 1000000.00), '1,000,000.00')
self.assertEqual(misc.formatLang(self.env, 1000000.00, rounding_unit='units'), '1,000,000')
self.assertEqual(misc.formatLang(self.env, 1000000.00, rounding_unit='thousands'), '1,000')
self.assertEqual(misc.formatLang(self.env, 1000000.00, rounding_unit='lakhs'), '10')
self.assertEqual(misc.formatLang(self.env, 1000000.00, rounding_unit="millions"), '1')
def test_rounding_method_and_rounding_unit(self):
self.assertEqual(misc.formatLang(self.env, 1822060000, rounding_method='HALF-UP', rounding_unit='lakhs'), '18,221')
self.assertEqual(misc.formatLang(self.env, 1822050000, rounding_method='HALF-UP', rounding_unit='lakhs'), '18,221')
self.assertEqual(misc.formatLang(self.env, 1822049900, rounding_method='HALF-UP', rounding_unit='lakhs'), '18,220')
class TestUrlValidate(BaseCase):
def test_url_validate(self):
for case, truth in [

View file

@ -42,12 +42,14 @@ class TestModuleManifest(BaseCase):
'auto_install': False,
'bootstrap': False,
'category': 'Uncategorized',
'configurator_snippets': {},
'countries': [],
'data': [],
'demo': [],
'demo_xml': [],
'depends': [],
'description': '',
'external_dependencies': [],
'external_dependencies': {},
'icon': '/base/static/description/icon.png',
'init_xml': [],
'installable': True,
@ -56,11 +58,11 @@ class TestModuleManifest(BaseCase):
'license': 'MIT',
'live_test_url': '',
'name': f'Temp {self.module_name}',
'new_page_templates': {},
'post_init_hook': '',
'post_load': '',
'pre_init_hook': '',
'sequence': 100,
'snippet_lists': {},
'summary': '',
'test': [],
'update_xml': [],

View file

@ -45,13 +45,13 @@ class TestORM(TransactionCase):
self.assertTrue(type(Model).display_name.automatic, "test assumption not satisfied")
# access regular field when another record from the same prefetch set has been deleted
records = Model.create([{'name': name} for name in ('Foo', 'Bar', 'Baz')])
records = Model.create([{'name': name[0], 'code': name[1]} for name in (['Foo', 'ZV'], ['Bar', 'ZX'], ['Baz', 'ZY'])])
for record in records:
record.name
record.unlink()
# access computed field when another record from the same prefetch set has been deleted
records = Model.create([{'name': name} for name in ('Foo', 'Bar', 'Baz')])
records = Model.create([{'name': name[0], 'code': name[1]} for name in (['Foo', 'ZV'], ['Bar', 'ZX'], ['Baz', 'ZY'])])
for record in records:
record.display_name
record.unlink()
@ -285,12 +285,14 @@ class TestORM(TransactionCase):
Command.create({'name': 'West Foo', 'code': 'WF'}),
Command.create({'name': 'East Foo', 'code': 'EF'}),
],
'code': 'ZV',
}, {
'name': 'Bar',
'state_ids': [
Command.create({'name': 'North Bar', 'code': 'NB'}),
Command.create({'name': 'South Bar', 'code': 'SB'}),
],
'code': 'ZX',
}]
foo, bar = self.env['res.country'].create(vals_list)
self.assertEqual(foo.name, 'Foo')
@ -347,12 +349,10 @@ class TestInherits(TransactionCase):
'employee': True,
})
foo_before, = user_foo.read()
del foo_before['__last_update']
del foo_before['create_date']
del foo_before['write_date']
user_bar = user_foo.copy({'login': 'bar'})
foo_after, = user_foo.read()
del foo_after['__last_update']
del foo_after['create_date']
del foo_after['write_date']
self.assertEqual(foo_before, foo_after)
@ -370,14 +370,12 @@ class TestInherits(TransactionCase):
partner_bar = self.env['res.partner'].create({'name': 'Bar'})
foo_before, = user_foo.read()
del foo_before['__last_update']
del foo_before['create_date']
del foo_before['write_date']
del foo_before['login_date']
partners_before = self.env['res.partner'].search([])
user_bar = user_foo.copy({'partner_id': partner_bar.id, 'login': 'bar'})
foo_after, = user_foo.read()
del foo_after['__last_update']
del foo_after['create_date']
del foo_after['write_date']
del foo_after['login_date']

View file

@ -3,7 +3,7 @@
from odoo.tests.common import TransactionCase
from odoo.tools import get_cache_key_counter
from threading import Thread, Barrier
class TestOrmcache(TransactionCase):
def test_ormcache(self):
@ -17,7 +17,7 @@ class TestOrmcache(TransactionCase):
miss = counter.miss
# clear the caches of ir.model.data, retrieve its key and
IMD.clear_caches()
self.env.registry.clear_cache()
self.assertNotIn(key, cache)
# lookup some reference
@ -37,3 +37,138 @@ class TestOrmcache(TransactionCase):
self.assertEqual(counter.hit, hit + 2)
self.assertEqual(counter.miss, miss + 1)
self.assertIn(key, cache)
def test_invalidation(self):
self.assertEqual(self.env.registry.cache_invalidated, set())
self.env.registry.clear_cache()
self.env.registry.clear_cache('templates')
self.assertEqual(self.env.registry.cache_invalidated, {'default', 'templates'})
self.env.registry.reset_changes()
self.assertEqual(self.env.registry.cache_invalidated, set())
self.env.registry.clear_cache('assets')
self.assertEqual(self.env.registry.cache_invalidated, {'assets'})
self.env.registry.reset_changes()
self.assertEqual(self.env.registry.cache_invalidated, set())
def test_invalidation_thread_local(self):
# this test ensures that the registry.cache_invalidated set is thread local
caches = ['default', 'templates', 'assets']
nb_treads = len(caches)
# use barriers to ensure threads synchronization
sync_clear_cache = Barrier(nb_treads, timeout=5)
sync_assert_equal = Barrier(nb_treads, timeout=5)
sync_reset = Barrier(nb_treads, timeout=5)
operations = []
def run(cache):
self.assertEqual(self.env.registry.cache_invalidated, set())
self.env.registry.clear_cache(cache)
operations.append('clear_cache')
sync_clear_cache.wait()
self.assertEqual(self.env.registry.cache_invalidated, {cache})
operations.append('assert_contains')
sync_assert_equal.wait()
self.env.registry.reset_changes()
operations.append('reset_changes')
sync_reset.wait()
self.assertEqual(self.env.registry.cache_invalidated, set())
operations.append('assert_empty')
# run all threads
threads = []
for cache in caches:
threads.append(Thread(target=run, args=(cache,)))
for thread in threads:
thread.start()
for thread in threads:
thread.join()
# ensure that the threads operations where executed in the expected order
self.assertEqual(
operations,
['clear_cache'] * nb_treads +
['assert_contains'] * nb_treads +
['reset_changes'] * nb_treads +
['assert_empty'] * nb_treads
)
def test_signaling_01_single(self):
self.assertFalse(self.registry.test_cr)
self.registry.cache_invalidated.clear()
registry = self.registry
old_sequences = dict(registry.cache_sequences)
with self.assertLogs('odoo.modules.registry') as logs:
registry.cache_invalidated.add('assets')
self.assertEqual(registry.cache_invalidated, {'assets'})
registry.signal_changes()
self.assertFalse(registry.cache_invalidated)
self.assertEqual(
logs.output,
["INFO:odoo.modules.registry:Caches invalidated, signaling through the database: ['assets']"],
)
for key, value in old_sequences.items():
if key == 'assets':
self.assertEqual(value + 1, registry.cache_sequences[key], "Assets cache sequence should have changed")
else:
self.assertEqual(value, registry.cache_sequences[key], "other registry sequence shouldn't have changed")
with self.assertNoLogs(None, None): # the registry sequence should be up to date on the same worker
registry.check_signaling()
# simulate other worker state
registry.cache_sequences.update(old_sequences)
with self.assertLogs() as logs:
registry.check_signaling()
self.assertEqual(
logs.output,
["INFO:odoo.modules.registry:Invalidating caches after database signaling: ['assets', 'templates.cached_values']"],
)
def test_signaling_01_multiple(self):
self.assertFalse(self.registry.test_cr)
self.registry.cache_invalidated.clear()
registry = self.registry
old_sequences = dict(registry.cache_sequences)
with self.assertLogs('odoo.modules.registry') as logs:
registry.cache_invalidated.add('assets')
registry.cache_invalidated.add('default')
self.assertEqual(registry.cache_invalidated, {'assets', 'default'})
registry.signal_changes()
self.assertFalse(registry.cache_invalidated)
self.assertEqual(
logs.output,
[
"INFO:odoo.modules.registry:Caches invalidated, signaling through the database: ['assets', 'default']",
],
)
for key, value in old_sequences.items():
if key in ('assets', 'default'):
self.assertEqual(value + 1, registry.cache_sequences[key], "Assets and default cache sequence should have changed")
else:
self.assertEqual(value, registry.cache_sequences[key], "other registry sequence shouldn't have changed")
with self.assertNoLogs(None, None): # the registry sequence should be up to date on the same worker
registry.check_signaling()
# simulate other worker state
registry.cache_sequences.update(old_sequences)
with self.assertLogs() as logs:
registry.check_signaling()
self.assertEqual(
logs.output,
["INFO:odoo.modules.registry:Invalidating caches after database signaling: ['assets', 'default', 'templates.cached_values']"],
)

View file

@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tests.common import BaseCase
from odoo.tests.common import BaseCase, TransactionCase
from odoo.tools import Query
@ -50,12 +50,12 @@ class QueryTestCase(BaseCase):
alias = query.left_join("product_template__categ_id", "user_id", "res_user", "id", "user_id")
self.assertEqual(alias, 'product_template__categ_id__user_id')
# additional implicit join
query.add_table('account.account')
query.add_table('account_account')
query.add_where("product_category.expense_account_id = account_account.id")
from_clause, where_clause, where_params = query.get_sql()
self.assertEqual(from_clause,
'"product_product", "product_template", "account.account" JOIN "product_category" AS "product_template__categ_id" ON ("product_template"."categ_id" = "product_template__categ_id"."id") LEFT JOIN "res_user" AS "product_template__categ_id__user_id" ON ("product_template__categ_id"."user_id" = "product_template__categ_id__user_id"."id")')
'"product_product", "product_template", "account_account" JOIN "product_category" AS "product_template__categ_id" ON ("product_template"."categ_id" = "product_template__categ_id"."id") LEFT JOIN "res_user" AS "product_template__categ_id__user_id" ON ("product_template__categ_id"."user_id" = "product_template__categ_id__user_id"."id")')
self.assertEqual(where_clause, "product_product.template_id = product_template.id AND product_category.expense_account_id = account_account.id")
def test_raise_missing_lhs(self):
@ -99,3 +99,33 @@ class QueryTestCase(BaseCase):
query.join('foo', 'bar_id', 'SELECT id FROM foo', 'id', 'bar')
from_clause, where_clause, where_params = query.get_sql()
self.assertEqual(from_clause, '"foo" JOIN (SELECT id FROM foo) AS "foo__bar" ON ("foo"."bar_id" = "foo__bar"."id")')
class TestQuery(TransactionCase):
def test_auto(self):
model = self.env['res.partner.category']
model.create([{'name': 'Test Category 1'}, {'name': 'Test Category 2'}])
query = model._search([])
self.assertIsInstance(query, Query)
ids = list(query)
self.assertGreater(len(ids), 1)
def test_records_as_query(self):
records = self.env['res.partner.category']
query = records._as_query()
self.assertEqual(list(query), records.ids)
self.cr.execute(*query.select())
self.assertEqual([row[0] for row in self.cr.fetchall()], records.ids)
records = self.env['res.partner.category'].search([])
query = records._as_query()
self.assertEqual(list(query), records.ids)
self.cr.execute(*query.select())
self.assertEqual([row[0] for row in self.cr.fetchall()], records.ids)
records = records.browse(reversed(records.ids))
query = records._as_query()
self.assertEqual(list(query), records.ids)
self.cr.execute(*query.select())
self.assertEqual([row[0] for row in self.cr.fetchall()], records.ids)

View file

@ -3,7 +3,7 @@
from odoo.tests.common import TransactionCase
from odoo.tools import pdf
from odoo.modules.module import get_module_resource
from odoo.tools.misc import file_open
import io
@ -12,8 +12,7 @@ class TestPdf(TransactionCase):
def setUp(self):
super().setUp()
file_path = get_module_resource('base', 'tests', 'minimal.pdf')
self.file = open(file_path, 'rb').read()
self.file = file_open('base/tests/minimal.pdf', 'rb').read()
self.minimal_reader_buffer = io.BytesIO(self.file)
self.minimal_pdf_reader = pdf.OdooPdfFileReader(self.minimal_reader_buffer)
@ -24,24 +23,29 @@ class TestPdf(TransactionCase):
pdf_writer = pdf.PdfFileWriter()
pdf_writer.cloneReaderDocumentRoot(self.minimal_pdf_reader)
pdf_writer.addAttachment('test_attachment.txt', b'My awesome attachment')
out = io.BytesIO()
pdf_writer.write(out)
attachments = list(self.minimal_pdf_reader.getAttachments())
self.assertEqual(len(attachments), 1)
r = pdf.OdooPdfFileReader(io.BytesIO(out.getvalue()))
self.assertEqual(len(list(r.getAttachments())), 1)
def test_odoo_pdf_file_writer(self):
attachments = list(self.minimal_pdf_reader.getAttachments())
self.assertEqual(len(attachments), 0)
r = self.minimal_pdf_reader
pdf_writer = pdf.OdooPdfFileWriter()
pdf_writer.cloneReaderDocumentRoot(self.minimal_pdf_reader)
for count, (name, data) in enumerate([
('test_attachment.txt', b'My awesome attachment'),
('another_attachment.txt', b'My awesome OTHER attachment'),
], start=1):
pdf_writer = pdf.OdooPdfFileWriter()
pdf_writer.cloneReaderDocumentRoot(r)
pdf_writer.addAttachment(name, data)
out = io.BytesIO()
pdf_writer.write(out)
pdf_writer.addAttachment('test_attachment.txt', b'My awesome attachment')
attachments = list(self.minimal_pdf_reader.getAttachments())
self.assertEqual(len(attachments), 1)
pdf_writer.addAttachment('another_attachment.txt', b'My awesome OTHER attachment')
attachments = list(self.minimal_pdf_reader.getAttachments())
self.assertEqual(len(attachments), 2)
r = pdf.OdooPdfFileReader(io.BytesIO(out.getvalue()))
self.assertEqual(len(list(r.getAttachments())), count)
def test_odoo_pdf_file_reader_with_owner_encryption(self):
pdf_writer = pdf.OdooPdfFileWriter()

View file

@ -646,7 +646,7 @@ class TestSyncRecorder(BaseCase):
# map stack frames to their function name, and check
stacks_methods = [[frame[2] for frame in stack] for stack in stacks]
self.assertEqual(stacks_methods, [
self.assertEqual(stacks_methods[:-2], [
['a'],
['a', 'b'],
['a'],
@ -657,8 +657,6 @@ class TestSyncRecorder(BaseCase):
['a', 'c'],
['a'],
[],
['__exit__'],
['__exit__', 'stop'] # could be removed by cleaning two last frames, or removing last frames only contained in profiler.py
])
# map stack frames to their line number, and check

View file

@ -12,7 +12,6 @@ from lxml.builder import E
from copy import deepcopy
from textwrap import dedent
from odoo.modules import get_module_resource
from odoo.tests.common import TransactionCase
from odoo.addons.base.models.ir_qweb import QWebException, render
from odoo.tools import misc, mute_logger
@ -662,7 +661,7 @@ class TestQWebNS(TransactionCase):
"""
})
error_msg = ''
error_msg = None
try:
"" + 0
except TypeError as e:
@ -671,6 +670,36 @@ class TestQWebNS(TransactionCase):
with self.assertRaises(QWebException, msg=error_msg):
self.env['ir.qweb']._render(view1.id)
def test_render_static_xml_with_void_element(self):
""" Test the rendering on a namespaced view with dynamic URI (need default namespace uri).
"""
tempate = """
<rss xmlns:g="http://base.google.com/ns/1.0" version="2.0">
<g:brand>Odoo</g:brand>
<g:link>My Link</g:link>
</rss>
"""
expected_result = """
<rss xmlns:g="http://base.google.com/ns/1.0" version="2.0">
<g:brand>Odoo</g:brand>
<g:link>My Link</g:link>
</rss>
"""
view1 = self.env['ir.ui.view'].create({
'name': "dummy",
'type': 'qweb',
'arch': """
<t t-name="base.dummy">%s</t>
""" % tempate
})
rendering = self.env['ir.qweb']._render(view1.id)
self.assertEqual(etree.fromstring(rendering), etree.fromstring(expected_result))
class TestQWebBasic(TransactionCase):
def test_compile_expr(self):
tests = [
@ -1667,7 +1696,7 @@ class TestQWebBasic(TransactionCase):
QWeb.with_context(preserve_comments=False)._render(view.id),
markupsafe.Markup('<p></p>'),
"Should not have the comment")
QWeb.clear_caches()
self.env.registry.clear_cache('templates')
self.assertEqual(
QWeb.with_context(preserve_comments=True)._render(view.id),
markupsafe.Markup(f'<p>{comment}</p>'),
@ -1688,12 +1717,38 @@ class TestQWebBasic(TransactionCase):
QWeb.with_context(preserve_comments=False)._render(view.id),
markupsafe.Markup('<p></p>'),
"Should not have the processing instruction")
QWeb.clear_caches()
self.env.registry.clear_cache('templates')
self.assertEqual(
QWeb.with_context(preserve_comments=True)._render(view.id),
markupsafe.Markup(f'<p>{p_instruction}</p>'),
"Should have the processing instruction")
def test_render_widget_contact(self):
u = self.env['res.users'].create({
'name': 'Test',
'login': 'test@example.com',
})
u.name = ""
view1 = self.env['ir.ui.view'].create({
'name': "dummy",
'type': 'qweb',
'arch': """
<t t-name="base.dummy"><root><span t-esc="user" t-options='{"widget": "contact", "fields": ["name"]}' /></root></t>
"""
})
self.env['ir.qweb']._render(view1.id, {'user': u}) # should not crash
def test_render_widget_duration_fallback(self):
self.env['res.lang'].with_context(active_test=False).search([('code', '=', 'pt_BR')]).active = True
view1 = self.env['ir.ui.view'].create({
'name': "dummy",
'type': 'qweb',
'arch': """
<t t-name="base.dummy"><root><span t-esc="3600" t-options='{"widget": "duration", "format": "short"}' /></root></t>
"""
})
self.env['ir.qweb'].with_context(lang='pt_BR')._render(view1.id, {}) # should not crash
def test_void_element(self):
view = self.env['ir.ui.view'].create({
'name': 'master',
@ -3121,26 +3176,6 @@ class TestQwebCache(TransactionCase):
result = etree.fromstring(IrQweb._render(view2.id, {'value': [10, 20, 30]}))
self.assertEqual(result, expected_result, 'Next rendering create cache from company and the value 10')
class FileSystemLoader(object):
def __init__(self, path):
# TODO: support multiple files #add_file() + add cache
self.path = path
self.doc = etree.parse(path).getroot()
def __iter__(self):
for node in self.doc:
name = node.get('t-name')
if name:
yield name
def __call__(self, name):
for node in self.doc:
if node.get('t-name') == name:
return (deepcopy(node), name)
class TestQWebStaticXml(TransactionCase):
matcher = re.compile(r'^qweb-test-(.*)\.xml$')
def test_render_nodb(self):
""" Render an html page without db ans wihtout registry
"""
@ -3181,58 +3216,3 @@ class TestQWebStaticXml(TransactionCase):
rendering = render('html', {'val': 3}, load).strip()
self.assertEqual(html.document_fromstring(rendering), html.document_fromstring(expected))
@classmethod
def get_cases(cls):
path = cls.qweb_test_file_path()
return (
cls("test_qweb_{}".format(cls.matcher.match(f).group(1)))
for f in os.listdir(path)
# js inheritance
if f != 'qweb-test-extend.xml'
if cls.matcher.match(f)
)
@classmethod
def qweb_test_file_path(cls):
return os.path.dirname(get_module_resource('web', 'static', 'lib', 'qweb', 'qweb2.js'))
def __getattr__(self, item):
if not item.startswith('test_qweb_'):
raise AttributeError("No {} on {}".format(item, self))
f = 'qweb-test-{}.xml'.format(item[10:])
path = self.qweb_test_file_path()
return lambda: self.run_test_file(os.path.join(path, f))
@mute_logger('odoo.addons.base.models.ir_qweb') # tests t-raw which is deprecated
def run_test_file(self, path):
self.env.user.tz = 'Europe/Brussels'
doc = etree.parse(path).getroot()
loader = FileSystemLoader(path)
for template in loader:
if not template or template.startswith('_'):
continue
param = doc.find('params[@id="{}"]'.format(template))
# OrderedDict to ensure JSON mappings are iterated in source order
# so output is predictable & repeatable
params = {} if param is None else json.loads(param.text, object_pairs_hook=collections.OrderedDict)
def remove_space(text):
return re.compile(r'\>[ \n\t]*\<').sub('><', text.strip())
result = remove_space(doc.find('result[@id="{}"]'.format(template)).text or u'').replace('&quot;', '&#34;')
try:
rendering_static = remove_space(render(template, values=params, load=loader))
self.assertEqual(rendering_static, result, "%s (static rendering)" % template)
except QWebException as e:
if not isinstance(e.__cause__, NotImplementedError) and "Please use \"env['ir.qweb']._render\" method" in str(e):
raise
def load_tests(loader, suite, _):
# can't override TestQWebStaticXml.__dir__ because dir() called on *class* not
# instance
suite.addTests(TestQWebStaticXml.get_cases())
return suite

View file

@ -1,10 +1,23 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import io
import logging
from base64 import b64decode
from unittest import skipIf
import odoo
import odoo.tests
try:
from pdfminer.converter import PDFPageAggregator
from pdfminer.layout import LAParams, LTFigure, LTTextBox
from pdfminer.pdfdocument import PDFDocument
from pdfminer.pdfinterp import PDFResourceManager, PDFPageInterpreter
from pdfminer.pdfpage import PDFPage
from pdfminer.pdfparser import PDFParser
pdfminer = True
except ImportError:
pdfminer = False
_logger = logging.getLogger(__name__)
@ -41,6 +54,507 @@ class TestReports(odoo.tests.TransactionCase):
else:
continue
def test_report_reload_from_attachment(self):
def get_attachments(res_id):
return self.env["ir.attachment"].search([('res_model', "=", "res.partner"), ("res_id", "=", res_id)])
Report = self.env['ir.actions.report'].with_context(force_report_rendering=True)
report = Report.create({
'name': 'test report',
'report_name': 'base.test_report',
'model': 'res.partner',
})
self.env['ir.ui.view'].create({
'type': 'qweb',
'name': 'base.test_report',
'key': 'base.test_report',
'arch': '''
<main>
<div class="article" data-oe-model="res.partner" t-att-data-oe-id="docs.id">
<span t-field="docs.display_name" />
</div>
</main>
'''
})
pdf_text = "0"
def _run_wkhtmltopdf(*args, **kwargs):
return bytes(pdf_text, "utf-8")
self.patch(type(Report), "_run_wkhtmltopdf", _run_wkhtmltopdf)
# sanity check: the report is not set to save attachment
# assert that there are no pre-existing attachment
partner_id = self.env.user.partner_id.id
self.assertFalse(get_attachments(partner_id))
pdf = report._render_qweb_pdf(report.id, [partner_id])
self.assertFalse(get_attachments(partner_id))
self.assertEqual(pdf[0], b"0")
# set the report to reload from attachment and make one
pdf_text = "1"
report.attachment = "'test_attach'"
report.attachment_use = True
report._render_qweb_pdf(report.id, [partner_id])
attach_1 = get_attachments(partner_id)
self.assertTrue(attach_1.exists())
# use the context key to not reload from attachment
# and not create another one
pdf_text = "2"
report = report.with_context(report_pdf_no_attachment=True)
pdf = report._render_qweb_pdf(report.id, [partner_id])
attach_2 = get_attachments(partner_id)
self.assertEqual(attach_2.id, attach_1.id)
self.assertEqual(b64decode(attach_1.datas), b"1")
self.assertEqual(pdf[0], b"2")
# Some paper format examples
PAPER_SIZES = {
(842, 1190): 'A3',
(595, 842): 'A4',
(420, 595): 'A5',
(297, 420): 'A6',
(612, 792): 'Letter',
(612, 1008): 'Legal',
(792, 1224): 'Ledger',
}
class Box:
"""
Utility class to help assertions
"""
def __init__(self, obj, page_height, page_width):
self.x1 = round(obj.x0, 1)
self.y1 = round(page_height-obj.y1, 1)
self.x2 = round(obj.x1, 1)
self.y2 = round(page_height-obj.y0, 1)
self.page_height = page_height
self.page_width = page_width
@property
def height(self):
return self.y2 - self.y1
@property
def width(self):
return self.x2 - self.x1
@property
def top(self):
return self.y1
@property
def left(self):
return self.x1
@property
def end_top(self):
return self.y2
@property
def end_left(self):
return self.x2
@property
def right(self):
return self.page_width - self.x2
@property
def bottom(self):
return self.page_height - self.y2
def __lt__(self, other):
return (self.y1, self.x1, self.y2, self.x2) < (other.y1, other.x1, other.y2, other.x2)
@skipIf(pdfminer is False, "pdfminer not installed")
class TestReportsRenderingCommon(odoo.tests.HttpCase):
def setUp(self):
super().setUp()
self.report = self.env['ir.actions.report'].create({
'name': 'Test Report Partner',
'model': 'res.partner',
'report_name': 'test_report.test_report_partner',
'paperformat_id': self.env.ref('base.paperformat_euro').id,
})
self.partners = self.env['res.partner'].create([{
'name': f'Report record {i}',
} for i in range(2)])
self.report_view = self.env['ir.ui.view'].create({
'type': 'qweb',
'name': 'test_report_partner',
'key': 'test_report.test_report_partner',
'arch': "<t></t>",
})
self.last_pdf_content = None
self.last_pdf_content_saved = False
def _addError(self, result, test, exc_info):
if self.last_pdf_content and not self.last_pdf_content_saved:
self.last_pdf_content_saved = True
self.save_pdf()
super()._addError(result, test, exc_info)
def get_paper_format(self, mediabox):
"""
:param: mediabox: a page mediabox. (Example: (0, 0, 595, 842))
:return: a (format, orientation). Example ('A4', 'portait')
"""
x, y, width, height = mediabox
self.assertEqual((x, y), (0, 0), "Expecting top corner to be 0, 0 ")
orientation = 'portait'
paper_size = (width, height)
if width > height:
orientation = 'landscape'
paper_size = (height, width)
return PAPER_SIZES.get(paper_size, f'custom{paper_size}'), orientation
def create_pdf(self, partners=None, header_content=None, page_content=None, footer_content=None):
if header_content is None:
header_content = '''
<img t-if="company.logo" t-att-src="image_data_uri(company.logo)" style="max-height: 45px;" alt="Logo"/>
<span>Some header Text</span>
'''
if footer_content is None:
footer_content = '''
<div style="text-align:center">Footer for <t t-esc="o.name"/> Page: <span class="page"/> / <span class="topage"/></div>
'''
if page_content is None:
page_content = '''
<div class="page">
<div style="background-color:red">
Name: <t t-esc="o.name"/>
</div>
</div>
'''
self.report_view.arch = f'''
<t t-name="test_report.test_report_partner">
<t t-set="company" t-value="res_company"/>
<t t-call="web.html_container">
<t t-foreach="docs" t-as="o">
<div class="header" style="font-family:Sans">
{header_content}
</div>
<div class="article" style="font-family:Sans">
{page_content}
</div>
<div class="footer" style="font-family:Sans">
{footer_content}
</div>
</t>
</t>
</t>
'''
# this templates doesn't use the "web.external_layout" in order to simplify the final result and make the edition of footer and header easier
# this test does not aims to test company base.document.layout, but the rendering only.
if partners is None:
partners = self.partners
self.last_pdf_content = self.env['ir.actions.report'].with_context(force_report_rendering=True)._render_qweb_pdf(self.report, partners.ids)[0]
return self.last_pdf_content
def save_pdf(self):
assert self.last_pdf_content
odoo.tests.save_test_file(self._testMethodName, self.last_pdf_content, 'pdf_', 'pdf', document_type='Report PDF', logger=_logger)
def _get_pdf_pages(self, pdf_content):
ioBytes = io.BytesIO(pdf_content)
parser = PDFParser(ioBytes)
doc = PDFDocument(parser)
return list(PDFPage.create_pages(doc))
def _parse_pdf(self, pdf_content, expected_format=('A4', 'portait')):
"""
:param: pdf_content: the bdf binary content
:param: expected_format: a get_paper_format like format.
:return: list[list[(box, Element)]] a list of element per page
Note: box is a 4 float tuple based on the top left corner to ease ordering of elements.
The result is also rounded to one digit
"""
pages = self._get_pdf_pages(pdf_content)
ressource_manager = PDFResourceManager()
device = PDFPageAggregator(ressource_manager, laparams=LAParams())
interpreter = PDFPageInterpreter(ressource_manager, device)
parsed_pages = []
for page in pages:
self.assertEqual(
self.get_paper_format(page.mediabox),
expected_format,
"Expecting pdf to be in A4 portait format",
) # this is the default expected format and other layout assertions are based on this one.
interpreter.process_page(page)
layout = device.get_result()
elements = []
parsed_pages.append(elements)
for obj in layout:
box = Box(
obj,
page_height=pages[0].mediabox[3],
page_width=pages[0].mediabox[2],
)
if isinstance(obj, LTTextBox):
#inverse x to start from top left corner
elements.append((box, obj.get_text().strip()))
elif isinstance(obj, LTFigure):
elements.append((box, 'LTFigure'))
elements.sort()
return parsed_pages
def assertPageFormat(self, paper_format, orientation):
pdf_content = self.create_pdf()
pages = self._get_pdf_pages(pdf_content)
self.assertEqual(len(pages), 2)
for page in pages:
self.assertEqual(
self.get_paper_format(page.mediabox),
(paper_format, orientation),
f"Expecting pdf to be in {paper_format} {orientation} format",
)
@odoo.tests.tagged('post_install', '-at_install', 'pdf_rendering')
class TestReportsRendering(TestReportsRenderingCommon):
"""
This test aims to test as much as possible the current pdf rendering,
especially multipage headers and footers
(the main reason why we are currently using wkhtmltopdf with patched qt)
A custom template without web.external_layout is used on purpose in order to
easily test headers and footer regarding rendering only,
without using any comany document.layout logic
"""
def test_format_A4(self):
self.report.paperformat_id = self.env.ref('base.paperformat_euro')
self.assertPageFormat('A4', 'portait')
def test_format_letter(self):
self.report.paperformat_id = self.env.ref('base.paperformat_us')
self.assertPageFormat('Letter', 'portait')
def test_format_landscape(self):
paper_format = self.env.ref('base.paperformat_euro')
paper_format.orientation = 'Landscape'
self.report.paperformat_id = paper_format
self.assertPageFormat('A4', 'landscape')
def test_layout(self):
pdf_content = self.create_pdf()
pages = self._parse_pdf(pdf_content)
self.assertEqual(len(pages), 2)
page_contents = [[elem[1] for elem in page] for page in pages]
expected_pages_content = [[
'LTFigure',
'Some header Text',
f'Name: {partner.name}',
f'Footer for {partner.name} Page: 1 / 1',
] for partner in self.partners]
self.assertEqual(
page_contents,
expected_pages_content,
)
page_positions = [[elem[0] for elem in page] for page in pages]
logo, header, content, footer = page_positions[0]
# leaving this as reference but this is to fragile to make a strict assertion
# 14.3, 29.6, 43.1, 137.2 # logo
# 19.1, 137.2, 32.5, 214.2 # header
# 111.3, 29.6, 124.8, 123.7 # content
# 751.6, 220.1, 765.1, 375.0 # footer
#
# \ \ / // _ \ | | | || _ \ | |
# \ V /| (_) || |_| || / | |__ / _ \/ _` |/ _ \ Some header Text
# |_| \___/ \___/ |_|_\ |____|\___/\__, |\___/
#
#
# Name: Report record 0
#
#
#
#
#
#
# Footer for Report record 0 Page: 1 / 1
#
#
self.assertEqual(logo.left, content.left, 'Logo and content should have the same left margin')
self.assertEqual(header.left, logo.end_left, 'Header starts after logo')
self.assertGreaterEqual(header.top, logo.top, 'header is vertically centered on logo')
self.assertGreaterEqual(logo.end_top, header.end_top, 'header is vertically centered on logo')
self.assertGreaterEqual(content.top, logo.end_top, 'Content is bellow logo')
self.assertGreaterEqual(footer.top, content.end_top, 'Footer is bellow content')
self.assertGreaterEqual(100, footer.bottom, 'Footer is on the bottom of the page')
self.assertAlmostEqual(footer.left, footer.right, -1, 'Footer is centered on the page')
def test_report_pdf_page_break(self):
partners = self.partners[:2]
page_content = '''
<div class="page">
<div style="background-color:red">
Name: <t t-esc="o.name"/>
</div>
<div style="page-break-before:always;background-color:blue">
Last page for <t t-esc="o.name"/>
</div>
</div>
'''
pdf_content = self.create_pdf(partners=partners, page_content=page_content)
pages = self._parse_pdf(pdf_content)
self.assertEqual(len(pages), 4, "Expecting 2 pages * 2 partners")
expected_pages_contents = []
for partner in self.partners:
expected_pages_contents.append([
'LTFigure', #logo
'Some header Text',
f'Name: {partner.name}',
f'Footer for {partner.name} Page: 1 / 2',
])
expected_pages_contents.append([
'LTFigure', #logo
'Some header Text',
f'Last page for {partner.name}',
f'Footer for {partner.name} Page: 2 / 2',
])
pages_contents = [[elem[1] for elem in page] for page in pages]
self.assertEqual(pages_contents, expected_pages_contents)
def test_pdf_render_page_overflow(self):
nb_lines = 80
page_content = f'''
<div class="page">
<div style="background-color:red">
Name: <t t-esc="o.name"/>
<div t-foreach="range({nb_lines})" t-as="pos" t-esc="pos"/>
</div>
</div>
'''
pdf_content = self.create_pdf(page_content=page_content)
pages = self._parse_pdf(pdf_content)
self.assertEqual(len(pages), 4, '4 pages are expected, 2 per record (you may ensure `nb_lines` has a correct value to generate an oveflow)')
page_break_at = int(pages[1][2][1].split('\n')[0]) # This element should be the first line, 61 when this test was written
expected_pages_contents = []
for partner in self.partners:
expected_pages_contents.append([
'LTFigure', #logo
'Some header Text',
f'Name: {partner.name}\n' + '\n'.join([str(i) for i in range(page_break_at)]),
f'Footer for {partner.name} Page: 1 / 2',
])
expected_pages_contents.append([
'LTFigure', #logo
'Some header Text',
'\n'.join([str(i) for i in range(page_break_at, nb_lines)]),
f'Footer for {partner.name} Page: 2 / 2',
])
pages_contents = [[elem[1] for elem in page] for page in pages]
self.assertEqual(pages_contents, expected_pages_contents)
def test_thead_tbody_repeat(self):
"""
Check that thead and t-foot are repeated after page break inside a tbody
"""
nb_lines = 50
page_content = f'''
<div class="page">
<table class="table">
<thead><tr><th> T1 </th><th> T2 </th><th> T3 </th></tr></thead>
<tbody>
<t t-foreach="range({nb_lines})" t-as="pos">
<tr><td><t t-esc="pos"/></td><td><t t-esc="pos"/></td><td><t t-esc="pos"/></td></tr>
</t>
</tbody>
<tfoot><tr><th> T1 </th><th> T2 </th><th> T3 </th></tr></tfoot>
</table>
</div>
'''
pdf_content = self.create_pdf(page_content=page_content)
pages = self._parse_pdf(pdf_content)
self.assertEqual(len(pages), 4, '4 pages are expected, 2 per record (you may ensure `nb_lines` has a correct value to generate an oveflow)')
page_break_at = int(pages[1][5][1]) # This element should be the first line of the table, 28 when this test was written
def expected_table(start, end):
table = ['T1', 'T2', 'T3'] # thead
for i in range(start, end):
table += [str(i), str(i), str(i)]
table += ['T1', 'T2', 'T3'] # tfoot
return table
expected_pages_contents = []
for partner in self.partners:
expected_pages_contents.append([
'LTFigure', #logo
'Some header Text',
* expected_table(0, page_break_at),
f'Footer for {partner.name} Page: 1 / 2',
])
expected_pages_contents.append([
'LTFigure', #logo
'Some header Text',
* expected_table(page_break_at, nb_lines),
f'Footer for {partner.name} Page: 2 / 2',
])
pages_contents = [[elem[1] for elem in page] for page in pages]
self.assertEqual(pages_contents, expected_pages_contents)
@odoo.tests.tagged('post_install', '-at_install', '-standard', 'pdf_rendering')
class TestReportsRenderingLimitations(TestReportsRenderingCommon):
def test_no_clip(self):
"""
Current version will add a fixed margin on top of document
This test demonstrates this limitation
"""
header_content = '''
<div style="background-color:blue">
<div t-foreach="range(15)" t-as="pos" t-esc="'Header %s' % pos"/>
</div>
'''
page_content = '''
<div class="page">
<div style="background-color:red; margin-left:100px">
<div t-foreach="range(10)" t-as="pos" t-esc="'Content %s' % pos"/>
</div>
</div>
'''
# adding a margin on page to avoid bot block to me considered as the same
pdf_content = self.create_pdf(page_content=page_content, header_content=header_content)
pages = self._parse_pdf(pdf_content)
self.assertEqual(len(pages), 2, "2 partners")
page = pages[0]
self.assertEqual(len(page), 3, "Expecting 3 box per page, Header, body, footer")
header = page[0][0]
content = page[1][0]
self.assertGreaterEqual(content.top, header.end_top, "EXISTING LIMITATION: large header shouldn't overflow on body, but they do")
@odoo.tests.tagged('post_install', '-at_install')
class TestAggregatePdfReports(odoo.tests.HttpCase):

View file

@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.exceptions import ValidationError
from odoo import Command
from odoo.exceptions import UserError, ValidationError
from odoo.tests.common import TransactionCase
@ -38,3 +38,33 @@ class TestCompany(TransactionCase):
'company_ids': main_company.ids,
})
user.action_unarchive()
def test_logo_check(self):
"""Ensure uses_default_logo is properly (re-)computed."""
company = self.env['res.company'].create({'name': 'foo'})
self.assertTrue(company.logo, 'Should have a default logo')
self.assertTrue(company.uses_default_logo)
company.partner_id.image_1920 = False
# No logo means we fall back to another default logo for the website route -> uses_default
self.assertTrue(company.uses_default_logo)
company.partner_id.image_1920 = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
self.assertFalse(company.uses_default_logo)
def test_unlink_company_with_children(self):
"""Ensure that companies with child companies cannot be deleted."""
parent_company = self.env['res.company'].create({
'name': 'Parent Company',
'child_ids': [
Command.create({'name': 'Child Company'}),
],
})
with self.assertRaises(UserError):
parent_company.unlink()
self.assertTrue(parent_company.exists())
def test_create_branch_with_default_parent_id(self):
branch = self.env['res.company'].with_context(default_parent_id=self.env.company.id).create({'name': 'Branch Company'})
self.assertFalse(branch.partner_id.parent_id)

View file

@ -85,6 +85,7 @@ class TestResConfig(TransactionCase):
# Check returned value
self.assertEqual(res.args[0], self.expected_final_error_msg_wo_menu)
# TODO: ASK DLE if this test can be removed
def test_40_view_expected_architecture(self):
"""Tests the res.config.settings form view architecture expected by the web client.
The res.config.settings form view is handled with a custom widget expecting a very specific
@ -99,11 +100,11 @@ class TestResConfig(TransactionCase):
'model': 'res.config.settings',
'inherit_id': self.env.ref('base.res_config_settings_view_form').id,
'arch': """
<xpath expr="//div[hasclass('settings')]" position="inside">
<xpath expr="//form" position="inside">
<t groups="base.group_system">
<div class="app_settings_block" data-string="Foo" string="Foo" data-key="foo">
<app data-string="Foo" string="Foo" name="foo">
<h2>Foo</h2>
</div>
</app>
</t>
</xpath>
""",
@ -111,14 +112,13 @@ class TestResConfig(TransactionCase):
arch = self.env['res.config.settings'].get_view(view.id)['arch']
tree = etree.fromstring(arch)
self.assertTrue(tree.xpath("""
//form[@class="oe_form_configuration o_base_settings"]
/div[@class="o_setting_container"]
/div[@class="settings"]
/div[@class="app_settings_block"][@data-key="foo"]
//form[@class="oe_form_configuration"]
/app[@name="foo"]
"""), 'The res.config.settings form view architecture is not what is expected by the web client.')
# TODO: ASK DLE if this test can be removed
def test_50_view_expected_architecture_t_node_groups(self):
"""Tests the behavior of the res.config.settings form view postprocessing when a block `app_settings_block`
"""Tests the behavior of the res.config.settings form view postprocessing when a block `app`
is wrapped in a `<t groups="...">`, which is used when you need to display an app settings section
only for users part of two groups at the same time."""
view = self.env['ir.ui.view'].create({
@ -127,12 +127,12 @@ class TestResConfig(TransactionCase):
'model': 'res.config.settings',
'inherit_id': self.env.ref('base.res_config_settings_view_form').id,
'arch': """
<xpath expr="//div[hasclass('settings')]" position="inside">
<xpath expr="//form" position="inside">
<t groups="base.group_system">
<div class="app_settings_block" data-string="Foo"
string="Foo" data-key="foo" groups="base.group_no_one">
<app data-string="Foo"
string="Foo" name="foo" groups="base.group_no_one">
<h2>Foo</h2>
</div>
</app>
</t>
</xpath>
""",
@ -143,9 +143,9 @@ class TestResConfig(TransactionCase):
# The <t> must be removed from the structure
self.assertFalse(tree.xpath('//t'), 'The `<t groups="...">` block must not remain in the view')
self.assertTrue(tree.xpath("""
//div[@class="settings"]
/div[@class="app_settings_block"][@data-key="foo"]
"""), 'The `class="app_settings_block"` block must be a direct child of the `class="settings"` block')
//form
/app[@name="foo"]
"""), 'The `app` block must be a direct child of the `form` block')
@tagged('post_install', '-at_install')
@ -231,7 +231,7 @@ class TestResConfigExecute(TransactionCase):
settings.set_values()
# Check user has access to all models of relational fields in view
# because the webclient makes a name_get request for all specified records
# because the webclient makes a read of display_name request for all specified records
# even if they are not shown to the user.
settings_view_arch = etree.fromstring(settings.get_view(view_id=self.settings_view.id)['arch'])
seen_fields = set()

View file

@ -1,10 +1,11 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from lxml import etree
from odoo import Command
from odoo.tests.common import TransactionCase
class TestResConfig(TransactionCase):
class TestResCurrency(TransactionCase):
def test_view_company_rate_label(self):
"""Tests the label of the company_rate and inverse_company_rate fields
are well set according to the company currency in the currency form view and the currency rate list view.
@ -22,6 +23,85 @@ class TestResConfig(TransactionCase):
self.assertEqual(node_company_rate.get('string'), f'Unit per {expected_currency}')
self.assertEqual(node_inverse_company_rate.get('string'), f'{expected_currency} per Unit')
def test_currency_cache(self):
currencyA, currencyB = self.env['res.currency'].create([{
'name': 'AAA',
'symbol': 'AAA',
'rate_ids': [Command.create({'name': '2009-09-09', 'rate': 1})]
}, {
'name': 'BBB',
'symbol': 'BBB',
'rate_ids': [
Command.create({'name': '2009-09-09', 'rate': 1}),
Command.create({'name': '2011-11-11', 'rate': 2}),
],
}])
self.assertEqual(currencyA._convert(
from_amount=100,
to_currency=currencyB,
company=self.env.company,
date='2010-10-10',
), 100)
# update the (cached) rate of the to_currency used in the previous query
self.env['res.currency.rate'].search([
('currency_id', '=', currencyB.id),
('name', '=', '2009-09-09')]
).rate = 3
# repeat _convert call
# the cached conversion rate is invalid due to the rate change -> query
with self.assertQueryCount(1):
self.assertEqual(currencyA._convert(
from_amount=100,
to_currency=currencyB,
company=self.env.company,
date='2010-10-10',
), 300)
# create a new rate of the to_currency for the date used in the previous query
self.env['res.currency.rate'].create({
'name': '2010-10-10',
'rate': 4,
'currency_id': currencyB.id,
'company_id': self.env.company.id,
})
# repeat _convert call
# the cached conversion rate is invalid due to the new rate of the to_currency -> query
with self.assertQueryCount(1):
self.assertEqual(currencyA._convert(
from_amount=100,
to_currency=currencyB,
company=self.env.company,
date='2010-10-10',
), 400)
# only one query is done when changing the convert params
with self.assertQueryCount(1):
self.assertEqual(currencyA._convert(
from_amount=100,
to_currency=currencyB,
company=self.env.company,
date='2011-11-11',
), 200)
# cache holds multiple values
with self.assertQueryCount(0):
self.assertEqual(currencyA._convert(
from_amount=100,
to_currency=currencyB,
company=self.env.company,
date='2010-10-10',
), 400)
self.assertEqual(currencyA._convert(
from_amount=100,
to_currency=currencyB,
company=self.env.company,
date='2011-11-11',
), 200)
def test_res_currency_name_search(self):
currency_A, currency_B = self.env["res.currency"].create([
{"name": "cuA", "symbol": "A"},
@ -32,9 +112,9 @@ class TestResConfig(TransactionCase):
{"name": "1971-01-01", "rate": 1.5, "currency_id": currency_B.id},
{"name": "1972-01-01", "rate": 0.69, "currency_id": currency_B.id},
])
# should not try to match field 'rate' (float field)
self.assertEqual(self.env["res.currency"].search_count([["rate_ids", "=", "1971-01-01"]]), 2)
# should not try to match field 'name' (date field)
self.assertEqual(self.env["res.currency"].search_count([["rate_ids", "=", "1971-01-01"]]), 2)
# should not try to match field 'rate' (float field)
self.assertEqual(self.env["res.currency"].search_count([["rate_ids", "=", "0.69"]]), 1)
# should not try to match any of 'name' and 'rate'
self.assertEqual(self.env["res.currency"].search_count([["rate_ids", "=", "irrelevant"]]), 0)

View file

@ -1,15 +1,85 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from contextlib import contextmanager
from unittest.mock import patch
from odoo import Command
from odoo.addons.base.models.ir_mail_server import extract_rfc2822_addresses
from odoo.addons.base.models.res_partner import Partner
from odoo.addons.base.tests.common import TransactionCaseWithUserDemo
from odoo.exceptions import AccessError, RedirectWarning, UserError, ValidationError
from odoo.tests import Form
from odoo.tests.common import TransactionCase
from odoo.exceptions import AccessError, UserError
from odoo.tests import tagged
from odoo.tests.common import tagged, TransactionCase
# samples use effective TLDs from the Mozilla public suffix
# list at http://publicsuffix.org
SAMPLES = [
('"Raoul Grosbedon" <raoul@chirurgiens-dentistes.fr> ', 'Raoul Grosbedon', 'raoul@chirurgiens-dentistes.fr'),
('ryu+giga-Sushi@aizubange.fukushima.jp', 'ryu+giga-sushi@aizubange.fukushima.jp', 'ryu+giga-sushi@aizubange.fukushima.jp'),
('Raoul chirurgiens-dentistes.fr', 'Raoul chirurgiens-dentistes.fr', ''),
(" Raoul O'hara <!@historicalsociety.museum>", "Raoul O'hara", '!@historicalsociety.museum'),
('Raoul Grosbedon <raoul@CHIRURGIENS-dentistes.fr> ', 'Raoul Grosbedon', 'raoul@chirurgiens-dentistes.fr'),
('Raoul megaraoul@chirurgiens-dentistes.fr', 'Raoul', 'megaraoul@chirurgiens-dentistes.fr'),
]
@tagged('res_partner')
class TestPartner(TransactionCase):
class TestPartner(TransactionCaseWithUserDemo):
@contextmanager
def mockPartnerCalls(self):
_original_create = Partner.create
self._new_partners = self.env['res.partner']
def _res_partner_create(model, *args, **kwargs):
records = _original_create(model, *args, **kwargs)
self._new_partners += records.sudo()
return records
with patch.object(Partner, 'create',
autospec=True, side_effect=_res_partner_create):
yield
def _check_find_or_create(self, test_string, expected_name, expected_email, expected_partner=False):
with self.mockPartnerCalls():
partner = self.env['res.partner'].find_or_create(test_string)
if expected_partner:
self.assertEqual(
partner, expected_partner,
f'Should have found {expected_partner.name} ({expected_partner.id}), found {partner.name} ({partner.id}) instead')
self.assertFalse(self._new_partners)
else:
self.assertEqual(
partner, self._new_partners,
f'Should have created a partner, found {partner.name} ({partner.id}) instead'
)
self.assertEqual(partner.name, expected_name)
self.assertEqual(partner.email or '', expected_email)
return partner
def test_archive_internal_partners(self):
test_partner = self.env['res.partner'].create({'name':'test partner'})
test_user = self.env['res.users'].create({
'login': 'test@odoo.com',
'partner_id': test_partner.id,
})
# Cannot archive the partner
with self.assertRaises(RedirectWarning):
test_partner.with_user(self.env.ref('base.user_admin')).toggle_active()
with self.assertRaises(ValidationError):
test_partner.with_user(self.user_demo).toggle_active()
# Can archive the user but the partner stays active
test_user.toggle_active()
self.assertTrue(test_partner.active, 'Parter related to user should remain active')
# Now we can archive the partner
test_partner.toggle_active()
# Activate the user should reactivate the partner
test_user.toggle_active()
self.assertTrue(test_partner.active, 'Activating user must active related partner')
def test_email_formatted(self):
""" Test various combinations of name / email, notably to check result
@ -106,85 +176,36 @@ class TestPartner(TransactionCase):
new_partner.write({'email': source})
self.assertEqual(new_partner.email_formatted, exp_email_formatted)
def test_name_search(self):
""" Check name_search on partner, especially with domain based on auto_join
user_ids field. Check specific SQL of name_search correctly handle joined tables. """
test_partner = self.env['res.partner'].create({'name': 'Vlad the Impaler'})
test_user = self.env['res.users'].create({'name': 'Vlad the Impaler', 'login': 'vlad', 'email': 'vlad.the.impaler@example.com'})
def test_find_or_create(self):
original_partner = self.env['res.partner'].browse(
self.env['res.partner'].name_create(SAMPLES[0][0])[0]
)
all_partners = []
ns_res = self.env['res.partner'].name_search('Vlad', operator='ilike')
self.assertEqual(set(i[0] for i in ns_res), set((test_partner | test_user.partner_id).ids))
for (text_input, expected_name, expected_email), expected_partner, find_idx in zip(
SAMPLES,
[original_partner, False, False, False, original_partner, False,
# patrick example
False, False, False,
# multi email
False],
[0, 0, 0, 0, 0, 0, 0, 6, 0, 0],
):
with self.subTest(text_input=text_input):
if not expected_partner and find_idx:
expected_partner = all_partners[find_idx]
all_partners.append(
self._check_find_or_create(
text_input, expected_name, expected_email,
expected_partner=expected_partner,
)
)
ns_res = self.env['res.partner'].name_search('Vlad', args=[('user_ids.email', 'ilike', 'vlad')])
self.assertEqual(set(i[0] for i in ns_res), set(test_user.partner_id.ids))
# Check a partner may be searched when current user has no access but sudo is used
public_user = self.env.ref('base.public_user')
with self.assertRaises(AccessError):
test_partner.with_user(public_user).check_access_rule('read')
ns_res = self.env['res.partner'].with_user(public_user).sudo().name_search('Vlad', args=[('user_ids.email', 'ilike', 'vlad')])
self.assertEqual(set(i[0] for i in ns_res), set(test_user.partner_id.ids))
def test_name_get(self):
""" Check name_get on partner, especially with different context
Check name_get correctly return name with context. """
test_partner_jetha = self.env['res.partner'].create({'name': 'Jethala', 'street': 'Powder gali', 'street2': 'Gokuldham Society'})
test_partner_bhide = self.env['res.partner'].create({'name': 'Atmaram Bhide'})
res_jetha = test_partner_jetha.with_context(show_address=1).name_get()
self.assertEqual(res_jetha[0][1], "Jethala\nPowder gali\nGokuldham Society", "name should contain comma separated name and address")
res_bhide = test_partner_bhide.with_context(show_address=1).name_get()
self.assertEqual(res_bhide[0][1], "Atmaram Bhide", "name should contain only name if address is not available, without extra commas")
res_jetha = test_partner_jetha.with_context(show_address=1, address_inline=1).name_get()
self.assertEqual(res_jetha[0][1], "Jethala, Powder gali, Gokuldham Society", "name should contain comma separated name and address")
res_bhide = test_partner_bhide.with_context(show_address=1, address_inline=1).name_get()
self.assertEqual(res_bhide[0][1], "Atmaram Bhide", "name should contain only name if address is not available, without extra commas")
def test_company_change_propagation(self):
""" Check propagation of company_id across children """
User = self.env['res.users']
Partner = self.env['res.partner']
Company = self.env['res.company']
company_1 = Company.create({'name': 'company_1'})
company_2 = Company.create({'name': 'company_2'})
test_partner_company = Partner.create({'name': 'This company'})
test_user = User.create({'name': 'This user', 'login': 'thisu', 'email': 'this.user@example.com', 'company_id': company_1.id, 'company_ids': [company_1.id]})
test_user.partner_id.write({'parent_id': test_partner_company.id})
test_partner_company.write({'company_id': company_1.id})
self.assertEqual(test_user.partner_id.company_id.id, company_1.id, "The new company_id of the partner company should be propagated to its children")
test_partner_company.write({'company_id': False})
self.assertFalse(test_user.partner_id.company_id.id, "If the company_id is deleted from the partner company, it should be propagated to its children")
with self.assertRaises(UserError, msg="You should not be able to update the company_id of the partner company if the linked user of a child partner is not an allowed to be assigned to that company"), self.cr.savepoint():
test_partner_company.write({'company_id': company_2.id})
def test_commercial_field_sync(self):
"""Check if commercial fields are synced properly: testing with VAT field"""
Partner = self.env['res.partner']
company_1 = Partner.create({'name': 'company 1', 'is_company': True, 'vat': 'BE0123456789'})
company_2 = Partner.create({'name': 'company 2', 'is_company': True, 'vat': 'BE9876543210'})
partner = Partner.create({'name': 'someone', 'is_company': False, 'parent_id': company_1.id})
Partner.flush_recordset()
self.assertEqual(partner.vat, company_1.vat, "VAT should be inherited from the company 1")
# create a delivery address for the partner
delivery = Partner.create({'name': 'somewhere', 'type': 'delivery', 'parent_id': partner.id})
self.assertEqual(delivery.commercial_partner_id.id, company_1.id, "Commercial partner should be recomputed")
self.assertEqual(delivery.vat, company_1.vat, "VAT should be inherited from the company 1")
# move the partner to another company
partner.write({'parent_id': company_2.id})
partner.flush_recordset()
self.assertEqual(partner.commercial_partner_id.id, company_2.id, "Commercial partner should be recomputed")
self.assertEqual(partner.vat, company_2.vat, "VAT should be inherited from the company 2")
self.assertEqual(delivery.commercial_partner_id.id, company_2.id, "Commercial partner should be recomputed on delivery")
self.assertEqual(delivery.vat, company_2.vat, "VAT should be inherited from the company 2 to delivery")
def test_is_public(self):
""" Check that base.partner_user is a public partner."""
self.assertFalse(self.env.ref('base.public_user').active)
self.assertFalse(self.env.ref('base.public_partner').active)
self.assertTrue(self.env.ref('base.public_partner').is_public)
def test_lang_computation_code(self):
""" Check computation of lang: coming from installed languages, forced
@ -218,6 +239,609 @@ class TestPartner(TransactionCase):
self.assertEqual(first_child.lang, 'de_DE')
self.assertEqual(second_child.lang, 'fr_FR')
def test_name_create(self):
res_partner = self.env['res.partner']
for text, expected_name, expected_mail in SAMPLES:
with self.subTest(text=text):
partner_id, dummy = res_partner.name_create(text)
partner = res_partner.browse(partner_id)
self.assertEqual(expected_name or expected_mail.lower(), partner.name)
self.assertEqual(expected_mail.lower() or False, partner.email)
# name_create supports default_email fallback
partner = self.env['res.partner'].browse(
self.env['res.partner'].with_context(
default_email='John.Wick@example.com'
).name_create('"Raoulette Vachette" <Raoul@Grosbedon.fr>')[0]
)
self.assertEqual(partner.name, 'Raoulette Vachette')
self.assertEqual(partner.email, 'raoul@grosbedon.fr')
partner = self.env['res.partner'].browse(
self.env['res.partner'].with_context(
default_email='John.Wick@example.com'
).name_create('Raoulette Vachette')[0]
)
self.assertEqual(partner.name, 'Raoulette Vachette')
self.assertEqual(partner.email, 'John.Wick@example.com')
def test_name_search(self):
res_partner = self.env['res.partner']
sources = [
('"A Raoul Grosbedon" <raoul@chirurgiens-dentistes.fr>', False),
('B Raoul chirurgiens-dentistes.fr', True),
("C Raoul O'hara <!@historicalsociety.museum>", True),
('ryu+giga-Sushi@aizubange.fukushima.jp', True),
]
for name, active in sources:
_partner_id, dummy = res_partner.with_context(default_active=active).name_create(name)
partners = res_partner.name_search('Raoul')
self.assertEqual(len(partners), 2, 'Incorrect search number result for name_search')
partners = res_partner.name_search('Raoul', limit=1)
self.assertEqual(len(partners), 1, 'Incorrect search number result for name_search with a limit')
self.assertEqual(partners[0][1], 'B Raoul chirurgiens-dentistes.fr', 'Incorrect partner returned, should be the first active')
def test_name_search_with_user(self):
""" Check name_search on partner, especially with domain based on auto_join
user_ids field. Check specific SQL of name_search correctly handle joined tables. """
test_partner = self.env['res.partner'].create({'name': 'Vlad the Impaler'})
test_user = self.env['res.users'].create({'name': 'Vlad the Impaler', 'login': 'vlad', 'email': 'vlad.the.impaler@example.com'})
ns_res = self.env['res.partner'].name_search('Vlad', operator='ilike')
self.assertEqual(set(i[0] for i in ns_res), set((test_partner | test_user.partner_id).ids))
ns_res = self.env['res.partner'].name_search('Vlad', args=[('user_ids.email', 'ilike', 'vlad')])
self.assertEqual(set(i[0] for i in ns_res), set(test_user.partner_id.ids))
# Check a partner may be searched when current user has no access but sudo is used
public_user = self.env.ref('base.public_user')
with self.assertRaises(AccessError):
test_partner.with_user(public_user).check_access_rule('read')
ns_res = self.env['res.partner'].with_user(public_user).sudo().name_search('Vlad', args=[('user_ids.email', 'ilike', 'vlad')])
self.assertEqual(set(i[0] for i in ns_res), set(test_user.partner_id.ids))
def test_partner_merge_wizard_dst_partner_id(self):
""" Check that dst_partner_id in merge wizard displays id along with partner name """
test_partner = self.env['res.partner'].create({'name': 'Radu the Handsome'})
expected_partner_name = '%s (%s)' % (test_partner.name, test_partner.id)
partner_merge_wizard = self.env['base.partner.merge.automatic.wizard'].with_context(
{'partner_show_db_id': True, 'default_dst_partner_id': test_partner}).new()
self.assertEqual(
partner_merge_wizard.dst_partner_id.display_name, expected_partner_name,
"'Destination Contact' name should contain db ID in brackets"
)
def test_partner_merge_null_company_property(self):
""" Check that partner with null company ir.property can be merged """
partners = self.env['res.partner'].create([
{'name': 'no barcode partner'},
{'name': 'partner1', 'barcode': 'partner1'},
{'name': 'partner2', 'barcode': 'partner2'},
])
with self.assertLogs(level='ERROR'):
partner_merge_wizard = self.env['base.partner.merge.automatic.wizard']._merge(
partners.ids,
partners[0],
)
self.assertFalse(partners[0].barcode)
partners = self.env['res.partner'].create([
{'name': 'partner1', 'barcode': 'partner1'},
{'name': 'partner2', 'barcode': 'partner2'},
])
self.env['ir.property'].search([
('res_id', 'in', ["res.partner,{}".format(p.id) for p in partners])
]).company_id = None
partner_merge_wizard = self.env['base.partner.merge.automatic.wizard']._merge(
partners.ids,
partners[0],
)
def test_read_group(self):
title_sir = self.env['res.partner.title'].create({'name': 'Sir...'})
title_lady = self.env['res.partner.title'].create({'name': 'Lady...'})
user_vals_list = [
{'name': 'Alice', 'login': 'alice', 'color': 1, 'function': 'Friend', 'date': '2015-03-28', 'title': title_lady.id},
{'name': 'Alice', 'login': 'alice2', 'color': 0, 'function': 'Friend', 'date': '2015-01-28', 'title': title_lady.id},
{'name': 'Bob', 'login': 'bob', 'color': 2, 'function': 'Friend', 'date': '2015-03-02', 'title': title_sir.id},
{'name': 'Eve', 'login': 'eve', 'color': 3, 'function': 'Eavesdropper', 'date': '2015-03-20', 'title': title_lady.id},
{'name': 'Nab', 'login': 'nab', 'color': -3, 'function': '5$ Wrench', 'date': '2014-09-10', 'title': title_sir.id},
{'name': 'Nab', 'login': 'nab-she', 'color': 6, 'function': '5$ Wrench', 'date': '2014-01-02', 'title': title_lady.id},
]
res_users = self.env['res.users']
users = res_users.create(user_vals_list)
domain = [('id', 'in', users.ids)]
# group on local char field without domain and without active_test (-> empty WHERE clause)
groups_data = res_users.with_context(active_test=False).read_group([], fields=['login'], groupby=['login'], orderby='login DESC')
self.assertGreater(len(groups_data), 6, "Incorrect number of results when grouping on a field")
# group on local char field with limit
groups_data = res_users.read_group(domain, fields=['login'], groupby=['login'], orderby='login DESC', limit=3, offset=3)
self.assertEqual(len(groups_data), 3, "Incorrect number of results when grouping on a field with limit")
self.assertEqual([g['login'] for g in groups_data], ['bob', 'alice2', 'alice'], 'Result mismatch')
# group on inherited char field, aggregate on int field (second groupby ignored on purpose)
groups_data = res_users.read_group(domain, fields=['name', 'color', 'function'], groupby=['function', 'login'])
self.assertEqual(len(groups_data), 3, "Incorrect number of results when grouping on a field")
self.assertEqual(['5$ Wrench', 'Eavesdropper', 'Friend'], [g['function'] for g in groups_data], 'incorrect read_group order')
for group_data in groups_data:
self.assertIn('color', group_data, "Aggregated data for the column 'color' is not present in read_group return values")
self.assertEqual(group_data['color'], 3, "Incorrect sum for aggregated data for the column 'color'")
# group on inherited char field, reverse order
groups_data = res_users.read_group(domain, fields=['name', 'color'], groupby='name', orderby='name DESC')
self.assertEqual([g['name'] for g in groups_data], ['Nab', 'Eve', 'Bob', 'Alice'], 'Incorrect ordering of the list')
# group on int field, default ordering
groups_data = res_users.read_group(domain, fields=['color'], groupby='color')
self.assertEqual([g['color'] for g in groups_data], [-3, 0, 1, 2, 3, 6], 'Incorrect ordering of the list')
# multi group, second level is int field, should still be summed in first level grouping
groups_data = res_users.read_group(domain, fields=['name', 'color'], groupby=['name', 'color'], orderby='name DESC')
self.assertEqual([g['name'] for g in groups_data], ['Nab', 'Eve', 'Bob', 'Alice'], 'Incorrect ordering of the list')
self.assertEqual([g['color'] for g in groups_data], [3, 3, 2, 1], 'Incorrect ordering of the list')
# group on inherited char field, multiple orders with directions
groups_data = res_users.read_group(domain, fields=['name', 'color'], groupby='name', orderby='color DESC, name')
self.assertEqual(len(groups_data), 4, "Incorrect number of results when grouping on a field")
self.assertEqual([g['name'] for g in groups_data], ['Eve', 'Nab', 'Bob', 'Alice'], 'Incorrect ordering of the list')
self.assertEqual([g['name_count'] for g in groups_data], [1, 2, 1, 2], 'Incorrect number of results')
# group on inherited date column (res_partner.date) -> Year-Month, default ordering
groups_data = res_users.read_group(domain, fields=['function', 'color', 'date'], groupby=['date'])
self.assertEqual(len(groups_data), 4, "Incorrect number of results when grouping on a field")
self.assertEqual([g['date'] for g in groups_data], ['January 2014', 'September 2014', 'January 2015', 'March 2015'], 'Incorrect ordering of the list')
self.assertEqual([g['date_count'] for g in groups_data], [1, 1, 1, 3], 'Incorrect number of results')
# group on inherited date column (res_partner.date) specifying the :year -> Year default ordering
groups_data = res_users.read_group(domain, fields=['function', 'color', 'date'], groupby=['date:year'])
self.assertEqual(len(groups_data), 2, "Incorrect number of results when grouping on a field")
self.assertEqual([g['date:year'] for g in groups_data], ['2014', '2015'], 'Incorrect ordering of the list')
self.assertEqual([g['date_count'] for g in groups_data], [2, 4], 'Incorrect number of results')
# group on inherited date column (res_partner.date) -> Year-Month, custom order
groups_data = res_users.read_group(domain, fields=['function', 'color', 'date'], groupby=['date'], orderby='date DESC')
self.assertEqual(len(groups_data), 4, "Incorrect number of results when grouping on a field")
self.assertEqual([g['date'] for g in groups_data], ['March 2015', 'January 2015', 'September 2014', 'January 2014'], 'Incorrect ordering of the list')
self.assertEqual([g['date_count'] for g in groups_data], [3, 1, 1, 1], 'Incorrect number of results')
# group on inherited many2one (res_partner.title), default order
groups_data = res_users.read_group(domain, fields=['function', 'color', 'title'], groupby=['title'])
self.assertEqual(len(groups_data), 2, "Incorrect number of results when grouping on a field")
# m2o is returned as a (id, label) pair
self.assertEqual([g['title'] for g in groups_data], [(title_lady.id, 'Lady...'), (title_sir.id, 'Sir...')], 'Incorrect ordering of the list')
self.assertEqual([g['title_count'] for g in groups_data], [4, 2], 'Incorrect number of results')
self.assertEqual([g['color'] for g in groups_data], [10, -1], 'Incorrect aggregation of int column')
# group on inherited many2one (res_partner.title), reversed natural order
groups_data = res_users.read_group(domain, fields=['function', 'color', 'title'], groupby=['title'], orderby="title desc")
self.assertEqual(len(groups_data), 2, "Incorrect number of results when grouping on a field")
# m2o is returned as a (id, label) pair
self.assertEqual([(title_sir.id, 'Sir...'), (title_lady.id, 'Lady...')], [g['title'] for g in groups_data], 'Incorrect ordering of the list')
self.assertEqual([g['title_count'] for g in groups_data], [2, 4], 'Incorrect number of results')
self.assertEqual([g['color'] for g in groups_data], [-1, 10], 'Incorrect aggregation of int column')
# group on inherited many2one (res_partner.title), multiple orders with m2o in second position
groups_data = res_users.read_group(domain, fields=['function', 'color', 'title'], groupby=['title'], orderby="color desc, title desc")
self.assertEqual(len(groups_data), 2, "Incorrect number of results when grouping on a field")
# m2o is returned as a (id, label) pair
self.assertEqual([g['title'] for g in groups_data], [(title_lady.id, 'Lady...'), (title_sir.id, 'Sir...')], 'Incorrect ordering of the result')
self.assertEqual([g['title_count'] for g in groups_data], [4, 2], 'Incorrect number of results')
self.assertEqual([g['color'] for g in groups_data], [10, -1], 'Incorrect aggregation of int column')
# group on inherited many2one (res_partner.title), ordered by other inherited field (color)
groups_data = res_users.read_group(domain, fields=['function', 'color', 'title'], groupby=['title'], orderby='color')
self.assertEqual(len(groups_data), 2, "Incorrect number of results when grouping on a field")
# m2o is returned as a (id, label) pair
self.assertEqual([g['title'] for g in groups_data], [(title_sir.id, 'Sir...'), (title_lady.id, 'Lady...')], 'Incorrect ordering of the list')
self.assertEqual([g['title_count'] for g in groups_data], [2, 4], 'Incorrect number of results')
self.assertEqual([g['color'] for g in groups_data], [-1, 10], 'Incorrect aggregation of int column')
def test_display_name_translation(self):
self.env['res.lang']._activate_lang('fr_FR')
self.env.ref('base.module_base')._update_translations(['fr_FR'])
res_partner = self.env['res.partner']
parent_contact = res_partner.create({
'name': 'Parent',
'type': 'contact',
})
child_contact = res_partner.create({
'type': 'other',
'parent_id': parent_contact.id,
})
self.assertEqual(child_contact.with_context(lang='en_US').display_name, 'Parent, Other Address')
self.assertEqual(child_contact.with_context(lang='fr_FR').display_name, 'Parent, Autre adresse')
@tagged('res_partner')
class TestPartnerAddressCompany(TransactionCase):
def test_address(self):
res_partner = self.env['res.partner']
ghoststep = res_partner.create({
'name': 'GhostStep',
'is_company': True,
'street': 'Main Street, 10',
'phone': '123456789',
'email': 'info@ghoststep.com',
'vat': 'BE0477472701',
'type': 'contact',
})
p1 = res_partner.browse(res_partner.name_create('Denis Bladesmith <denis.bladesmith@ghoststep.com>')[0])
self.assertEqual(p1.type, 'contact', 'Default type must be "contact"')
p1phone = '123456789#34'
p1.write({'phone': p1phone,
'parent_id': ghoststep.id})
self.assertEqual(p1.street, ghoststep.street, 'Address fields must be synced')
self.assertEqual(p1.phone, p1phone, 'Phone should be preserved after address sync')
self.assertEqual(p1.type, 'contact', 'Type should be preserved after address sync')
self.assertEqual(p1.email, 'denis.bladesmith@ghoststep.com', 'Email should be preserved after sync')
# turn off sync
p1street = 'Different street, 42'
p1.write({'street': p1street,
'type': 'invoice'})
self.assertEqual(p1.street, p1street, 'Address fields must not be synced after turning sync off')
self.assertNotEqual(ghoststep.street, p1street, 'Parent address must never be touched')
# turn on sync again
p1.write({'type': 'contact'})
self.assertEqual(p1.street, ghoststep.street, 'Address fields must be synced again')
self.assertEqual(p1.phone, p1phone, 'Phone should be preserved after address sync')
self.assertEqual(p1.type, 'contact', 'Type should be preserved after address sync')
self.assertEqual(p1.email, 'denis.bladesmith@ghoststep.com', 'Email should be preserved after sync')
# Modify parent, sync to children
ghoststreet = 'South Street, 25'
ghoststep.write({'street': ghoststreet})
self.assertEqual(p1.street, ghoststreet, 'Address fields must be synced automatically')
self.assertEqual(p1.phone, p1phone, 'Phone should not be synced')
self.assertEqual(p1.email, 'denis.bladesmith@ghoststep.com', 'Email should be preserved after sync')
p1street = 'My Street, 11'
p1.write({'street': p1street})
self.assertEqual(ghoststep.street, ghoststreet, 'Touching contact should never alter parent')
def test_address_first_contact_sync(self):
""" Test initial creation of company/contact pair where contact address gets copied to
company """
res_partner = self.env['res.partner']
ironshield = res_partner.browse(res_partner.name_create('IronShield')[0])
self.assertFalse(ironshield.is_company, 'Partners are not companies by default')
self.assertEqual(ironshield.type, 'contact', 'Default type must be "contact"')
ironshield.write({'type': 'contact'})
p1 = res_partner.create({
'name': 'Isen Hardearth',
'street': 'Strongarm Avenue, 12',
'parent_id': ironshield.id,
})
self.assertEqual(p1.type, 'contact', 'Default type must be "contact", not the copied parent type')
self.assertEqual(ironshield.street, p1.street, 'Address fields should be copied to company')
def test_address_get(self):
""" Test address_get address resolution mechanism: it should first go down through descendants,
stopping when encountering another is_copmany entity, then go up, stopping again at the first
is_company entity or the root ancestor and if nothing matches, it should use the provided partner
itself """
res_partner = self.env['res.partner']
elmtree = res_partner.browse(res_partner.name_create('Elmtree')[0])
branch1 = res_partner.create({'name': 'Branch 1',
'parent_id': elmtree.id,
'is_company': True})
leaf10 = res_partner.create({'name': 'Leaf 10',
'parent_id': branch1.id,
'type': 'invoice'})
branch11 = res_partner.create({'name': 'Branch 11',
'parent_id': branch1.id,
'type': 'other'})
leaf111 = res_partner.create({'name': 'Leaf 111',
'parent_id': branch11.id,
'type': 'delivery'})
branch11.write({'is_company': False}) # force is_company after creating 1rst child
branch2 = res_partner.create({'name': 'Branch 2',
'parent_id': elmtree.id,
'is_company': True})
leaf21 = res_partner.create({'name': 'Leaf 21',
'parent_id': branch2.id,
'type': 'delivery'})
leaf22 = res_partner.create({'name': 'Leaf 22',
'parent_id': branch2.id})
leaf23 = res_partner.create({'name': 'Leaf 23',
'parent_id': branch2.id,
'type': 'contact'})
# go up, stop at branch1
self.assertEqual(leaf111.address_get(['delivery', 'invoice', 'contact', 'other']),
{'delivery': leaf111.id,
'invoice': leaf10.id,
'contact': branch1.id,
'other': branch11.id}, 'Invalid address resolution')
self.assertEqual(branch11.address_get(['delivery', 'invoice', 'contact', 'other']),
{'delivery': leaf111.id,
'invoice': leaf10.id,
'contact': branch1.id,
'other': branch11.id}, 'Invalid address resolution')
# go down, stop at at all child companies
self.assertEqual(elmtree.address_get(['delivery', 'invoice', 'contact', 'other']),
{'delivery': elmtree.id,
'invoice': elmtree.id,
'contact': elmtree.id,
'other': elmtree.id}, 'Invalid address resolution')
# go down through children
self.assertEqual(branch1.address_get(['delivery', 'invoice', 'contact', 'other']),
{'delivery': leaf111.id,
'invoice': leaf10.id,
'contact': branch1.id,
'other': branch11.id}, 'Invalid address resolution')
self.assertEqual(branch2.address_get(['delivery', 'invoice', 'contact', 'other']),
{'delivery': leaf21.id,
'invoice': branch2.id,
'contact': branch2.id,
'other': branch2.id}, 'Invalid address resolution. Company is the first encountered contact, therefore default for unfound addresses.')
# go up then down through siblings
self.assertEqual(leaf21.address_get(['delivery', 'invoice', 'contact', 'other']),
{'delivery': leaf21.id,
'invoice': branch2.id,
'contact': branch2.id,
'other': branch2.id}, 'Invalid address resolution, should scan commercial entity ancestor and its descendants')
self.assertEqual(leaf22.address_get(['delivery', 'invoice', 'contact', 'other']),
{'delivery': leaf21.id,
'invoice': leaf22.id,
'contact': leaf22.id,
'other': leaf22.id}, 'Invalid address resolution, should scan commercial entity ancestor and its descendants')
self.assertEqual(leaf23.address_get(['delivery', 'invoice', 'contact', 'other']),
{'delivery': leaf21.id,
'invoice': leaf23.id,
'contact': leaf23.id,
'other': leaf23.id}, 'Invalid address resolution, `default` should only override if no partner with specific type exists')
# empty adr_pref means only 'contact'
self.assertEqual(elmtree.address_get([]),
{'contact': elmtree.id}, 'Invalid address resolution, no contact means commercial entity ancestor')
self.assertEqual(leaf111.address_get([]),
{'contact': branch1.id}, 'Invalid address resolution, no contact means finding contact in ancestors')
branch11.write({'type': 'contact'})
self.assertEqual(leaf111.address_get([]),
{'contact': branch11.id}, 'Invalid address resolution, branch11 should now be contact')
def test_commercial_partner_nullcompany(self):
""" The commercial partner is the first/nearest ancestor-or-self which
is a company or doesn't have a parent
"""
P = self.env['res.partner']
p0 = P.create({'name': '0', 'email': '0'})
self.assertEqual(p0.commercial_partner_id, p0, "partner without a parent is their own commercial partner")
p1 = P.create({'name': '1', 'email': '1', 'parent_id': p0.id})
self.assertEqual(p1.commercial_partner_id, p0, "partner's parent is their commercial partner")
p12 = P.create({'name': '12', 'email': '12', 'parent_id': p1.id})
self.assertEqual(p12.commercial_partner_id, p0, "partner's GP is their commercial partner")
p2 = P.create({'name': '2', 'email': '2', 'parent_id': p0.id, 'is_company': True})
self.assertEqual(p2.commercial_partner_id, p2, "partner flagged as company is their own commercial partner")
p21 = P.create({'name': '21', 'email': '21', 'parent_id': p2.id})
self.assertEqual(p21.commercial_partner_id, p2, "commercial partner is closest ancestor with themselves as commercial partner")
p3 = P.create({'name': '3', 'email': '3', 'is_company': True})
self.assertEqual(p3.commercial_partner_id, p3, "being both parent-less and company should be the same as either")
notcompanies = p0 | p1 | p12 | p21
self.env.cr.execute('update res_partner set is_company=null where id = any(%s)', [notcompanies.ids])
for parent in notcompanies:
p = P.create({
'name': parent.name + '_sub',
'email': parent.email + '_sub',
'parent_id': parent.id,
})
self.assertEqual(
p.commercial_partner_id,
parent.commercial_partner_id,
"check that is_company=null is properly handled when looking for ancestor"
)
def test_commercial_field_sync(self):
"""Check if commercial fields are synced properly: testing with VAT field"""
Partner = self.env['res.partner']
company_1 = Partner.create({'name': 'company 1', 'is_company': True, 'vat': 'BE0123456789'})
company_2 = Partner.create({'name': 'company 2', 'is_company': True, 'vat': 'BE9876543210'})
partner = Partner.create({'name': 'someone', 'is_company': False, 'parent_id': company_1.id})
Partner.flush_recordset()
self.assertEqual(partner.vat, company_1.vat, "VAT should be inherited from the company 1")
# create a delivery address for the partner
delivery = Partner.create({'name': 'somewhere', 'type': 'delivery', 'parent_id': partner.id})
self.assertEqual(delivery.commercial_partner_id.id, company_1.id, "Commercial partner should be recomputed")
self.assertEqual(delivery.vat, company_1.vat, "VAT should be inherited from the company 1")
# move the partner to another company
partner.write({'parent_id': company_2.id})
partner.flush_recordset()
self.assertEqual(partner.commercial_partner_id.id, company_2.id, "Commercial partner should be recomputed")
self.assertEqual(partner.vat, company_2.vat, "VAT should be inherited from the company 2")
self.assertEqual(delivery.commercial_partner_id.id, company_2.id, "Commercial partner should be recomputed on delivery")
self.assertEqual(delivery.vat, company_2.vat, "VAT should be inherited from the company 2 to delivery")
def test_commercial_sync(self):
res_partner = self.env['res.partner']
p0 = res_partner.create({'name': 'Sigurd Sunknife',
'email': 'ssunknife@gmail.com'})
sunhelm = res_partner.create({'name': 'Sunhelm',
'is_company': True,
'street': 'Rainbow Street, 13',
'phone': '1122334455',
'email': 'info@sunhelm.com',
'vat': 'BE0477472701',
'child_ids': [Command.link(p0.id),
Command.create({'name': 'Alrik Greenthorn',
'email': 'agr@sunhelm.com'})]})
p1 = res_partner.create({'name': 'Otto Blackwood',
'email': 'otto.blackwood@sunhelm.com',
'parent_id': sunhelm.id})
p11 = res_partner.create({'name': 'Gini Graywool',
'email': 'ggr@sunhelm.com',
'parent_id': p1.id})
p2 = res_partner.search([('email', '=', 'agr@sunhelm.com')], limit=1)
sunhelm.write({'child_ids': [Command.create({'name': 'Ulrik Greenthorn',
'email': 'ugr@sunhelm.com'})]})
p3 = res_partner.search([('email', '=', 'ugr@sunhelm.com')], limit=1)
for p in (p0, p1, p11, p2, p3):
self.assertEqual(p.commercial_partner_id, sunhelm, 'Incorrect commercial entity resolution')
self.assertEqual(p.vat, sunhelm.vat, 'Commercial fields must be automatically synced')
sunhelmvat = 'BE0123456749'
sunhelm.write({'vat': sunhelmvat})
for p in (p0, p1, p11, p2, p3):
self.assertEqual(p.vat, sunhelmvat, 'Commercial fields must be automatically and recursively synced')
p1vat = 'BE0987654394'
p1.write({'vat': p1vat})
for p in (sunhelm, p0, p11, p2, p3):
self.assertEqual(p.vat, sunhelmvat, 'Sync to children should only work downstream and on commercial entities')
# promote p1 to commercial entity
p1.write({'parent_id': sunhelm.id,
'is_company': True,
'name': 'Sunhelm Subsidiary'})
self.assertEqual(p1.vat, p1vat, 'Setting is_company should stop auto-sync of commercial fields')
self.assertEqual(p1.commercial_partner_id, p1, 'Incorrect commercial entity resolution after setting is_company')
# writing on parent should not touch child commercial entities
sunhelmvat2 = 'BE0112233453'
sunhelm.write({'vat': sunhelmvat2})
self.assertEqual(p1.vat, p1vat, 'Setting is_company should stop auto-sync of commercial fields')
self.assertEqual(p0.vat, sunhelmvat2, 'Commercial fields must be automatically synced')
def test_company_dependent_commercial_sync(self):
ResPartner = self.env['res.partner']
company_1, company_2 = self.env['res.company'].create([
{'name': 'company_1'},
{'name': 'company_2'},
])
test_partner_company = ResPartner.create({
'name': 'This company',
'barcode': 'Main Company',
'is_company': True,
})
test_partner_company.with_company(company_1).barcode = 'Company 1'
test_partner_company.with_company(company_2).barcode = 'Company 2'
commercial_fields = ResPartner._commercial_fields()
with patch.object(
ResPartner.__class__,
'_commercial_fields',
lambda self: commercial_fields + ['barcode'],
), patch.object(ResPartner.__class__, '_validate_fields'): # skip _check_barcode_unicity
child_address = ResPartner.create({
'name': 'Contact',
'parent_id': test_partner_company.id,
})
self.assertEqual(child_address.barcode, 'Main Company')
self.assertEqual(child_address.with_company(company_1).barcode, 'Company 1')
self.assertEqual(child_address.with_company(company_2).barcode, 'Company 2')
child_address.parent_id = False
# Reassigning a parent (or a new one) shouldn't fail
child_address.parent_id = test_partner_company.id
def test_company_change_propagation(self):
""" Check propagation of company_id across children """
User = self.env['res.users']
Partner = self.env['res.partner']
Company = self.env['res.company']
company_1 = Company.create({'name': 'company_1'})
company_2 = Company.create({'name': 'company_2'})
test_partner_company = Partner.create({'name': 'This company'})
test_user = User.create({'name': 'This user', 'login': 'thisu', 'email': 'this.user@example.com', 'company_id': company_1.id, 'company_ids': [company_1.id]})
test_user.partner_id.write({'parent_id': test_partner_company.id})
test_partner_company.write({'company_id': company_1.id})
self.assertEqual(test_user.partner_id.company_id.id, company_1.id, "The new company_id of the partner company should be propagated to its children")
test_partner_company.write({'company_id': False})
self.assertFalse(test_user.partner_id.company_id.id, "If the company_id is deleted from the partner company, it should be propagated to its children")
with self.assertRaises(UserError, msg="You should not be able to update the company_id of the partner company if the linked user of a child partner is not an allowed to be assigned to that company"), self.cr.savepoint():
test_partner_company.write({'company_id': company_2.id})
def test_display_address_missing_key(self):
""" Check _display_address when some keys are missing. As a defaultdict is used, missing keys should be
filled with empty strings. """
country = self.env["res.country"].create({"name": "TestCountry", "address_format": "%(city)s %(zip)s", "code": "ZV"})
partner = self.env["res.partner"].create({
"name": "TestPartner",
"country_id": country.id,
"city": "TestCity",
"zip": "12345",
})
before = partner._display_address()
# Manually update the country address_format because placeholders are checked by create
self.env.cr.execute(
"UPDATE res_country SET address_format ='%%(city)s %%(zip)s %%(nothing)s' WHERE id=%s",
[country.id]
)
self.env["res.country"].invalidate_model()
self.assertEqual(before, partner._display_address().strip())
def test_display_name(self):
""" Check display_name on partner, especially with different context
Check display_name correctly return name with context. """
test_partner_jetha = self.env['res.partner'].create({'name': 'Jethala', 'street': 'Powder gali', 'street2': 'Gokuldham Society'})
test_partner_bhide = self.env['res.partner'].create({'name': 'Atmaram Bhide'})
res_jetha = test_partner_jetha.with_context(show_address=1).display_name
self.assertEqual(res_jetha, "Jethala\nPowder gali\nGokuldham Society", "name should contain comma separated name and address")
res_bhide = test_partner_bhide.with_context(show_address=1).display_name
self.assertEqual(res_bhide, "Atmaram Bhide", "name should contain only name if address is not available, without extra commas")
res_jetha = test_partner_jetha.with_context(show_address=1, address_inline=1).display_name
self.assertEqual(res_jetha, "Jethala, Powder gali, Gokuldham Society", "name should contain comma separated name and address")
res_bhide = test_partner_bhide.with_context(show_address=1, address_inline=1).display_name
self.assertEqual(res_bhide, "Atmaram Bhide", "name should contain only name if address is not available, without extra commas")
def test_accessibility_of_company_partner_from_branch(self):
""" Check accessibility of company partner from branch. """
company = self.env['res.company'].create({'name': 'company'})
branch = self.env['res.company'].create({
'name': 'branch',
'parent_id': company.id
})
partner = self.env['res.partner'].create({
'name': 'partner',
'company_id': company.id
})
user = self.env['res.users'].create({
'name': 'user',
'login': 'user',
'company_id': branch.id,
'company_ids': [branch.id]
})
record = self.env['res.partner'].with_user(user).search([('id', '=', partner.id)])
self.assertEqual(record.id, partner.id)
@tagged('res_partner', 'post_install', '-at_install')
class TestPartnerForm(TransactionCase):
# those tests are made post-install because they need module 'web' for the
# form view to work properly
def test_lang_computation_form_view(self):
""" Check computation of lang: coming from installed languages, forced
default value and propagation from parent."""
@ -272,25 +896,6 @@ class TestPartner(TransactionCase):
self.assertEqual(partner.child_ids.filtered(lambda p: p.name == "First Child").lang, 'de_DE')
self.assertEqual(partner.child_ids.filtered(lambda p: p.name == "Second Child").lang, 'fr_FR')
def test_partner_merge_wizard_dst_partner_id(self):
""" Check that dst_partner_id in merge wizard displays id along with partner name """
test_partner = self.env['res.partner'].create({'name': 'Radu the Handsome'})
expected_partner_name = '%s (%s)' % (test_partner.name, test_partner.id)
partner_merge_wizard = self.env['base.partner.merge.automatic.wizard'].with_context(
{'partner_show_db_id': True, 'default_dst_partner_id': test_partner}).new()
self.assertEqual(
partner_merge_wizard.dst_partner_id.name_get(),
[(test_partner.id, expected_partner_name)],
"'Destination Contact' name should contain db ID in brackets"
)
def test_partner_is_public(self):
""" Check that base.partner_user is a public partner."""
self.assertFalse(self.env.ref('base.public_user').active)
self.assertFalse(self.env.ref('base.public_partner').active)
self.assertTrue(self.env.ref('base.public_partner').is_public)
def test_onchange_parent_sync_user(self):
company_1 = self.env['res.company'].create({'name': 'company_1'})
test_user = self.env['res.users'].create({
@ -311,21 +916,51 @@ class TestPartner(TransactionCase):
partner_form.name = 'Philip'
self.assertEqual(partner_form.user_id, test_parent_partner.user_id)
def test_display_address_missing_key(self):
""" Check _display_address when some keys are missing. As a defaultdict is used, missing keys should be
filled with empty strings. """
country = self.env["res.country"].create({"name": "TestCountry", "address_format": "%(city)s %(zip)s"})
partner = self.env["res.partner"].create({
"name": "TestPartner",
"country_id": country.id,
"city": "TestCity",
"zip": "12345",
})
before = partner._display_address()
# Manually update the country address_format because placeholders are checked by create
self.env.cr.execute(
"UPDATE res_country SET address_format ='%%(city)s %%(zip)s %%(nothing)s' WHERE id=%s",
[country.id]
)
self.env["res.country"].invalidate_model()
self.assertEqual(before, partner._display_address().strip())
@tagged('res_partner')
class TestPartnerRecursion(TransactionCase):
def setUp(self):
super(TestPartnerRecursion, self).setUp()
res_partner = self.env['res.partner']
self.p1 = res_partner.browse(res_partner.name_create('Elmtree')[0])
self.p2 = res_partner.create({'name': 'Elmtree Child 1', 'parent_id': self.p1.id})
self.p3 = res_partner.create({'name': 'Elmtree Grand-Child 1.1', 'parent_id': self.p2.id})
def test_100_res_partner_recursion(self):
self.assertTrue(self.p3._check_recursion())
self.assertTrue((self.p1 + self.p2 + self.p3)._check_recursion())
# split 101, 102, 103 tests to force SQL rollback between them
def test_101_res_partner_recursion(self):
with self.assertRaises(ValidationError):
self.p1.write({'parent_id': self.p3.id})
def test_102_res_partner_recursion(self):
with self.assertRaises(ValidationError):
self.p2.write({'parent_id': self.p3.id})
def test_103_res_partner_recursion(self):
with self.assertRaises(ValidationError):
self.p3.write({'parent_id': self.p3.id})
def test_104_res_partner_recursion_indirect_cycle(self):
""" Indirect hacky write to create cycle in children """
p3b = self.p1.create({'name': 'Elmtree Grand-Child 1.2', 'parent_id': self.p2.id})
with self.assertRaises(ValidationError):
self.p2.write({'child_ids': [Command.update(self.p3.id, {'parent_id': p3b.id}),
Command.update(p3b.id, {'parent_id': self.p3.id})]})
def test_110_res_partner_recursion_multi_update(self):
""" multi-write on several partners in same hierarchy must not trigger a false cycle detection """
ps = self.p1 + self.p2 + self.p3
self.assertTrue(ps.write({'phone': '123456'}))
def test_111_res_partner_recursion_infinite_loop(self):
""" The recursion check must not loop forever """
self.p2.parent_id = False
self.p3.parent_id = False
self.p1.parent_id = self.p2
with self.assertRaises(ValidationError):
(self.p3|self.p2).write({'parent_id': self.p1.id})

View file

@ -31,6 +31,7 @@ class TestResPartnerBank(SavepointCaseWithUserDemo):
# sanitaze the acc_number
sanitized_acc_number = 'BE001251882303'
self.assertEqual(partner_bank.sanitized_acc_number, sanitized_acc_number)
vals = partner_bank_model.search(
[('acc_number', '=', sanitized_acc_number)])
self.assertEqual(1, len(vals))
@ -49,3 +50,7 @@ class TestResPartnerBank(SavepointCaseWithUserDemo):
vals = partner_bank_model.search(
[('acc_number', '=', acc_number.lower())])
self.assertEqual(1, len(vals))
# updating the sanitized value will also update the acc_number
partner_bank.write({'sanitized_acc_number': 'BE001251882303WRONG'})
self.assertEqual(partner_bank.acc_number, partner_bank.sanitized_acc_number)

View file

@ -3,9 +3,12 @@
from types import SimpleNamespace
from unittest.mock import patch
from odoo import SUPERUSER_ID
from odoo.addons.base.models.res_users import is_selection_groups, get_selection_groups, name_selection_groups
from odoo.exceptions import UserError, ValidationError
from odoo.tests.common import TransactionCase, Form, tagged, new_test_user
from odoo.service import security
from odoo.tests.common import Form, TransactionCase, new_test_user, tagged, HttpCase, users
from odoo.tools import mute_logger
@ -133,7 +136,7 @@ class TestUsers(TransactionCase):
with self.assertRaises(UserError, msg='Internal users should not be able to deactivate their account'):
user_internal._deactivate_portal_user()
@mute_logger('odoo.sql_db')
@mute_logger('odoo.sql_db', 'odoo.addons.base.models.res_users_deletion')
def test_deactivate_portal_users_archive_and_remove(self):
"""Test that if the account can not be removed, it's archived instead
and sensitive information are removed.
@ -223,20 +226,19 @@ class TestUsers(TransactionCase):
request_patch.start()
self.assertEqual(user.context_get()['lang'], 'fr_FR')
self.env.registry.clear_caches()
self.env.registry.clear_cache()
user.lang = False
self.assertEqual(user.context_get()['lang'], 'es_ES')
self.env.registry.clear_caches()
self.env.registry.clear_cache()
request_patch.stop()
self.assertEqual(user.context_get()['lang'], 'de_DE')
self.env.registry.clear_caches()
self.env.registry.clear_cache()
company.lang = False
self.assertEqual(user.context_get()['lang'], 'en_US')
@tagged('post_install', '-at_install')
class TestUsers2(TransactionCase):
@ -307,8 +309,8 @@ class TestUsers2(TransactionCase):
normalized_values = user._remove_reified_groups({fname: group2.id})
self.assertEqual(normalized_values['groups_id'], [(4, group2.id)])
def test_read_group_with_reified_field(self):
""" Check that read_group gets rid of reified fields"""
def test_read_list_with_reified_field(self):
""" Check that read_group and search_read get rid of reified fields"""
User = self.env['res.users']
fnames = ['name', 'email', 'login']
@ -322,7 +324,13 @@ class TestUsers2(TransactionCase):
# check that the reified field name has no effect in fields
res_with_reified = User.read_group([], fnames + [reified_fname], ['company_id'])
res_without_reified = User.read_group([], fnames, ['company_id'])
self.assertEqual(res_with_reified, res_without_reified, "Reified fields should be ignored")
self.assertEqual(res_with_reified, res_without_reified, "Reified fields should be ignored in read_group")
# check that the reified fields are not considered invalid in search_read
# and are ignored
res_with_reified = User.search_read([], fnames + [reified_fname])
res_without_reified = User.search_read([], fnames)
self.assertEqual(res_with_reified, res_without_reified, "Reified fields should be ignored in search_read")
# Verify that the read_group is raising an error if reified field is used as groupby
with self.assertRaises(ValueError):
@ -339,22 +347,30 @@ class TestUsers2(TransactionCase):
user_groups_ids = [str(group_id) for group_id in sorted(user_groups.ids)]
group_field_name = f"sel_groups_{'_'.join(user_groups_ids)}"
# <group col="4" attrs="{'invisible': [('sel_groups_1_9_10', '!=', 1)]}" groups="base.group_no_one" class="o_label_nowrap">
# <group col="4" invisible="sel_groups_1_9_10 != 1" groups="base.group_no_one" class="o_label_nowrap">
with self.debug_mode():
user_form = Form(self.env['res.users'], view='base.view_users_form')
user_form.name = "Test"
user_form.login = "Test"
self.assertFalse(user_form.share)
setattr(user_form, group_field_name, group_portal.id)
user_form[group_field_name] = group_portal.id
self.assertTrue(user_form.share, 'The groups_id onchange should have been triggered')
setattr(user_form, group_field_name, group_user.id)
user_form[group_field_name] = group_user.id
self.assertFalse(user_form.share, 'The groups_id onchange should have been triggered')
setattr(user_form, group_field_name, group_public.id)
user_form[group_field_name] = group_public.id
self.assertTrue(user_form.share, 'The groups_id onchange should have been triggered')
def test_update_user_groups_view(self):
"""Test that the user groups view can still be built if all user type groups are share"""
self.env['res.groups'].search([
("category_id", "=", self.env.ref("base.module_category_user_type").id)
]).write({'share': True})
self.env['res.groups']._update_user_groups_view()
@tagged('post_install', '-at_install', 'res_groups')
class TestUsersGroupWarning(TransactionCase):
@ -463,21 +479,14 @@ class TestUsersGroupWarning(TransactionCase):
warning should be there since 'Sales: Administrator' is required when
user is having 'Field Service: Administrator'. When user reverts the
changes, warning should disappear. """
# 97 requests if only base is installed
# 412 runbot community
# 549 runbot enterprise
with self.assertQueryCount(__system__=549), \
Form(self.test_group_user.with_context(show_user_group_warning=True), view='base.view_users_form') as UserForm:
UserForm._values[self.sales_categ_field] = False
UserForm._perform_onchange([self.sales_categ_field])
with Form(self.test_group_user.with_context(show_user_group_warning=True), view='base.view_users_form') as UserForm:
UserForm[self.sales_categ_field] = False
self.assertEqual(
UserForm.user_group_warning,
'Since Test Group User is a/an "Field Service: Administrator", they will at least obtain the right "Sales: Administrator"'
)
UserForm._values[self.sales_categ_field] = self.group_sales_administrator.id
UserForm._perform_onchange([self.sales_categ_field])
UserForm[self.sales_categ_field] = self.group_sales_administrator.id
self.assertFalse(UserForm.user_group_warning)
def test_user_group_inheritance_warning(self):
@ -485,21 +494,14 @@ class TestUsersGroupWarning(TransactionCase):
should be there since 'Sales: Administrator' is required when user is
having 'Field Service: Administrator'. When user reverts the changes,
warning should disappear. """
# 97 requests if only base is installed
# 412 runbot community
# 549 runbot enterprise
with self.assertQueryCount(__system__=549), \
Form(self.test_group_user.with_context(show_user_group_warning=True), view='base.view_users_form') as UserForm:
UserForm._values[self.sales_categ_field] = self.group_sales_user.id
UserForm._perform_onchange([self.sales_categ_field])
with Form(self.test_group_user.with_context(show_user_group_warning=True), view='base.view_users_form') as UserForm:
UserForm[self.sales_categ_field] = self.group_sales_user.id
self.assertEqual(
UserForm.user_group_warning,
'Since Test Group User is a/an "Field Service: Administrator", they will at least obtain the right "Sales: Administrator"'
)
UserForm._values[self.sales_categ_field] = self.group_sales_administrator.id
UserForm._perform_onchange([self.sales_categ_field])
UserForm[self.sales_categ_field] = self.group_sales_administrator.id
self.assertFalse(UserForm.user_group_warning)
def test_user_group_inheritance_warning_multi(self):
@ -509,23 +511,15 @@ class TestUsersGroupWarning(TransactionCase):
are required when user is havning 'Field Service: Administrator'.
When user reverts the changes For 'Sales: Administrator', warning
should disappear for Sales Access."""
# 101 requests if only base is installed
# 416 runbot community
# 553 runbot enterprise
with self.assertQueryCount(__system__=553), \
Form(self.test_group_user.with_context(show_user_group_warning=True), view='base.view_users_form') as UserForm:
UserForm._values[self.sales_categ_field] = self.group_sales_user.id
UserForm._values[self.project_categ_field] = self.group_project_user.id
UserForm._perform_onchange([self.sales_categ_field])
with Form(self.test_group_user.with_context(show_user_group_warning=True), view='base.view_users_form') as UserForm:
UserForm[self.sales_categ_field] = self.group_sales_user.id
UserForm[self.project_categ_field] = self.group_project_user.id
self.assertTrue(
UserForm.user_group_warning,
'Since Test Group User is a/an "Field Service: Administrator", they will at least obtain the right "Sales: Administrator", Project: Administrator"',
)
UserForm._values[self.sales_categ_field] = self.group_sales_administrator.id
UserForm._perform_onchange([self.sales_categ_field])
UserForm[self.sales_categ_field] = self.group_sales_administrator.id
self.assertEqual(
UserForm.user_group_warning,
'Since Test Group User is a/an "Field Service: Administrator", they will at least obtain the right "Project: Administrator"'
@ -537,33 +531,65 @@ class TestUsersGroupWarning(TransactionCase):
'Timesheets: User: all timesheets' is at least required when user is
having 'Project: Administrator'. When user reverts the changes For
'Timesheets: User: all timesheets', warning should disappear."""
# 98 requests if only base is installed
# 413 runbot community
# 550 runbot enterprise
with self.assertQueryCount(__system__=550), \
Form(self.test_group_user.with_context(show_user_group_warning=True), view='base.view_users_form') as UserForm:
UserForm._values[self.timesheets_categ_field] = self.group_timesheets_user_own_timesheet.id
UserForm._perform_onchange([self.timesheets_categ_field])
with Form(self.test_group_user.with_context(show_user_group_warning=True), view='base.view_users_form') as UserForm:
UserForm[self.timesheets_categ_field] = self.group_timesheets_user_own_timesheet.id
self.assertEqual(
UserForm.user_group_warning,
'Since Test Group User is a/an "Project: Administrator", they will at least obtain the right "Timesheets: User: all timesheets"'
)
UserForm._values[self.timesheets_categ_field] = self.group_timesheets_user_all_timesheet.id
UserForm._perform_onchange([self.timesheets_categ_field])
UserForm[self.timesheets_categ_field] = self.group_timesheets_user_all_timesheet.id
self.assertFalse(UserForm.user_group_warning)
def test_user_group_parent_inheritance_no_warning(self):
""" User changes 'Field Service: User' from 'Field Service: Administrator'.
The warning should not be there since 'Field Service: User' is not affected
by any other groups."""
# 83 requests if only base is installed
# 397 runbot community
# 534 runbot enterprise
with self.assertQueryCount(__system__=534), \
Form(self.test_group_user.with_context(show_user_group_warning=True), view='base.view_users_form') as UserForm:
UserForm._values[self.field_service_categ_field] = self.group_field_service_user.id
UserForm._perform_onchange([self.field_service_categ_field])
with Form(self.test_group_user.with_context(show_user_group_warning=True), view='base.view_users_form') as UserForm:
UserForm[self.field_service_categ_field] = self.group_field_service_user.id
self.assertFalse(UserForm.user_group_warning)
class TestUsersTweaks(TransactionCase):
def test_superuser(self):
""" The superuser is inactive and must remain as such. """
user = self.env['res.users'].browse(SUPERUSER_ID)
self.assertFalse(user.active)
with self.assertRaises(UserError):
user.write({'active': True})
@tagged('post_install', '-at_install')
class TestUsersIdentitycheck(HttpCase):
@users('admin')
def test_revoke_all_devices(self):
"""
Test to check the revoke all devices by changing the current password as a new password
"""
# Change the password to 8 characters for security reasons
self.env.user.password = "admin@odoo"
# Create a session
session = self.authenticate('admin', 'admin@odoo')
# Test the session is valid
self.assertTrue(security.check_session(session, self.env))
# Valid session -> not redirected from /web to /web/login
self.assertTrue(self.url_open('/web').url.endswith('/web'))
# The user clicks the button logout from all devices from his profile
action = self.env.user.action_revoke_all_devices()
# The form of the wizard opens with a new record
form = Form(self.env[action['res_model']].create({}), action['view_id'])
# The user fills his password
form.password = 'admin@odoo'
# The user clicks the button "Log out from all devices", which triggers a save then a call to the button method
user_identity_check = form.save()
user_identity_check.revoke_all_devices()
# Test the session is no longer valid
self.assertFalse(security.check_session(session, self.env))
# Invalid session -> redirected from /web to /web/login
self.assertTrue(self.url_open('/web').url.endswith('/web/login'))
# In addition, the wizard must have been deleted
self.assertFalse(user_identity_check.exists())

View file

@ -60,6 +60,80 @@ class test_search(TransactionCase):
id_desc_active_desc = Partner.search([('name', 'like', 'test_search_order%'), '|', ('active', '=', True), ('active', '=', False)], order="id desc, active desc")
self.assertEqual([e, ab, b, a, d, c], list(id_desc_active_desc), "Search with 'ID DESC, ACTIVE DESC' order failed.")
a.ref = "ref1"
c.ref = "ref2"
ids = (a | b | c).ids
for order, result in [
('ref', a | c | b),
('ref desc', b | c | a),
('ref asc nulls first', b | a | c),
('ref asc nulls last', a | c | b),
('ref desc nulls first', b | c | a),
('ref desc nulls last', c | a | b)
]:
with self.subTest(order):
self.assertEqual(
Partner.search([('id', 'in', ids)], order=order).mapped('name'),
result.mapped('name'))
# sorting by an m2o should alias to the natural order of the m2o
self.patch_order('res.country', 'phone_code')
a.country_id, c.country_id = self.env['res.country'].create([{
'name': "Country 1",
'code': 'C1',
'phone_code': '01',
}, {
'name': 'Country 2',
'code': 'C2',
'phone_code': '02'
}])
for order, result in [
('country_id', a | c | b),
('country_id desc', b | c | a),
('country_id asc nulls first', b | a | c),
('country_id asc nulls last', a | c | b),
('country_id desc nulls first', b | c | a),
('country_id desc nulls last', c | a | b)
]:
with self.subTest(order):
self.assertEqual(
Partner.search([('id', 'in', ids)], order=order).mapped('name'),
result.mapped('name'))
# NULLS applies to the m2o itself, not its sub-fields, so a null `phone_code`
# will sort normally (larger than non-null codes)
b.country_id = self.env['res.country'].create({'name': "Country X", 'code': 'C3'})
for order, result in [
('country_id', a | c | b),
('country_id desc', b | c | a),
('country_id asc nulls first', a | c | b),
('country_id asc nulls last', a | c | b),
('country_id desc nulls first', b | c | a),
('country_id desc nulls last', b | c | a)
]:
with self.subTest(order):
self.assertEqual(
Partner.search([('id', 'in', ids)], order=order).mapped('name'),
result.mapped('name'))
# a field DESC should reverse the nested behaviour (and thus the inner
# NULLS clauses), but the outer NULLS clause still has no effect
self.patch_order('res.country', 'phone_code NULLS FIRST')
for order, result in [
('country_id', b | a | c),
('country_id desc', c | a | b),
('country_id asc nulls first', b | a | c),
('country_id asc nulls last', b | a | c),
('country_id desc nulls first', c | a | b),
('country_id desc nulls last', c | a | b)
]:
with self.subTest(order):
self.assertEqual(
Partner.search([('id', 'in', ids)], order=order).mapped('name'),
result.mapped('name'))
def test_10_inherits_m2order(self):
Users = self.env['res.users']
@ -144,9 +218,16 @@ class test_search(TransactionCase):
kw = dict(groups_id=[Command.set([self.ref('base.group_system'),
self.ref('base.group_partner_manager')])])
u1 = Users.create(dict(name='Q', login='m', **kw)).id
# When creating with the superuser, the ordering by 'create_uid' will
# compare user logins with the superuser's login "__system__", which
# may give different results, because "_" may come before or after
# letters, depending on the database's locale. In order to avoid this
# issue, use a user with a login that doesn't include "_".
u0 = Users.create(dict(name='A system', login='a', **kw)).id
u1 = Users.with_user(u0).create(dict(name='Q', login='m', **kw)).id
u2 = Users.with_user(u1).create(dict(name='B', login='f', **kw)).id
u3 = Users.create(dict(name='C', login='c', **kw)).id
u3 = Users.with_user(u0).create(dict(name='C', login='c', **kw)).id
u4 = Users.with_user(u2).create(dict(name='D', login='z', **kw)).id
expected_ids = [u2, u4, u3, u1]
@ -166,7 +247,7 @@ class test_search(TransactionCase):
'ttype': 'boolean',
})
self.assertEqual('x_active', model_country._active_name)
country_ussr = model_country.create({'name': 'USSR', 'x_active': False})
country_ussr = model_country.create({'name': 'USSR', 'x_active': False, 'code': 'ZV'})
ussr_search = model_country.search([('name', '=', 'USSR')])
self.assertFalse(ussr_search)
ussr_search = model_country.with_context(active_test=False).search([('name', '=', 'USSR')])
@ -203,7 +284,10 @@ class test_search(TransactionCase):
{'name': 'runbot'},
])
self.assertEqual(len(partners) + count_partner_before, Partner.search_count([]))
self.assertEqual(len(partners) + count_partner_before, Partner.search([], count=True))
self.assertEqual(3, Partner.search_count([], limit=3))
self.assertEqual(3, Partner.search([], count=True, limit=3))
def test_22_large_domain(self):
""" Ensure search and its unerlying SQL mechanism is able to handle large domains"""
N = 9500
domain = ['|'] * (N - 1) + [('login', '=', 'admin')] * N
self.env['res.users'].search(domain)

View file

@ -137,6 +137,7 @@ class TestRunnerLoggingCommon(TransactionCase):
message = re.sub(r'line \d+', 'line $line', message)
message = re.sub(r'py:\d+', 'py:$line', message)
message = re.sub(r'decorator-gen-\d+', 'decorator-gen-xxx', message)
message = re.sub(r'^\s*~*\^+~*\s*\n', '', message, flags=re.MULTILINE)
message = message.replace(f'"{root_path}', '"/root_path/odoo')
message = message.replace(f'"{python_path}', '"/usr/lib/python')
message = message.replace('\\', '/')

View file

@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from hashlib import sha256
from unittest.mock import patch
import logging
import time
@ -10,7 +11,7 @@ import io
from odoo.exceptions import UserError
from odoo.tools import sql
from odoo.tools.translate import quote, unquote, xml_translate, html_translate, TranslationImporter
from odoo.tools.translate import quote, unquote, xml_translate, html_translate, TranslationImporter, TranslationModuleReader
from odoo.tests.common import TransactionCase, BaseCase, new_test_user, tagged
_stats_logger = logging.getLogger('odoo.tests.stats')
@ -351,6 +352,15 @@ class TestLanguageInstall(TransactionCase):
self.assertEqual(loaded[0][2], True)
@tagged('post_install', '-at_install')
class TestTranslationExport(TransactionCase):
def test_export_translatable_resources(self):
"""Read files of installed modules and export translatable terms"""
with self.assertNoLogs('odoo.tools.translate', "ERROR"):
TranslationModuleReader(self.env.cr)
class TestTranslation(TransactionCase):
@classmethod
def setUpClass(cls):
@ -374,7 +384,7 @@ class TestTranslation(TransactionCase):
def test_101_create_translated_record(self):
category = self.customers.with_context({})
self.assertEqual(category.name, 'Customers', "Error in basic name_get")
self.assertEqual(category.name, 'Customers', "Error in basic name")
category_fr = category.with_context({'lang': 'fr_FR'})
self.assertEqual(category_fr.name, 'Clients', "Translation not found")
@ -488,6 +498,37 @@ class TestTranslation(TransactionCase):
category_in = CategoryEs.search([('name', 'in', ['Customers'])])
self.assertIn(self.customers, category_in, "Search with 'in' should use the English name if the current language translation is not available")
def test_111_prefetch_langs(self):
category_en = self.customers.with_context(lang='en_US')
self.env.ref('base.lang_nl').active = True
category_nl = category_en.with_context(lang='nl_NL')
category_nl.name = 'Klanten'
self.assertTrue(self.env.ref('base.lang_fr').active)
category_fr = category_en.with_context(lang='fr_FR')
self.assertFalse(self.env.ref('base.lang_zh_CN').active)
category_zh = category_en.with_context(lang='zh_CN')
self.env['res.partner'].with_context(active_test=False).search([]).write({'lang': 'fr_FR'})
self.env.ref('base.lang_en').active = False
category_fr.with_context(prefetch_langs=True).name
category_nl.name
category_en.name
category_zh.name
category_fr.invalidate_recordset()
with self.assertQueryCount(1):
self.assertEqual(category_fr.with_context(prefetch_langs=True).name, 'Clients')
with self.assertQueryCount(0):
self.assertEqual(category_nl.name, 'Klanten')
self.assertEqual(category_en.name, 'Customers')
self.assertEqual(category_zh.name, 'Customers')
# TODO Currently, the unique constraint doesn't work for translatable field
# def test_111_unique_en(self):
# Country = self.env['res.country']
@ -1138,6 +1179,30 @@ class TestXMLTranslation(TransactionCase):
self.assertEqual(view.with_env(env_fr).arch_db, archf % terms_fr)
self.assertEqual(view.with_env(env_nl).arch_db, archf % terms_nl)
def test_sync_xml_close_terms(self):
""" Check translations of 'arch' after xml tags changes in source terms. """
archf = '<form string="X">%s<div>%s</div>%s</form>'
terms_en = ('RandomRandom1', 'RandomRandom2', 'RandomRandom3')
terms_fr = ('RandomRandom1', 'AléatoireAléatoire2', 'AléatoireAléatoire3')
view = self.create_view(archf, terms_en, en_US=terms_en, fr_FR=terms_fr)
env_nolang = self.env(context={})
env_en = self.env(context={'lang': 'en_US'})
env_fr = self.env(context={'lang': 'fr_FR'})
self.assertEqual(view.with_env(env_nolang).arch_db, archf % terms_en)
self.assertEqual(view.with_env(env_en).arch_db, archf % terms_en)
self.assertEqual(view.with_env(env_fr).arch_db, archf % terms_fr)
# modify source term in view
terms_en = ('RandomRandom1', 'SomethingElse', 'RandomRandom3')
view.with_env(env_en).write({'arch_db': archf % terms_en})
# check whether close terms have correct translations
self.assertEqual(view.with_env(env_nolang).arch_db, archf % terms_en)
self.assertEqual(view.with_env(env_en).arch_db, archf % terms_en)
self.assertEqual(view.with_env(env_fr).arch_db, archf % ('RandomRandom1', 'SomethingElse', 'AléatoireAléatoire3'))
def test_sync_xml_upgrade(self):
# text term and xml term with the same text content, text term is removed, xml term is changed
archf = '<form>%s<div>%s</div></form>'
@ -1209,6 +1274,7 @@ class TestXMLTranslation(TransactionCase):
self.assertEqual(view.arch_db, archf % terms_en)
self.assertEqual(view.with_context(lang='fr_FR').arch_db, archf % terms_fr)
def test_cache_consistency(self):
view = self.env["ir.ui.view"].create({
"name": "test_translate_xml_cache_invalidation",
@ -1263,6 +1329,117 @@ class TestXMLTranslation(TransactionCase):
self.assertEqual(view.with_context(lang='en_US').arch_db, '<form string="X">Bread and cheese<div>Fork3</div></form>')
self.assertEqual(view.with_context(lang='es_ES').arch_db, '<form string="X">Bread and cheese<div>Tenedor3</div></form>')
def test_delay_translations(self):
archf = '<form string="%s"><div>%s</div><div>%s</div></form>'
terms_en = ('Knife', 'Fork', 'Spoon')
terms_fr = ('Couteau', 'Fourchette', 'Cuiller')
view0 = self.create_view(archf, terms_en, fr_FR=terms_fr)
archf2 = '<form string="%s"><p>%s</p><div>%s</div></form>'
terms_en2 = ('new Knife', 'Fork', 'Spoon')
# write en_US with delay_translations
view0.with_context(lang='en_US', delay_translations=True).arch_db = archf2 % terms_en2
view0.invalidate_recordset()
self.assertEqual(
view0.with_context(lang='en_US').arch_db,
archf2 % terms_en2,
'en_US value should be the latest one since it is updated directly'
)
self.assertEqual(view0.with_context(lang='en_US', check_translations=True).arch_db, archf2 % terms_en2)
self.assertEqual(
view0.with_context(lang='fr_FR').arch_db,
archf % terms_fr,
"fr_FR value should keep the same since its translations hasn't been confirmed"
)
self.assertEqual(
view0.with_context(lang='fr_FR', edit_translations=True).arch_db,
'<form string="'
'&lt;span '
'class=&quot;o_delay_translation&quot; '
'data-oe-model=&quot;ir.ui.view&quot; '
f'data-oe-id=&quot;{view0.id}&quot; '
'data-oe-field=&quot;arch_db&quot; '
'data-oe-translation-state=&quot;to_translate&quot; '
f'data-oe-translation-initial-sha=&quot;{sha256(terms_en2[0].encode()).hexdigest()}&quot;'
'&gt;'
f'{terms_en2[0]}'
'&lt;/span&gt;"'
'>'
'<p>'
'<span '
'class="o_delay_translation" '
'data-oe-model="ir.ui.view" '
f'data-oe-id="{view0.id}" '
'data-oe-field="arch_db" '
'data-oe-translation-state="translated" '
f'data-oe-translation-initial-sha="{sha256(terms_fr[1].encode()).hexdigest()}"'
'>'
f'{terms_fr[1]}'
'</span>'
'</p>'
'<div>'
'<span '
'class="o_delay_translation" '
'data-oe-model="ir.ui.view" '
f'data-oe-id="{view0.id}" '
'data-oe-field="arch_db" '
'data-oe-translation-state="translated" '
f'data-oe-translation-initial-sha="{sha256(terms_fr[2].encode()).hexdigest()}"'
'>'
f'{terms_fr[2]}'
'</span>'
'</div>'
'</form>'
)
self.assertEqual(
view0.with_context(lang='fr_FR', check_translations=True).arch_db,
archf2 % (terms_en2[0], terms_fr[1], terms_fr[2])
)
self.assertEqual(
view0.with_context(lang='nl_NL').arch_db,
archf2 % terms_en2,
"nl_NL value should fallback to en_US value"
)
self.assertEqual(
view0.with_context(lang='nl_NL', check_translations=True).arch_db,
archf2 % terms_en2
)
# update and confirm translations
view0.update_field_translations('arch_db', {'fr_FR': {}})
self.assertEqual(
view0.with_context(lang='fr_FR').arch_db,
archf2 % (terms_en2[0], terms_fr[1], terms_fr[2])
)
self.assertEqual(
view0.with_context(lang='fr_FR', check_translations=True).arch_db,
archf2 % (terms_en2[0], terms_fr[1], terms_fr[2])
)
def test_delay_translations_no_term(self):
archf = '<form string="%s"><div>%s</div><div>%s</div></form>'
terms_en = ('Knife', 'Fork', 'Spoon')
terms_fr = ('Couteau', 'Fourchette', 'Cuiller')
view0 = self.create_view(archf, terms_en, fr_FR=terms_fr)
archf2 = '<form/>'
# delay_translations only works when the written value has at least one translatable term
view0.with_context(lang='en_US', delay_translations=True).arch_db = archf2
for lang in ('en_US', 'fr_FR', 'nl_NL'):
self.assertEqual(
view0.with_context(lang=lang).arch_db,
archf2,
f'arch_db for {lang} should be {archf2}'
)
self.assertEqual(
view0.with_context(lang=lang, check_translations=True).arch_db,
archf2,
f'arch_db for {lang} should be {archf2} when check_translations'
)
class TestHTMLTranslation(TransactionCase):
def test_write_non_existing(self):
@ -1277,6 +1454,28 @@ class TestHTMLTranslation(TransactionCase):
# same behavior is expected for translated fields
company.flush_recordset()
def test_delay_translations_no_term(self):
self.env['res.lang']._activate_lang('fr_FR')
self.env['res.lang']._activate_lang('nl_NL')
Company = self.env['res.company']
company0 = Company.create({'name': 'company_1', 'report_footer': '<h1>Knife</h1>'})
company0.update_field_translations('report_footer', {'fr_FR': {'Knife': 'Couteau'}})
for html in ('<h1></h1>', '', False):
# delay_translations only works when the written value has at least one translatable term
company0.with_context(lang='en_US', delay_translations=True).report_footer = html
for lang in ('en_US', 'fr_FR', 'nl_NL'):
self.assertEqual(
company0.with_context(lang=lang).report_footer,
html,
f'report_footer for {lang} should be {html}'
)
self.assertEqual(
company0.with_context(lang=lang, check_translations=True).report_footer,
html,
f'report_footer for {lang} should be {html} when check_translations'
)
@tagged('post_install', '-at_install')
class TestLanguageInstallPerformance(TransactionCase):

View file

@ -283,17 +283,17 @@ class TestHasGroup(TransactionCase):
user_b.write({"groups_id": [Command.link(group_C.id)]})
def test_has_group_cleared_cache_on_write(self):
self.registry._clear_cache()
self.assertFalse(self.registry._Registry__cache, "Ensure ormcache is empty")
self.env.registry.clear_cache()
self.assertFalse(self.registry._Registry__caches['default'], "Ensure ormcache is empty")
def populate_cache():
self.test_user.has_group('test_user_has_group.group0')
self.assertTrue(self.registry._Registry__cache, "user.has_group cache must be populated")
self.assertTrue(self.registry._Registry__caches['default'], "user.has_group cache must be populated")
populate_cache()
self.env.ref(self.group0).write({"share": True})
self.assertFalse(self.registry._Registry__cache, "Writing on group must invalidate user.has_group cache")
self.assertFalse(self.registry._Registry__caches['default'], "Writing on group must invalidate user.has_group cache")
populate_cache()
# call_cache_clearing_methods is called in res.groups.write to invalidate
@ -303,6 +303,6 @@ class TestHasGroup(TransactionCase):
# the ormcache of method `user.has_group()`
self.env['ir.model.access'].call_cache_clearing_methods()
self.assertFalse(
self.registry._Registry__cache,
self.registry._Registry__caches['default'],
"call_cache_clearing_methods() must invalidate user.has_group cache"
)

File diff suppressed because it is too large Load diff

View file

@ -142,16 +142,21 @@ class TestAPIKeys(common.HttpCase):
def setUp(self):
super().setUp()
def get_json_data():
raise ValueError("There is no json here")
# needs a fake request in order to call methods protected with check_identity
fake_req = DotDict({
# various things go and access request items
'httprequest': DotDict({
'environ': {'REMOTE_ADDR': 'localhost'},
'cookies': {},
'args': {},
}),
# bypass check_identity flow
'session': {'identity-check-last': time.time()},
'geoip': {},
'get_json_data': get_json_data,
})
_request_stack.push(fake_req)
self.addCleanup(_request_stack.pop)