Files

1179 lines
55 KiB
Python

from unittest.mock import call, MagicMock, patch
from socket import gaierror
import urllib.parse
from .common import TestDatabasesCommon
from odoo import Command, fields
from odoo.exceptions import UserError
from odoo.tools.mail import html2plaintext
from odoo.tests import freeze_time, tagged
from odoo.tests.common import users
from odoo.addons.databases.models.project_project import get_database_api_keys
@tagged('-at_install', 'post_install')
class TestSynchronization(TestDatabasesCommon):
def mock_json2_calls_for_db(self, fqdn, version='dont-write-it-to-project', users=[], kpis=[]):
self.json2_mocked_calls[fqdn]['version'] = version
self.json2_mocked_calls[fqdn]['res.users']['search_read'] = users
self.json2_mocked_calls[fqdn]['res.users.apikeys']['generate'] = 'newApiKey'
self.json2_mocked_calls[fqdn]['kpi.provider']['get_kpi_summary'] = kpis
@users('db_manager@company.tld')
def test_synchronization_disabled(self):
self.env['ir.config_parameter'].sudo().set_param('databases.odoocom_apikey', False)
self.env['project.project'].action_synchronize_all_databases()
self.mock_requests_request.authenticate.assert_not_called()
self.mock_requests_request.execute_kw.assert_not_called()
@users('db_manager@company.tld')
def test_synchronize_databases(self):
"""Synchronize the databases from the Odoo.com SaaS"""
# First-time-seen database creates a project
Project = self.env['project.project']
self.json2_mocked_calls['www.odoo.com']['odoo.database']['list'] = [
{'name': 'odoo-sa', 'url': 'http://odoo-sa.my.odoo.test', 'login': 'some_user@odoo.com', 'version': '16.0+e'},
]
self.mock_json2_calls_for_db('odoo-sa.my.odoo.test')
Project.action_synchronize_all_databases()
new_projects = Project.search([])
self.assertCountEqual(self.mock_requests_request.mock_calls[:1], [
call('post', 'https://www.odoo.com/json/2/odoo.database/list', data=None, json={},
headers={'Authorization': 'Bearer odoocom_privateKey', 'X-Odoo-Database': 'openerp'}, allow_redirects=False, timeout=15),
])
self.assertRecordValues(new_projects, [
{
'name': 'odoo-sa',
'database_name': 'odoo-sa',
'database_hosting': 'saas',
'database_url': 'http://odoo-sa.my.odoo.test',
'database_api_login': 'some_user@odoo.com',
'database_version': '16.0',
},
])
# Already-seen database update the project, based on the URL
self.json2_mocked_calls['www.odoo.com']['odoo.database']['list'] = [
{'name': 'Odoo', 'url': 'http://odoo-sa.my.odoo.test', 'login': 'other_user@odoo.com', 'version': '17.0+e'},
]
with self.capture_wizard() as captured:
Project.action_synchronize_all_databases()
self.assertEqual(Project.search([]), new_projects)
self.assertRecordValues(new_projects, [
{
'name': 'odoo-sa',
'database_name': 'Odoo',
'database_hosting': 'saas',
'database_url': 'http://odoo-sa.my.odoo.test',
'database_api_login': 'other_user@odoo.com',
'database_version': '17.0',
},
])
wizard, = captured['wizards']
self.assertEqual(wizard.summary_message, "0 new databases, 1 updated.")
@users('db_user@company.tld')
def test_synchronize_database_with_db_user(self):
"""
- 1) Synchronize all db as databases_admin with a new db listed on odoo.com, with 'db_user@company.tld' user in it
-> The database project should have been created
-> a database user 'db_user@company.tld' should have been created
- 2) Synchronize all db as databases_user with a new document kpi (and still report the same user)
-> The kpi should have been updated correctly
- 3) Synchronize all db as databases_user with a user deleted and a different kpi
-> It should delete the user if the databases_user isn't in the list anymore
"""
# 1) Synchronize all db as databases_admin with a new db listed on odoo.com
self.json2_mocked_calls['www.odoo.com']['odoo.database']['list'] = [
{'name': 'Odoo', 'url': 'http://odoo-sa.my.odoo.test', 'login': 'other_user@odoo.com', 'version': '17.0+e'},
]
self.mock_json2_calls_for_db('odoo-sa.my.odoo.test', users=[{
'id': 3, 'name': 'db_user@company.tld', 'login': 'db_user@company.tld', 'login_date': '2026-03-24 17:02:44',
}])
self.env['project.project'].with_user(self.user_db_manager).action_synchronize_all_databases()
self.assertEqual(
self.env['project.project'].search([]).database_user_ids.mapped('login'),
['db_user@company.tld'],
"a database user 'db_user@company.tld' should have been created",
)
# 2) Synchronize all db as databases_user with a new document kpi (and still report the same user)
self.mock_json2_calls_for_db(
'odoo-sa.my.odoo.test',
kpis=[{'id': 'documents.inbox', 'name': 'Inbox', 'type': 'integer', 'value': 10}],
users=[{'id': 3, 'name': 'db_user@company.tld', 'login': 'db_user@company.tld', 'login_date': '2026-03-24 17:02:44'}],
)
self.env['project.project'].action_synchronize_all_databases()
self.assertEqual(
self.env['project.project'].search([]).database_nb_documents,
10,
"The kpi should have been updated correctly",
)
# 3) Synchronize all db as databases_user with a user deleted and a different kpi
self.mock_json2_calls_for_db(
'odoo-sa.my.odoo.test',
kpis=[{'id': 'documents.inbox', 'name': 'Inbox', 'type': 'integer', 'value': 20}],
users=[] # Simulate user deletion from the remote db
)
self.env['project.project'].action_synchronize_all_databases()
self.assertFalse(self.env['project.project'].search([]), 'db_user should have lost access')
project = self.env['project.project'].sudo().search([])
self.assertTrue(project, 'The database should still exist')
self.assertFalse(project.database_user_ids, 'The database_user should have been deleted')
self.assertEqual(project.database_nb_documents, 20, "The kpi should have been updated correctly")
@users('db_manager@company.tld')
def test_db_synchronize_several_dbs(self):
Project = self.env['project.project']
self.json2_mocked_calls['www.odoo.com']['odoo.database']['list'] = [
{'name': 'odoo-sa', 'url': 'http://odoo-sa.my.odoo.test', 'login': 'some_user@odoo.com', 'version': '16.0+e'},
{'name': 'titi-sarl', 'url': 'http://titi-sarl.my.odoo.test', 'login': 'admin@titi-sarl.fr', 'version': '17.0+e'},
{'name': 'toto-nv', 'url': 'http://toto-nv.my.odoo.test', 'login': 'contact@toto-nv.be', 'version': '18.0+e'},
]
self.mock_json2_calls_for_db('odoo-sa.my.odoo.test')
self.mock_json2_calls_for_db('titi-sarl.my.odoo.test')
self.mock_json2_calls_for_db('toto-nv.my.odoo.test')
with self.capture_wizard() as captured:
Project.action_synchronize_all_databases()
wizard, = captured['wizards']
self.assertEqual(wizard.summary_message, "3 new databases, 3 updated.")
self.assertCountEqual(self.mock_requests_request.mock_calls[:1], [
call('post', 'https://www.odoo.com/json/2/odoo.database/list', data=None, json={},
headers={'Authorization': 'Bearer odoocom_privateKey', 'X-Odoo-Database': 'openerp'}, allow_redirects=False, timeout=15),
])
new_projects = Project.search([], order='database_version')
self.assertRecordValues(new_projects, [
{
'name': 'odoo-sa',
'database_name': 'odoo-sa',
'database_hosting': 'saas',
'database_url': 'http://odoo-sa.my.odoo.test',
'database_api_login': 'some_user@odoo.com',
'database_version': '16.0',
},
{
'name': 'titi-sarl',
'database_name': 'titi-sarl',
'database_hosting': 'saas',
'database_url': 'http://titi-sarl.my.odoo.test',
'database_api_login': 'admin@titi-sarl.fr',
'database_version': '17.0',
},
{
'name': 'toto-nv',
'database_name': 'toto-nv',
'database_hosting': 'saas',
'database_url': 'http://toto-nv.my.odoo.test',
'database_api_login': 'contact@toto-nv.be',
'database_version': '18.0',
},
])
# Already-seen database update the project, based on the URL
# Other dbs should stay untouched
self.json2_mocked_calls['www.odoo.com']['odoo.database']['list'] = [
{'name': 'Odoo', 'url': 'http://odoo-sa.my.odoo.test', 'login': 'other_user@odoo.com', 'version': '17.0+e'},
]
self.mock_json2_calls_for_db('odoo-sa.my.odoo.test')
with self.capture_wizard() as captured:
Project.action_synchronize_all_databases()
wizard, = captured['wizards']
self.assertEqual(wizard.summary_message, "0 new databases, 3 updated.")
self.assertEqual(Project.search([]), new_projects)
self.assertRecordValues(new_projects, [
{
'name': 'odoo-sa',
'database_name': 'Odoo',
'database_hosting': 'saas',
'database_url': 'http://odoo-sa.my.odoo.test',
'database_api_login': 'other_user@odoo.com',
'database_version': '17.0',
},
{
'name': 'titi-sarl',
'database_name': 'titi-sarl',
'database_hosting': 'saas',
'database_url': 'http://titi-sarl.my.odoo.test',
'database_api_login': 'admin@titi-sarl.fr',
'database_version': '17.0',
},
{
'name': 'toto-nv',
'database_name': 'toto-nv',
'database_hosting': 'saas',
'database_url': 'http://toto-nv.my.odoo.test',
'database_api_login': 'contact@toto-nv.be',
'database_version': '18.0',
},
])
def test_odoocom_db_synchronization(self):
# First-time-seen database creates a project
Project = self.env['project.project']
self.json2_mocked_calls['www.odoo.com']['odoo.database']['list'] = [
{'name': 'cron-odoo-sa', 'url': 'http://cron-odoo-sa.my.odoo.test', 'login': 'some_user@odoo.com', 'version': '16.0+e'},
]
self.json2_mocked_calls['cron-odoo-sa.my.odoo.test']['version'] = '16.0+e'
self.json2_mocked_calls['cron-odoo-sa.my.odoo.test']['res.users']['search_read'] = []
self.json2_mocked_calls['cron-odoo-sa.my.odoo.test']['res.users.apikeys']['generate'] = 'some_new_api_key'
self.json2_mocked_calls['cron-odoo-sa.my.odoo.test']['kpi.provider']['get_kpi_summary'] = []
with freeze_time('2025-01-01 00:00'):
Project._cron_synchronize_all_databases_with_odoocom()
new_projects = Project.search([])
self.assertCountEqual(self.mock_requests_request.mock_calls, [
call('post', 'https://www.odoo.com/json/2/odoo.database/list', data=None, json={},
headers={'Authorization': 'Bearer odoocom_privateKey', 'X-Odoo-Database': 'openerp'}, allow_redirects=False, timeout=15),
call('post', 'http://cron-odoo-sa.my.odoo.test/json/2/res.users.apikeys/generate', data=None, json={
'key': 'odoocom_privateKey',
'scope': None,
'name': f'Access for {self.env.ref("base.main_company").name}',
'expiration_date': False,
},
headers={'Authorization': 'Bearer odoocom_privateKey', 'X-Odoo-Database': 'cron-odoo-sa'}, allow_redirects=False, timeout=15),
])
self.assertRecordValues(new_projects, [
{
'name': 'cron-odoo-sa',
'database_name': 'cron-odoo-sa',
'database_hosting': 'saas',
'database_url': 'http://cron-odoo-sa.my.odoo.test',
'database_api_login': 'some_user@odoo.com',
'database_version': '16.0',
'database_last_synchro': False,
},
])
database_api_key = get_database_api_keys(new_projects).get(new_projects.id)
self.assertEqual(database_api_key, 'some_new_api_key')
with freeze_time('2025-01-01 00:00'):
Project._cron_synchronize_all_databases()
self.assertEqual(
new_projects.database_last_synchro,
fields.Datetime.from_string("2025-01-01 00:00"),
"The database should now have been scanned",
)
with freeze_time('2025-01-01 23:59'):
Project._cron_synchronize_all_databases()
self.assertEqual(
new_projects.database_last_synchro,
fields.Datetime.from_string("2025-01-01 00:00"),
"The database shouldn't have been scanned yet",
)
with freeze_time('2025-01-02 00:00'):
Project._cron_synchronize_all_databases()
self.assertEqual(
new_projects.database_last_synchro,
fields.Datetime.from_string("2025-01-02 00:00"),
"The database should have been scanned",
)
def test_synchronizing_a_saas_db_with_just_a_url_should_raise_an_error(self):
""" Trying to synchronize one single database missing a db name should raise a User Friendly error"""
db_as_db_manager = self.env['project.project'].with_user(self.user_db_manager).create([{
'name': 'An accounting DB',
'database_hosting': 'saas',
'database_url': 'http://anotherdb.that.doesnt.exist',
'database_user_ids': [Command.create({
'name': 'db_user@company.tld',
'login': 'db_user@company.tld',
})]
}])
db_as_db_user = db_as_db_manager.with_user(self.user_db_user)
with self.assertRaisesRegex(UserError, "1 databases could not be synchronized."):
db_as_db_user.action_database_synchronize()
self.assertRecordValues(db_as_db_user.message_ids, [
{'preview': 'Error while connecting to http://anotherdb.that.doesnt.exist: '
'We are missing the database name, the api login or the api key'},
{'preview': 'Project created'},
])
self.assertIn('Unreachable', db_as_db_user.tag_ids.mapped('name'))
@users('db_manager@company.tld')
def test_synchronizing_several_db_with_one_missing_its_name_and_api_info(self):
""" In case several db gets synchronized, it shouldn't raise an Error but show it later on in the wizard
- Create a fully legit db record by synchronizing all dbs with Odoo.com
- Create manually a db missing its name and credentials
- Try to synchronize both db at the same time
=> No error raised
=> Any issue should be reported in the wizard
"""
# gets one legit database created through odoo.com synchronization and sync it
Project = self.env['project.project']
self.json2_mocked_calls['www.odoo.com']['odoo.database']['list'] = [
{'name': 'odoo-sa', 'url': 'http://odoo-sa.my.odoo.test', 'login': 'some_user@odoo.com', 'version': '16.0+e'},
]
self.mock_json2_calls_for_db('odoo-sa.my.odoo.test')
Project.action_synchronize_all_databases()
anotherdb = Project.create([{
'name': 'An accounting DB',
'database_hosting': 'saas',
'database_url': 'http://anotherdb.that.doesnt.exist',
}])
dbs = Project.search([])
self.assertEqual(len(dbs), 2)
# Synchronizing several db shouldn't raise any error
self.json2_mocked_calls['odoo-sa.my.odoo.test']['version'] = '16.0+e'
self.json2_mocked_calls['odoo-sa.my.odoo.test']['res.users']['search_read'] = []
self.json2_mocked_calls['odoo-sa.my.odoo.test']['kpi.provider']['get_kpi_summary'] = []
with self.capture_wizard() as captured:
dbs.action_database_synchronize()
wizard, = captured['wizards']
self.assertEqual(wizard.error_message.strip(), "1 databases could not be synchronized.")
self.assertRecordValues(anotherdb.message_ids, [
{'preview': 'Error while connecting to http://anotherdb.that.doesnt.exist: '
'We are missing the database name, the api login or the api key'},
{'preview': 'Project created'},
])
self.assertIn('Unreachable', anotherdb.tag_ids.mapped('name'))
@users('db_manager@company.tld')
def test_successful_synchronization_clears_unreachable_tag(self):
Project = self.env['project.project']
db = Project.create([{
'name': 'An accounting DB',
'database_hosting': 'saas',
'database_url': 'http://somedb.my.odoo.test',
'database_name': 'somedb',
'database_api_login': 'user',
'database_api_key': 'key',
# TODO in saas~19.3: replace with `self.env.ref('databases.project_tag_db_unreachable').ids`
'tag_ids': [self.env['databases.synchronization.wizard']._get_unreachable_tag_id()],
}])
self.mock_json2_calls_for_db('somedb.my.odoo.test')
db.action_database_synchronize()
self.assertNotIn('Unreachable', db.tag_ids.mapped('name'))
@users('db_manager@company.tld')
def test_synchronizing_all_databases_shouldnt_raise_but_should_report_any_issue(self):
""" In case several db gets synchronized, it shouldn't raise an Error but show it later on in the wizard
- Create manually a db missing its name and credentials
- Synchronize everything, creating a new db record from sync
=> The db synced from odoo.com should be there
=> Any issue should be reported in the wizard
"""
Project = self.env['project.project']
anotherdb = Project.create([{
'name': 'An accounting DB',
'database_hosting': 'saas',
'database_url': 'http://anotherdb.that.doesnt.exist',
}])
dbs = Project.search([])
self.assertEqual(len(dbs), 1)
self.json2_mocked_calls['www.odoo.com']['odoo.database']['list'] = [
{'name': 'odoo-sa', 'url': 'http://odoo-sa.my.odoo.test', 'login': 'some_user@odoo.com', 'version': '16.0+e'},
{'name': 'erroneous', 'url': 'http://erroneous.my.odoo.test', 'login': 'some_user@odoo.com', 'version': '18.0+e'},
]
self.mock_json2_calls_for_db('odoo-sa.my.odoo.test', version='saas~18.3a1+e')
self.mock_json2_calls_for_db('erroneous.my.odoo.test', version='saas~18.1+e',
users=UserError('Getting users error message'),
kpis=UserError('Getting KPIs error message'))
# This shouldn't raise any error
with self.capture_wizard() as captured:
Project.action_synchronize_all_databases()
dbs = Project.search([])
self.assertRecordValues(dbs, [{
'database_url': 'http://anotherdb.that.doesnt.exist',
'database_version': False,
}, {
'database_url': 'http://erroneous.my.odoo.test',
'database_version': 'saas~18.1',
}, {
'database_url': 'http://odoo-sa.my.odoo.test',
'database_version': 'saas~18.3',
}])
wizard, = captured['wizards']
self.assertEqual(wizard.error_message.strip(), "2 databases could not be synchronized.")
self.assertRecordValues(anotherdb.message_ids, [
{'preview': 'Error while connecting to http://anotherdb.that.doesnt.exist: '
'We are missing the database name, the api login or the api key'},
{'preview': 'Project created'},
])
self.assertIn('Unreachable', anotherdb.tag_ids.mapped('name'))
erroneousdb = Project.search([('database_name', '=', 'erroneous')])
self.assertEqual(html2plaintext(erroneousdb.message_ids[0].body),
'Error while getting users from erroneous: Getting users error message\n'
'Error while getting KPIs from erroneous: Getting KPIs error message')
self.assertIn('Unreachable', erroneousdb.tag_ids.mapped('name'))
@users('db_manager@company.tld')
def test_synchronizing_all_databases_shouldnt_raise_due_to_dns_errors(self):
Project = self.env['project.project']
self.json2_mocked_calls['www.odoo.com']['odoo.database']['list'] = [
{'name': 'odoo-sa', 'url': 'http://odoo-sa.my.odoo.test', 'login': 'some_user@odoo.com', 'version': '16.0+e'},
]
self.mock_json2_calls_for_db('odoo-sa.my.odoo.test')
mock_gai = self.startPatcher(patch("odoo.addons.databases.wizard.databases_synchronization_wizard.getaddrinfo",
side_effect=gaierror("some getaddrinfo error")))
Project.action_synchronize_all_databases()
dbs = Project.search([])
self.assertRecordValues(dbs.message_ids, [
{'preview': 'Error while resolving http://odoo-sa.my.odoo.test: some getaddrinfo error'},
{'preview': 'Project created from template Database Template.'},
{'preview': 'Project created'},
])
mock_gai.configure_mock(side_effect=None, return_value=[])
Project.action_synchronize_all_databases()
self.assertRecordValues(dbs.message_ids, [
{'preview': 'Error while resolving http://odoo-sa.my.odoo.test: found no IP'},
{'preview': 'Error while resolving http://odoo-sa.my.odoo.test: some getaddrinfo error'},
{'preview': 'Project created from template Database Template.'},
{'preview': 'Project created'},
])
@users('db_manager@company.tld')
def test_synchronizing_all_databases_shouldnt_raise_if_project_template_not_configured(self):
self.json2_mocked_calls['www.odoo.com']['odoo.database']['list'] = [
{'name': 'odoo-sa', 'url': 'http://odoo-sa.my.odoo.test', 'login': 'some_user@odoo.com', 'version': '16.0+e'},
]
self.env['ir.config_parameter'].sudo().set_param('databases.odoocom_project_template', False)
self.mock_json2_calls_for_db('odoo-sa.my.odoo.test')
# This shouldn't raise any error
self.env['project.project'].action_synchronize_all_databases()
@users('db_manager@company.tld')
def test_sync_pull_db_record_already_registered_as_saas_manually(self):
""" If a db was manually created as a saas db and synchronized later on, the information should be udpated
- Manually Create a saas db record with missing informations
- Trigger the synchronization
=> The db informations should be updated
"""
Project = self.env['project.project']
self.assertFalse(Project.search([]), "There shouldn't be any project yet")
Project.create([{
'name': 'odoo-sa',
'database_hosting': 'saas',
'database_url': 'http://odoo-sa.my.odoo.test',
}])
db = Project.search([])
self.assertRecordValues(db, [
{
'name': 'odoo-sa',
'database_name': False,
'database_hosting': 'saas',
'database_url': 'http://odoo-sa.my.odoo.test',
'database_api_login': False,
'database_version': False,
},
])
self.json2_mocked_calls['www.odoo.com']['odoo.database']['list'] = [
{'name': 'odoo-sa', 'url': 'http://odoo-sa.my.odoo.test', 'login': 'some_user@odoo.com', 'version': '16.0+e'},
]
self.mock_json2_calls_for_db('odoo-sa.my.odoo.test')
Project.action_synchronize_all_databases()
self.assertRecordValues(db, [
{
'name': 'odoo-sa',
'database_name': 'odoo-sa',
'database_hosting': 'saas',
'database_url': 'http://odoo-sa.my.odoo.test',
'database_api_login': 'some_user@odoo.com',
'database_version': '16.0',
},
])
@users('db_manager@company.tld')
def test_db_on_premise_configured_shouldnt_be_modified(self):
""" Fully configured on premise Db reported by odoo.com shouldn't be updated as it could wrongly override
the data and be very frustrating to the db manager.
- Manually create an on premise db
- configure the login and apikey
- update all dbs
=> The database should have been left untouched
"""
Project = self.env['project.project']
self.assertFalse(Project.search([]), "There shouldn't be any project yet")
Project.create([{
'name': 'odoo-sa',
'database_hosting': 'premise',
'database_name': 'odoo-sa',
'database_url': 'http://odoo-sa.my.odoo.test',
'database_api_login': 'randomlogin@randomdomain.random',
'database_api_key': 'random api key, amazing',
}])
db = Project.search([])
self.assertRecordValues(db, [
{
'name': 'odoo-sa',
'database_name': 'odoo-sa',
'database_hosting': 'premise',
'database_url': 'http://odoo-sa.my.odoo.test',
'database_api_login': 'randomlogin@randomdomain.random',
},
])
database_api_key = get_database_api_keys(db).get(db.id)
self.assertEqual(database_api_key, 'random api key, amazing')
self.json2_mocked_calls['www.odoo.com']['odoo.database']['list'] = [
{'name': 'odoo-sa', 'url': 'http://odoo-sa.my.odoo.test', 'login': 'somethingelse@wout.wout', 'version': '16.0+e'},
]
self.mock_json2_calls_for_db('odoo-sa.my.odoo.test')
with self.capture_wizard() as captured:
Project.action_synchronize_all_databases()
self.assertRecordValues(db, [
{
'name': 'odoo-sa',
'database_name': 'odoo-sa',
'database_hosting': 'premise',
'database_url': 'http://odoo-sa.my.odoo.test',
'database_api_login': 'randomlogin@randomdomain.random',
'database_version': False,
},
])
database_api_key = get_database_api_keys(db).get(db.id)
self.assertEqual(database_api_key, 'random api key, amazing')
wizard, = captured['wizards']
self.assertFalse(wizard.error_message)
self.assertRecordValues(db.message_ids, [{'preview': 'Project created'}])
self.assertNotIn('Unreachable', db.tag_ids.mapped('name'))
@users('db_manager@company.tld')
def test_db_on_premise_not_configured_should_be_updated_to_saas_db(self):
""" A NOT configured on premise Db reported by odoo.com should be updated with odoo.com data
- Manually create an on premise db
- update all dbs
=> The database should have been updated and be reported as a saas databases
"""
Project = self.env['project.project']
self.assertFalse(Project.search([]), "There shouldn't be any project yet")
Project.create([{
'name': 'odoo-sa',
'database_hosting': 'premise',
'database_url': 'http://odoo-sa.my.odoo.test',
}])
db = Project.search([])
self.assertRecordValues(db, [
{
'name': 'odoo-sa',
'database_name': False,
'database_hosting': 'premise',
'database_url': 'http://odoo-sa.my.odoo.test',
'database_api_login': False,
'database_version': False,
},
])
self.json2_mocked_calls['www.odoo.com']['odoo.database']['list'] = [
{'name': 'odoo-sa', 'url': 'http://odoo-sa.my.odoo.test', 'login': 'some_user@odoo.com', 'version': '16.0+e'},
]
self.mock_json2_calls_for_db('odoo-sa.my.odoo.test')
Project.action_synchronize_all_databases()
self.assertRecordValues(db, [
{
'name': 'odoo-sa',
'database_name': 'odoo-sa',
'database_hosting': 'saas',
'database_url': 'http://odoo-sa.my.odoo.test',
'database_api_login': 'some_user@odoo.com',
'database_version': '16.0',
},
])
@users('db_manager@company.tld')
def test_synchronization_wizard_update_from_odoocom_without_configuration(self):
self.env['ir.config_parameter'].sudo().set_param('databases.odoocom_apikey', False)
wizard = self.env['databases.synchronization.wizard'].create({})
# This shouldn't raise any error
wizard._do_update_from_odoocom()
@users('db_manager@company.tld')
def test_synchronization_wizard_update_from_odoocom_with_authenticate_error(self):
# Simulate an invalid API key error
self.mock_requests_request.configure_mock(**{
'side_effect': None,
'return_value.status_code': 401,
'return_value.json.return_value': {'message': 'Invalid apikey'},
})
wizard = self.env['databases.synchronization.wizard'].create({})
wizard._do_update_from_odoocom()
self.assertEqual(wizard.error_message, 'Error while listing databases from https://www.odoo.com: Invalid apikey\n')
@users('db_manager@company.tld')
def test_synchronization_wizard_update_from_odoocom_with_call_error(self):
# Simulate an exception
self.mock_requests_request.configure_mock(**{
'side_effect': None,
'return_value.status_code': 422,
'return_value.json.return_value': {'message': 'Some error'},
})
wizard = self.env['databases.synchronization.wizard'].create({})
wizard._do_update_from_odoocom()
self.assertEqual(wizard.error_message, 'Error while listing databases from https://www.odoo.com: Some error\n')
@users('db_manager@company.tld')
def test_user_management(self):
database = self.env['project.project'].create([{
'name': 'odoo-sa',
'database_hosting': 'saas',
'database_url': 'http://odoo-sa.my.odoo.test',
'database_name': 'odoo-sa',
'database_api_login': 'admin',
'database_api_key': 'admin_apikey',
}])
invite_wizard = self.env['databases.manage_users.wizard'].create({
'mode': 'invite',
'database_ids': database.ids,
'user_ids': self.user_employee.ids,
})
self.json2_mocked_calls['odoo-sa.my.odoo.test']['res.users']['web_create_users'] = True
invite_wizard.action_invite_users()
self.assertCountEqual(self.mock_requests_request.mock_calls, [
call('post', 'http://odoo-sa.my.odoo.test/json/2/res.users/web_create_users', data=None, json={
'emails': ['employee@company.tld'],
'context': {'no_reset_password': True},
}, headers={'Authorization': 'Bearer admin_apikey', 'X-Odoo-Database': 'odoo-sa'}, allow_redirects=False, timeout=15),
])
self.assertFalse(invite_wizard.error_message)
self.mock_requests_request.reset_mock()
remove_wizard = self.env['databases.manage_users.wizard'].create({
'mode': 'remove',
'database_ids': database.ids,
'user_ids': self.user_employee.ids,
})
self.json2_mocked_calls['odoo-sa.my.odoo.test']['res.users']['search'] = [23]
self.json2_mocked_calls['odoo-sa.my.odoo.test']['res.users']['write'] = True
invite_wizard.action_remove_users()
self.assertCountEqual(self.mock_requests_request.mock_calls, [
call('post', 'http://odoo-sa.my.odoo.test/json/2/res.users/search', data=None, json={
'domain': [('login', 'in', ['employee@company.tld'])],
}, headers={'Authorization': 'Bearer admin_apikey', 'X-Odoo-Database': 'odoo-sa'}, allow_redirects=False, timeout=15),
call('post', 'http://odoo-sa.my.odoo.test/json/2/res.users/write', data=None, json={
'ids': [23],
'vals': {'active': False},
}, headers={'Authorization': 'Bearer admin_apikey', 'X-Odoo-Database': 'odoo-sa'}, allow_redirects=False, timeout=15),
])
self.assertFalse(remove_wizard.error_message)
@users('db_manager@company.tld')
def test_synchronization_with_duplicate_property_labels(self):
Project = self.env['project.project']
self.json2_mocked_calls['www.odoo.com']['odoo.database']['list'] = [
{'name': 'odoo-sa', 'url': 'http://odoo-sa.my.odoo.test', 'login': 'some_user@odoo.com', 'version': '16.0+e'},
]
self.mock_json2_calls_for_db('odoo-sa.my.odoo.test', kpis=[
{'id': 'my_kpi.some_unique_id', 'name': 'Duplicate label', 'type': 'integer', 'value': 1},
{'id': 'my_kpi.another_unique_id', 'name': 'Duplicate label', 'type': 'integer', 'value': 4},
])
Project.action_synchronize_all_databases()
new_projects = Project.search([])
self.assertRecordValues(new_projects.sudo().database_kpi_base_definition_id, [{
'properties_definition': [
{'name': 'my_kpi_some_unique_id', 'string': 'Duplicate label', 'type': 'integer', 'default': 0},
{'name': 'my_kpi_another_unique_id', 'string': 'Duplicate label', 'type': 'integer', 'default': 0},
],
}])
self.assertRecordValues(new_projects, [{
'database_kpi_properties': {
'my_kpi_some_unique_id': 1,
'my_kpi_another_unique_id': 4,
},
}])
@users('db_manager@company.tld')
def test_synchronization_applies_latest_kpi_snapshot(self):
Project = self.env['project.project']
self.json2_mocked_calls['www.odoo.com']['odoo.database']['list'] = [
{'name': 'odoo-sa', 'url': 'http://odoo-sa.my.odoo.test', 'login': 'some_user@odoo.com', 'version': '16.0+e'},
]
self.mock_json2_calls_for_db('odoo-sa.my.odoo.test', kpis=[
{'id': 'my_kpi.mon_bel_id', 'name': 'My KPI', 'type': 'integer', 'value': 1},
{'id': 'my_kpi.mon_autre_bel_id', 'name': 'My other KPI', 'type': 'integer', 'value': 2},
])
Project.action_synchronize_all_databases()
new_projects = Project.search([])
self.assertRecordValues(new_projects.sudo().database_kpi_base_definition_id, [{
'properties_definition': [
{'name': 'my_kpi_mon_bel_id', 'string': 'My KPI', 'type': 'integer', 'default': 0},
{'name': 'my_kpi_mon_autre_bel_id', 'string': 'My other KPI', 'type': 'integer', 'default': 0},
],
}])
self.assertRecordValues(
new_projects,
[{
"database_kpi_properties": {
"my_kpi_mon_bel_id": 1,
"my_kpi_mon_autre_bel_id": 2,
}
}],
)
self.mock_json2_calls_for_db('odoo-sa.my.odoo.test', kpis=[
{'id': 'my_kpi.mon_bel_id', 'name': 'My KPI', 'type': 'integer', 'value': 0},
{'id': 'my_kpi.mon_autre_bel_id', 'name': 'My KPI', 'type': 'integer', 'value': 1},
])
Project.action_synchronize_all_databases()
self.assertRecordValues(new_projects, [{'database_kpi_properties': {'my_kpi_mon_bel_id': 0, 'my_kpi_mon_autre_bel_id': 1}}])
self.mock_json2_calls_for_db('odoo-sa.my.odoo.test', kpis=[
{'id': 'my_kpi.mon_autre_bel_id', 'name': 'My KPI', 'type': 'integer', 'value': 0},
])
Project.action_synchronize_all_databases()
self.assertRecordValues(new_projects, [{'database_kpi_properties': {'my_kpi_mon_autre_bel_id': 0}}])
self.mock_json2_calls_for_db('odoo-sa.my.odoo.test', kpis=[])
Project.action_synchronize_all_databases()
self.assertRecordValues(new_projects, [{'database_kpi_properties': {}}])
@users('db_manager@company.tld')
def test_synchronize_all_databases_synchronization_order(self):
"""Synchronizing all dbs should synchronize db from the user triggering the action first"""
# set the synchronization limit to 1
self.env['ir.config_parameter'].sudo().set_param('databases.immediate_sync_limit', 1)
# create 2 db records, 1 in which the user has a login record, another one without
db_other, db_user = self.env['project.project'].create([
{
'name': 'Other DB',
'database_hosting': 'saas',
'database_url': 'http://other-db.odoo.test',
'database_name': 'other-db',
'database_api_login': 'admin',
'database_api_key': 'key',
},
{
'name': 'User DB',
'database_hosting': 'saas',
'database_url': 'http://user-db.odoo.test',
'database_name': 'user-db',
'database_api_login': 'admin',
'database_api_key': 'key',
'database_user_ids': [Command.create({
'name': self.env.user.name,
'login': self.env.user.login,
})]
},
])
# call action_synchronize_all_databases
self.json2_mocked_calls['www.odoo.com']['odoo.database']['list'] = []
self.mock_json2_calls_for_db('user-db.odoo.test')
self.mock_json2_calls_for_db('other-db.odoo.test')
self.env['project.project'].action_synchronize_all_databases()
# Ensure the db from the user was synchronized and that the other wasn't synchronized
self.assertTrue(db_user.database_last_synchro, "The db in which the user can login should get synchronized first")
self.assertFalse(
db_other.database_last_synchro,
"Considering that only one db can be synchronized right now, the db without a user should have been delayed."
)
@users('db_manager@company.tld')
def test_synchronize_several_selected_databases_synchronization_order(self):
"""Synchronizing a selection of dbs should synchronize selected dbs in which the user can loggin first"""
# set the synchronization limit to 1
self.env['ir.config_parameter'].sudo().set_param('databases.immediate_sync_limit', 1)
# create 3 db records, 2 in which the user has a login record, another one without
db_user_1, db_other, db_user_2 = self.env['project.project'].create([
{
'name': 'User DB 1',
'database_hosting': 'saas',
'database_url': 'http://user-db-1.odoo.test',
'database_name': 'user-db-1',
'database_api_login': 'admin',
'database_api_key': 'key',
'database_user_ids': [Command.create({
'name': self.env.user.name,
'login': self.env.user.login,
})]
},
{
'name': 'Other DB',
'database_hosting': 'saas',
'database_url': 'http://other-db.odoo.test',
'database_name': 'other-db',
'database_api_login': 'admin',
'database_api_key': 'key',
},
{
'name': 'User DB 2',
'database_hosting': 'saas',
'database_url': 'http://user-db-2.odoo.test',
'database_name': 'user-db-2',
'database_api_login': 'admin',
'database_api_key': 'key',
'database_user_ids': [Command.create({
'name': self.env.user.name,
'login': self.env.user.login,
})]
}
])
# select 1 db with a login and another one without and call action_database_synchronize on those
selected_dbs = db_other + db_user_2
self.mock_json2_calls_for_db('user-db-1.odoo.test')
self.mock_json2_calls_for_db('user-db-2.odoo.test')
self.mock_json2_calls_for_db('other-db.odoo.test')
selected_dbs.action_database_synchronize()
self.assertFalse(db_user_1.database_last_synchro, "This db wasn't selected and shouldn't have been synchronized")
self.assertTrue(
db_user_2.database_last_synchro,
"This db is selected + the user can log in and thus should have been synchronized first"
)
self.assertFalse(
db_other.database_last_synchro,
"This db is selected but doesn't contain the user and should have been delayed"
)
@users('db_manager@company.tld')
def test_ensuring_synchronization_order_change_depending_on_user(self):
"""This test ensure the sync order really changes depending on the user being in the databases"""
# set the synchronization limit to 1
self.env['ir.config_parameter'].sudo().set_param('databases.immediate_sync_limit', 1)
# first ensure the databases are sync by id
db_first_sync, db_second_sync = self.env['project.project'].create([
{
'name': 'Other DB',
'database_hosting': 'saas',
'database_url': 'http://other-db.odoo.test',
'database_name': 'other-db',
'database_api_login': 'admin',
'database_api_key': 'key',
},
{
'name': 'User DB',
'database_hosting': 'saas',
'database_url': 'http://another-db.odoo.test',
'database_name': 'user-db',
'database_api_login': 'admin',
'database_api_key': 'key',
},
])
self.json2_mocked_calls['www.odoo.com']['odoo.database']['list'] = []
self.mock_json2_calls_for_db('user-db.odoo.test')
self.mock_json2_calls_for_db('other-db.odoo.test')
self.env['project.project'].action_synchronize_all_databases()
self.assertTrue(db_first_sync.database_last_synchro, "This db should have been synchronized considering the default order")
self.assertFalse(
db_second_sync.database_last_synchro,
"This db synchronization should have been delayed considering the default order"
)
# Synchronize a second time and ensure the second db was synchronized
self.env['project.project'].action_synchronize_all_databases()
# ensure second sync will sync this one
self.assertTrue(db_second_sync.database_last_synchro, "The second synchronization should have synchronize this db first")
# reset sync date, add a user and ensure it impacts the sync order
dbs = (db_first_sync + db_second_sync)
dbs.database_last_synchro = False
self.assertFalse(any(dbs.mapped('database_last_synchro')))
self.json2_mocked_calls['www.odoo.com']['odoo.database']['list'] = []
self.mock_json2_calls_for_db('user-db.odoo.test')
self.mock_json2_calls_for_db('other-db.odoo.test')
db_second_sync.write({
'database_user_ids': [Command.create({
'name': self.env.user.name,
'login': self.env.user.login,
})]
})
self.env['project.project'].action_synchronize_all_databases()
self.assertTrue(
db_second_sync.database_last_synchro,
"The user can log into this db and thus it should have been synchronized first"
)
self.assertFalse(
db_first_sync.database_last_synchro,
"This db synchronization should have been delayed considering only one db can be synchronized"
)
@tagged('-at_install', 'post_install')
class TestXmlRpcSynchronization(TestDatabasesCommon):
def setUp(self):
super().setUp()
# Simulate a server where /json/2 is not implemented, but let calls to odoo.com pass through
def mock_requests_request(method, uri, *args, **kwargs):
if urllib.parse.urlparse(uri).hostname == 'www.odoo.com':
return prev_mock_requests_request(method, uri, *args, **kwargs)
return MagicMock(status_code=303)
prev_mock_requests_request = self.mock_requests_request.side_effect
self.mock_requests_request.configure_mock(side_effect=mock_requests_request)
# Return a specific mock object for each ServerProxy
self.odoo_sa_xmrpc_common = MagicMock(**{'authenticate.return_value': -128})
self.odoo_sa_xmrpc_object = MagicMock()
self.call_resolution_per_uri = {
"http://odoo-sa.my.odoo.test/xmlrpc/2/common": self.odoo_sa_xmrpc_common,
"http://odoo-sa.my.odoo.test/xmlrpc/2/object": self.odoo_sa_xmrpc_object,
}
self.startPatcher(patch("xmlrpc.client.ServerProxy", side_effect=lambda uri: self.call_resolution_per_uri[uri]))
def set_returned_databases_list(self, databases_list):
for database in databases_list:
self.call_resolution_per_uri.setdefault(f"{database['url']}/xmlrpc/2/common", MagicMock())\
.version.configure_mock(return_value={'server_serie': database['version']})
self.call_resolution_per_uri.setdefault(f"{database['url']}/xmlrpc/2/object", MagicMock())
self.json2_mocked_calls['www.odoo.com']['odoo.database']['list'] = databases_list
@users('db_manager@company.tld')
def test_synchronize_databases(self):
"""Synchronize the databases from the Odoo.com SaaS"""
# First-time-seen database creates a project
Project = self.env['project.project']
self.set_returned_databases_list([
{'name': 'odoo-sa', 'login': 'some_user@odoo.com', 'version': '16.0+e', 'url': 'http://odoo-sa.my.odoo.test'},
])
Project.action_synchronize_all_databases()
new_projects = Project.search([])
self.assertRecordValues(new_projects, [
{
'name': 'odoo-sa',
'database_name': 'odoo-sa',
'database_hosting': 'saas',
'database_url': 'http://odoo-sa.my.odoo.test',
'database_api_login': 'some_user@odoo.com',
'database_version': '16.0',
},
])
# Already-seen database updates the project, based on the URL
self.set_returned_databases_list([
{'name': 'odoo-bis', 'login': 'other_user@odoo.com', 'version': '17.0+e', 'url': 'http://odoo-sa.my.odoo.test'},
])
with self.capture_wizard() as captured:
Project.action_synchronize_all_databases()
self.assertEqual(Project.search([]), new_projects)
self.assertRecordValues(new_projects, [
{
'name': 'odoo-sa',
'database_name': 'odoo-bis',
'database_hosting': 'saas',
'database_url': 'http://odoo-sa.my.odoo.test',
'database_api_login': 'other_user@odoo.com',
'database_version': '17.0',
},
])
wizard, = captured['wizards']
self.assertEqual(wizard.summary_message, "0 new databases, 1 updated.")
@users('db_manager@company.tld')
def test_db_synchronize_several_dbs(self):
Project = self.env['project.project']
self.set_returned_databases_list([
{'name': 'odoo-sa', 'login': 'some_user@odoo.com', 'version': '16.0+e', 'url': 'http://odoo-sa.my.odoo.test'},
{'name': 'titi-sarl', 'login': 'admin@titi-sarl.fr', 'version': '17.0+e', 'url': 'http://titi-sarl.my.odoo.test'},
{'name': 'toto-nv', 'login': 'contact@toto-nv.be', 'version': '18.0+e', 'url': 'http://toto-nv.my.odoo.test'},
])
with self.capture_wizard() as captured:
Project.action_synchronize_all_databases()
wizard, = captured['wizards']
self.assertEqual(wizard.summary_message, "3 new databases, 3 updated.")
new_projects = Project.search([], order='database_version')
self.assertRecordValues(new_projects, [
{
'name': 'odoo-sa',
'database_name': 'odoo-sa',
'database_hosting': 'saas',
'database_url': 'http://odoo-sa.my.odoo.test',
'database_api_login': 'some_user@odoo.com',
'database_version': '16.0',
},
{
'name': 'titi-sarl',
'database_name': 'titi-sarl',
'database_hosting': 'saas',
'database_url': 'http://titi-sarl.my.odoo.test',
'database_api_login': 'admin@titi-sarl.fr',
'database_version': '17.0',
},
{
'name': 'toto-nv',
'database_name': 'toto-nv',
'database_hosting': 'saas',
'database_url': 'http://toto-nv.my.odoo.test',
'database_api_login': 'contact@toto-nv.be',
'database_version': '18.0',
},
])
# Already-seen database update the project, based on the URL
# Other dbs should stay untouched
self.set_returned_databases_list([
{'name': 'odoo', 'login': 'other_user@odoo.com', 'version': '17.0+e', 'url': 'http://odoo-sa.my.odoo.test'},
])
with self.capture_wizard() as captured:
Project.action_synchronize_all_databases()
wizard, = captured['wizards']
self.assertEqual(wizard.summary_message, "0 new databases, 3 updated.")
self.assertEqual(Project.search([]), new_projects)
self.assertRecordValues(new_projects, [
{
'name': 'odoo-sa',
'database_name': 'odoo',
'database_hosting': 'saas',
'database_url': 'http://odoo-sa.my.odoo.test',
'database_api_login': 'other_user@odoo.com',
'database_version': '17.0',
},
{
'name': 'titi-sarl',
'database_name': 'titi-sarl',
'database_hosting': 'saas',
'database_url': 'http://titi-sarl.my.odoo.test',
'database_api_login': 'admin@titi-sarl.fr',
'database_version': '17.0',
},
{
'name': 'toto-nv',
'database_name': 'toto-nv',
'database_hosting': 'saas',
'database_url': 'http://toto-nv.my.odoo.test',
'database_api_login': 'contact@toto-nv.be',
'database_version': '18.0',
},
])
def test_odoocom_db_synchronization(self):
# First-time-seen database creates a project
Project = self.env['project.project']
self.set_returned_databases_list([
{'name': 'cron-odoo-sa', 'login': 'some_user@odoo.com', 'version': '16.0+e', 'url': 'http://cron-odoo-sa.my.odoo.test'},
])
with freeze_time('2025-01-01 00:00'):
Project._cron_synchronize_all_databases_with_odoocom()
new_projects = Project.search([])
self.assertRecordValues(new_projects, [
{
'name': 'cron-odoo-sa',
'database_name': 'cron-odoo-sa',
'database_hosting': 'saas',
'database_url': 'http://cron-odoo-sa.my.odoo.test',
'database_api_login': 'some_user@odoo.com',
'database_version': '16.0',
'database_last_synchro': False,
},
])
cron_odoo_sa_xmlrpc_common = MagicMock()
cron_odoo_sa_xmlrpc_common.version.return_value = {'server_serie': 'saas~18.4'}
self.call_resolution_per_uri['http://cron-odoo-sa.my.odoo.test/xmlrpc/2/common'] = cron_odoo_sa_xmlrpc_common
with freeze_time('2025-01-01 00:00'):
Project._cron_synchronize_all_databases()
self.assertEqual(
new_projects.database_last_synchro,
fields.Datetime.from_string("2025-01-01 00:00"),
"The database should now have been scanned",
)
with freeze_time('2025-01-01 23:59'):
Project._cron_synchronize_all_databases()
self.assertEqual(
new_projects.database_last_synchro,
fields.Datetime.from_string("2025-01-01 00:00"),
"The database shouldn't have been scanned yet",
)
with freeze_time('2025-01-02 00:00'):
Project._cron_synchronize_all_databases()
self.assertEqual(
new_projects.database_last_synchro,
fields.Datetime.from_string("2025-01-02 00:00"),
"The database should have been scanned",
)
@users('db_manager@company.tld')
def test_user_management(self):
database = self.env['project.project'].create([{
'name': 'odoo-sa',
'database_hosting': 'saas',
'database_url': 'http://odoo-sa.my.odoo.test',
'database_name': 'odoo-sa',
'database_api_login': 'admin',
'database_api_key': 'admin_apikey',
}])
invite_wizard = self.env['databases.manage_users.wizard'].create({
'mode': 'invite',
'database_ids': database.ids,
'user_ids': self.user_employee.ids,
})
invite_wizard.action_invite_users()
self.assertCountEqual(self.odoo_sa_xmrpc_common.authenticate.mock_calls, [
call('odoo-sa', 'admin', 'admin_apikey', {}),
])
self.assertCountEqual(self.odoo_sa_xmrpc_object.execute_kw.mock_calls, [
call('odoo-sa', -128, 'admin_apikey', 'res.users', 'web_create_users', [], {
'emails': ['employee@company.tld'],
'context': {'no_reset_password': True},
}),
])
self.assertFalse(invite_wizard.error_message)
self.odoo_sa_xmrpc_common.authenticate.reset_mock()
self.odoo_sa_xmrpc_object.execute_kw.reset_mock()
self.odoo_sa_xmrpc_object.execute_kw.side_effect = [[1024], True]
remove_wizard = self.env['databases.manage_users.wizard'].create({
'mode': 'remove',
'database_ids': database.ids,
'user_ids': self.user_employee.ids,
})
invite_wizard.action_remove_users()
# TODO: self.odoo_sa_xmrpc_common.authenticate.assert_not_called() # should have recycled the client
self.assertCountEqual(self.odoo_sa_xmrpc_object.execute_kw.mock_calls, [
call('odoo-sa', -128, 'admin_apikey', 'res.users', 'search', [
[('login', 'in', ['employee@company.tld'])],
]),
call('odoo-sa', -128, 'admin_apikey', 'res.users', 'write', [
[1024],
{'active': False},
]),
])
self.assertFalse(remove_wizard.error_message)