From 26e8c3afde93535ad5bbf1b1289fa3503be9f0bf Mon Sep 17 00:00:00 2001 From: Alexandre Fayolle Date: Wed, 25 Mar 2026 15:54:03 +0100 Subject: [PATCH 1/2] [IMP] mail_environment: uninstall hook --- mail_environment/__init__.py | 1 + mail_environment/__manifest__.py | 1 + mail_environment/hooks.py | 41 ++++ .../tests/test_mail_environment.py | 205 ++++++++++++++++++ 4 files changed, 248 insertions(+) create mode 100644 mail_environment/hooks.py diff --git a/mail_environment/__init__.py b/mail_environment/__init__.py index 0650744f6..071962a35 100644 --- a/mail_environment/__init__.py +++ b/mail_environment/__init__.py @@ -1 +1,2 @@ from . import models +from .hooks import uninstall_hook diff --git a/mail_environment/__manifest__.py b/mail_environment/__manifest__.py index 7bd19de9c..14a0f7075 100644 --- a/mail_environment/__manifest__.py +++ b/mail_environment/__manifest__.py @@ -10,4 +10,5 @@ "license": "AGPL-3", "website": "https://github.com/OCA/server-env", "depends": ["mail", "server_environment"], + "uninstall_hook": "uninstall_hook", } diff --git a/mail_environment/hooks.py b/mail_environment/hooks.py new file mode 100644 index 000000000..cadc8cb4c --- /dev/null +++ b/mail_environment/hooks.py @@ -0,0 +1,41 @@ +def uninstall_hook(env): + """Restore database columns that server.env.mixin dropped for mail models. + + When mail_environment is uninstalled, ``ir.mail_server`` and + ``fetchmail.server`` would be left without the columns that the ORM + dropped when this addon was first installed. This hook recreates those + columns and repopulates them with the current effective values so the + database remains usable after removal. + """ + mixin = env["server.env.mixin"] + mixin.restore_env_managed_columns( + "ir.mail_server", + [ + "smtp_host", + "smtp_port", + "smtp_user", + "smtp_pass", + "smtp_encryption", + "smtp_authentication", + ], + field_defaults={ + "smtp_authentication": "login", # Fallback for required field + }, + ) + mixin.restore_env_managed_columns( + "fetchmail.server", + [ + "server", + "port", + "server_type", + "user", + "password", + "is_ssl", + "attach", + "original", + ], + field_defaults={ + "server": "localhost", # Fallback for required field + "server_type": "imap", # Fallback for required field + }, + ) diff --git a/mail_environment/tests/test_mail_environment.py b/mail_environment/tests/test_mail_environment.py index 44c396b2c..5ddc81efa 100644 --- a/mail_environment/tests/test_mail_environment.py +++ b/mail_environment/tests/test_mail_environment.py @@ -2,6 +2,8 @@ # License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html) +from odoo.tools import sql + from odoo.addons.server_environment.tests.common import ServerEnvironmentCase fetchmail_config = """ @@ -75,3 +77,206 @@ def test_fetchmail_search_is_ssl(self): fetchmail2, self.env["fetchmail.server"].search([("is_ssl", "!=", True)]), ) + + +_outgoing_config = """ +[outgoing_mail.test_outgoing] +smtp_host = smtp.example.com +smtp_port = 587 +smtp_encryption = starttls +smtp_authentication = login +smtp_user = testuser +smtp_pass = testpass +""" + +_incoming_config = """ +[incoming_mail.test_incoming] +server = imap.example.com +port = 993 +server_type = imap +is_ssl = 1 +user = imap_user +password = imap_pass +attach = 1 +original = 0 +""" + + +class TestRestoreEnvManagedColumns(ServerEnvironmentCase): + """Test column restoration performed by the uninstall hook.""" + + def _drop_columns(self, model_name, field_names): + """Drop columns created by restore_env_managed_columns (test cleanup).""" + model = self.env[model_name] + cr = self.env.cr + for field_name in field_names: + if sql.column_exists(cr, model._table, field_name): + cr.execute( # noqa: S608 + f'ALTER TABLE {model._table} DROP COLUMN "{field_name}"' + ) + + def test_restore_ir_mail_server_columns(self): + """Outgoing mail columns are recreated and populated with config values.""" + field_names = [ + "smtp_host", + "smtp_port", + "smtp_encryption", + "smtp_authentication", + "smtp_user", + "smtp_pass", + ] + server = self.env["ir.mail_server"].create({"name": "test_outgoing"}) + try: + with self.load_config(public=_outgoing_config): + self.env["server.env.mixin"].restore_env_managed_columns( + "ir.mail_server", field_names + ) + table = self.env["ir.mail_server"]._table + for field_name in field_names: + self.assertTrue( + sql.column_exists(self.env.cr, table, field_name), + f"Column {field_name} was not created", + ) + self.env.cr.execute( + "SELECT smtp_host, smtp_port, smtp_encryption," + " smtp_authentication, smtp_user, smtp_pass" + " FROM ir_mail_server WHERE id = %s", + [server.id], + ) + row = self.env.cr.dictfetchone() + self.assertEqual(row["smtp_host"], "smtp.example.com") + self.assertEqual(row["smtp_port"], 587) + self.assertEqual(row["smtp_encryption"], "starttls") + self.assertEqual(row["smtp_authentication"], "login") + self.assertEqual(row["smtp_user"], "testuser") + self.assertEqual(row["smtp_pass"], "testpass") + finally: + self._drop_columns("ir.mail_server", field_names) + + def test_restore_ir_mail_server_columns_with_default(self): + """Columns are populated with default values when no config is loaded.""" + field_names = ["smtp_host", "smtp_port"] + # Write via the inverse to set the x_smtp_host_env_default sparse field. + server = self.env["ir.mail_server"].create( + {"name": "test_default_outgoing", "smtp_host": "default.example.com"} + ) + try: + # No config loaded — values come from x_smtp_host_env_default. + self.env["server.env.mixin"].restore_env_managed_columns( + "ir.mail_server", field_names + ) + table = self.env["ir.mail_server"]._table + self.assertTrue(sql.column_exists(self.env.cr, table, "smtp_host")) + self.env.cr.execute( + "SELECT smtp_host FROM ir_mail_server WHERE id = %s", + [server.id], + ) + self.assertEqual(self.env.cr.fetchone()[0], "default.example.com") + finally: + self._drop_columns("ir.mail_server", field_names) + + def test_restore_fetchmail_server_columns(self): + """Incoming mail columns are recreated and populated with config values.""" + field_names = [ + "server", + "port", + "server_type", + "is_ssl", + "user", + "password", + "attach", + "original", + ] + fetchmail = self.env["fetchmail.server"].create({"name": "test_incoming"}) + try: + with self.load_config(public=_incoming_config): + self.env["server.env.mixin"].restore_env_managed_columns( + "fetchmail.server", field_names + ) + table = self.env["fetchmail.server"]._table + for field_name in field_names: + self.assertTrue( + sql.column_exists(self.env.cr, table, field_name), + f"Column {field_name} was not created", + ) + self.env.cr.execute( + 'SELECT server, port, server_type, is_ssl, "user",' + " password, attach, original" + " FROM fetchmail_server WHERE id = %s", + [fetchmail.id], + ) + row = self.env.cr.dictfetchone() + self.assertEqual(row["server"], "imap.example.com") + self.assertEqual(row["port"], 993) + self.assertEqual(row["server_type"], "imap") + self.assertTrue(row["is_ssl"]) + self.assertEqual(row["user"], "imap_user") + self.assertEqual(row["password"], "imap_pass") + self.assertTrue(row["attach"]) + self.assertFalse(row["original"]) + finally: + self._drop_columns("fetchmail.server", field_names) + + def test_restore_env_managed_columns_idempotent(self): + """Calling restore_env_managed_columns twice is safe and idempotent.""" + field_names = ["smtp_host", "smtp_port"] + self.env["ir.mail_server"].create({"name": "test_idempotent"}) + try: + with self.load_config(public=_outgoing_config): + mixin = self.env["server.env.mixin"] + mixin.restore_env_managed_columns("ir.mail_server", field_names) + # Second call must not raise or corrupt data. + mixin.restore_env_managed_columns("ir.mail_server", field_names) + table = self.env["ir.mail_server"]._table + for field_name in field_names: + self.assertTrue(sql.column_exists(self.env.cr, table, field_name)) + finally: + self._drop_columns("ir.mail_server", field_names) + + def test_restore_env_managed_columns_with_fallback_defaults(self): + """Required fields can be restored with fallback values via field_defaults.""" + + field_names = ["smtp_authentication"] + server = self.env["ir.mail_server"].create({"name": "test_fallback"}) + try: + # smtp_authentication is required but we provide no config or default. + # Without field_defaults, this would raise UserError. + # With field_defaults, it should succeed. + self.env["server.env.mixin"].restore_env_managed_columns( + "ir.mail_server", + field_names, + field_defaults={"smtp_authentication": "login"}, + ) + table = self.env["ir.mail_server"]._table + column_exists = sql.column_exists(self.env.cr, table, "smtp_authentication") + self.assertTrue(column_exists) + self.env.cr.execute( + "SELECT smtp_authentication FROM ir_mail_server WHERE id = %s", + [server.id], + ) + self.assertEqual(self.env.cr.fetchone()[0], "login") + finally: + self._drop_columns("ir.mail_server", field_names) + + def test_restore_env_managed_columns_required_field_no_fallback(self): + """Restoring a required field without value and no fallback raises UserError.""" + from odoo.exceptions import UserError + + field_names = ["smtp_authentication"] + self.env["ir.mail_server"].create({"name": "test_no_fallback"}) + try: + # smtp_authentication is required but we have no config, no default, + # and no field_defaults provided. This should raise UserError. + with self.assertRaises(UserError) as cm: + self.env["server.env.mixin"].restore_env_managed_columns( + "ir.mail_server", field_names + ) + self.assertIn("smtp_authentication", str(cm.exception)) + self.assertIn("field_defaults", str(cm.exception)) + finally: + # Manually drop in case the column was partially created. + model = self.env["ir.mail_server"] + if sql.column_exists(self.env.cr, model._table, "smtp_authentication"): + self.env.cr.execute( # noqa: S608 + f'ALTER TABLE {model._table} DROP COLUMN "smtp_authentication"' + ) From bee5f134d036d303f1202ee7c9b81af3bc317b43 Mon Sep 17 00:00:00 2001 From: Alexandre Fayolle Date: Wed, 1 Apr 2026 13:41:59 +0200 Subject: [PATCH 2/2] [DON'T MERGE] test-requirements.txt --- test-requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/test-requirements.txt b/test-requirements.txt index 4ad8e0ece..f432fe7f2 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1 +1,2 @@ odoo-test-helper +odoo-addon-server_environment @ git+https://github.com/OCA/server-env@refs/pull/261/head#subdirectory=server_environment