mirror of
https://github.com/bringout/oca-ocb-core.git
synced 2026-04-20 03:32:00 +02:00
17.0 vanilla
This commit is contained in:
parent
2e65bf056a
commit
df627a6bba
328 changed files with 578149 additions and 759311 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
),
|
||||
])
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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><p>coucou</p></div>'),
|
||||
Markup('<div>coucou<br>\ncoucou</div>'),
|
||||
Markup('<div>coucou<br>\n<br>\ncoucou</div>'),
|
||||
Markup('<div><p>coucou<br>\ncoucou<br>\n<br>\nzbouip</p><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. """
|
||||
|
||||
|
|
|
|||
|
|
@ -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é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>
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
|
|
|
|||
|
|
@ -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': [],
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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']"],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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('"', '"')
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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('\\', '/')
|
||||
|
|
|
|||
|
|
@ -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="'
|
||||
'<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="to_translate" '
|
||||
f'data-oe-translation-initial-sha="{sha256(terms_en2[0].encode()).hexdigest()}"'
|
||||
'>'
|
||||
f'{terms_en2[0]}'
|
||||
'</span>"'
|
||||
'>'
|
||||
'<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):
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue