From 14096350b31bd6e94e887f82475838b29976e127 Mon Sep 17 00:00:00 2001 From: Alexandre Fayolle Date: Wed, 25 Mar 2026 15:40:12 +0100 Subject: [PATCH] [IMP] server_environment: module uninstallation Add a helper to manage the restoring of the database columns when a module using `server_environment` is uninstalled or the dependency on `server_environment` is dropped. Document how to use the helper in an uninstall script or in an upgrade script (if a new version of the addon drops the dependency). --- server_environment/__init__.py | 1 + server_environment/readme/USAGE.rst | 144 ++++++++++++++++++ .../tests/test_server_environment.py | 13 ++ server_environment/uninstall.py | 98 ++++++++++++ 4 files changed, 256 insertions(+) create mode 100644 server_environment/uninstall.py diff --git a/server_environment/__init__.py b/server_environment/__init__.py index 52bc6e60..85c79f6f 100644 --- a/server_environment/__init__.py +++ b/server_environment/__init__.py @@ -1,3 +1,4 @@ from . import models from . import server_env from .server_env import serv_config, setboolean +from . import uninstall diff --git a/server_environment/readme/USAGE.rst b/server_environment/readme/USAGE.rst index deeea866..df1652da 100644 --- a/server_environment/readme/USAGE.rst +++ b/server_environment/readme/USAGE.rst @@ -22,3 +22,147 @@ If you want to have a technical name to reference:: _inherit = ["storage.backend", "server.env.techname.mixin"] [...] + +## Restoring columns on uninstall + +When `server.env.mixin` is bound to an existing model, the ORM drops the +original stored columns for all env-managed fields. If the binding addon is +later uninstalled, those columns must be recreated so the database remains +usable. + +Add an `uninstall_hook` to your addon and delegate to +`restore_env_managed_columns`: + + # your_addon/__init__.py + from ./hooks import uninstall_hook + # your_addon/hooks.py + from odoo.addons.server_environment import uninstall + + def uninstall_hook(env): + uninstall.restore_env_managed_columns( + env, + "storage.backend", + ["directory_path", "other_field"], + ) + + # your_addon/__manifest__.py + { + ... + "uninstall_hook": "uninstall_hook", + } + +The helper creates any missing columns (idempotent: safe to call multiple +times) and repopulates them with each record's current effective value — +whether that value came from an environment configuration file or from the +stored default field (`x__env_default`). + +The hook must run *before* the ORM extensions are removed, which is guaranteed +by Odoo's uninstall sequence (hooks execute before `Module.module_uninstall()`). + +### Handling required fields + +If a restored column is **required** (has a `NOT NULL` constraint) but has no +effective value (missing from environment config and no default field set), the +restoration will fail with a `UserError`. + +**Solution:** pass a `field_defaults` dictionary with fallback values: + + def uninstall_hook(env): + restore_env_managed_columns( + env, + "ir.mail_server", + ["smtp_host", "smtp_authentication"], + field_defaults={ + "smtp_authentication": "login", # fallback for required field + }, + ) + +The helper will use the fallback value if provided and the computed field value +is empty. If no fallback is provided but a required field has no value, a +`UserError` is raised with instructions on how to provide a `field_defaults` +parameter. + +## Migrating when dropping server_environment dependency + +When refactoring an existing addon that embeds a `server.env.mixin` binding, you +may want to extract the binding into a separate *glue* addon and drop the +`server_environment` dependency from the original. This keeps the base addon +lightweight while preserving server-environment features for those who install +the glue addon. + +**Pattern:** + +- **Original addon (v1)**: depends on `server_environment` and binds the mixin + directly in model code. +- **Refactored addon (v2)**: removes `server_environment` from dependencies, + removes the mixin binding and the related ORM model inheritance. +- **New glue addon** (optional, same version): depends on both `server_environment` + and the original addon v2; re-adds the mixin binding in a separate module file. + +**Migration checklist:** + +1. In the **original addon's v2 `__manifest__.py`**: + - Remove `"server_environment"` from `depends`. + - Remove the model file(s) that contained the mixin binding. + - Update `depends` to add the new glue addon *if* the base addon still needs it + (otherwise, make the glue addon optional for users who want env-binding). + +2. In the **original addon's v2 model code**: + - Delete or simplify the model class that inherited from `server.env.mixin`. + - If the model was only there for the binding, remove it entirely. + - Restore the original field definitions (not as computed fields). + +3. **Create a migration script** (if needed) to restore columns *during the addon + upgrade*, before the ORM model extensions are unloaded. Use a `@post_load` + hook or a dedicated migration script: + + # migrations/18.0.1.0.0/post-restore-columns.py + def migrate(cr, version): + # Call the restoration logic while the v1 model is still active + env = odoo.api.Environment(cr, odoo.SUPERUSER_ID, {}) + # If any field is required and may have no value in the environment, + # provide a fallback via field_defaults + restore_env_managed_columns( + env, + "storage.backend", + ["directory_path", "other_field"], + field_defaults={ + "directory_path": "/tmp", # fallback for required field + }, + ) + +4. **Create the glue addon** with the model re-inheritance: + + # your_addon_env/__init__.py + from . import models + + # your_addon_env/models/__init__.py + from . import storage_backend + + # your_addon_env/models/storage_backend.py + class StorageBackend(models.Model): + _name = "storage.backend" + _inherit = ["storage.backend", "server.env.mixin"] + + @property + def _server_env_fields(self): + return {"directory_path": {}} + + # your_addon_env/__manifest__.py + { + "name": "Storage Backend – Server Environment", + "version": "18.0.1.0.0", + "depends": ["server_environment", "storage_backend"], + "installable": True, + } + +**Key points:** + +- Column restoration must happen *during the addon upgrade* (step 3), not as an + uninstall hook, because the original model binding is still active. +- The `restore_env_managed_columns` helper is idempotent and safe to call even + if columns already exist. +- Users who do not need server environment features simply do *not* install the + glue addon—the base addon continues to work with plain database columns. +- Users who do need server environment can install both the base addon (v2+) and + the glue addon (same version) to get the binding back. diff --git a/server_environment/tests/test_server_environment.py b/server_environment/tests/test_server_environment.py index cfa6878c..63bab62d 100644 --- a/server_environment/tests/test_server_environment.py +++ b/server_environment/tests/test_server_environment.py @@ -6,6 +6,8 @@ from odoo.tools.config import config as odoo_config +from odoo.addons.server_environment.uninstall import restore_env_managed_columns + from .. import server_env from . import common @@ -61,3 +63,14 @@ def test_value_retrival(self): self.assertEqual(val, "testing") val = parser.get("external_service.ftp", "host") self.assertEqual(val, "sftp.example.com") + + def test_restore_env_managed_columns_unknown_field(self): + """Helper gracefully skips a field that doesn't exist on the model.""" + # Must not raise even when the field name doesn't exist. + restore_env_managed_columns( + self.env, "res.partner", ["__nonexistent_field_xyz__"] + ) + + def test_restore_env_managed_columns_no_fields(self): + """Helper is a no-op when given an empty field list.""" + restore_env_managed_columns(self.env, "res.partner", []) diff --git a/server_environment/uninstall.py b/server_environment/uninstall.py new file mode 100644 index 00000000..ba8a5426 --- /dev/null +++ b/server_environment/uninstall.py @@ -0,0 +1,98 @@ +# Copyright 2026 Camptocamp (https://www.camptocamp.com) +import logging + +from odoo.tools import SQL, sql + +_logger = logging.getLogger(__name__) + + +def restore_env_managed_columns(env, model_name, field_names, field_defaults=None): + """Restore database columns for fields formerly managed via server.env.mixin. + + When an addon binds ``server.env.mixin`` to an existing model, the ORM + drops the original stored columns. Call this helper from an + ``uninstall_hook`` so those columns are recreated and repopulated + with their current effective values before the addon is removed. + + The hook must run *while* the module's ORM extensions are still active + (guaranteed by Odoo's uninstall sequence: hooks execute before + ``Module.module_uninstall()``), so the env-computed fields are still + readable and their values can be written back to freshly created columns. + + The operation is idempotent: calling it multiple times will not fail. + + **Defaults:** If a restored field value is NULL/empty, the helper will + use the fallback from ``field_defaults`` (if provided) or the field's + ORM-level default (if defined). + + Note: ``field.required`` is set to False by the mixin, so we cannot detect + which fields are required. Provide explicit ``field_defaults`` for fields + that must have values. + + :param str model_name: dotted model name, e.g. ``"ir.mail_server"`` + :param field_names: iterable of field names whose columns to restore + :param dict field_defaults: optional mapping of field name to fallback + value used when restoring a column that has no effective env-computed + value, e.g. ``{"smtp_authentication": ""}`` + """ + model = env[model_name] + cr = env.cr + field_defaults = field_defaults or {} + + for field_name in field_names: + field = model._fields.get(field_name) + if field is None: + _logger.warning( + "restore_env_managed_columns: field %r not found on %s, skipping", + field_name, + model_name, + ) + continue + column_type = field.column_type + if column_type is None: + _logger.warning( + "restore_env_managed_columns: " + "field %r on %s has no SQL column type, skipping", + field_name, + model_name, + ) + continue + table = model._table + if not sql.column_exists(cr, table, field_name): + sql.create_column(cr, table, field_name, column_type[1], field.string) + _logger.info( + "restore_env_managed_columns: created column %s.%s (%s)", + table, + field_name, + column_type[1], + ) + # Repopulate every existing record with the current computed value. + # The hook runs while the ORM extensions are still active, so the + # env-computed field is still readable via the normal accessor. + for record in model.search([]): + value = record[field_name] + # The ORM returns False for NULL on non-boolean fields; map + # that back to None so psycopg2 writes a proper SQL NULL. + if value is False and field.type != "boolean": + value = None + elif value == "": + value = None + + # Try to get a default value if we have None. + # Note: field.required is False after mixin transformation, + # so we apply defaults for all None values when available. + if value is None: + if field_name in field_defaults: + value = field_defaults[field_name] + elif field_name in model.default_get([field_name]): + value = model.default_get([field_name])[field_name] + + cr.execute( + SQL( + "UPDATE %s SET %s = %s WHERE id = %s", + SQL.identifier(table), + SQL.identifier(field_name), + value, + record.id, + ) + )