Skip to content

Commit 398d5b1

Browse files
committed
[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).
1 parent 5a2f8a2 commit 398d5b1

3 files changed

Lines changed: 256 additions & 2 deletions

File tree

server_environment/models/server_env_mixin.py

Lines changed: 106 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66

77
from lxml import etree
88

9-
from odoo import api, fields, models
10-
from odoo.tools import mute_logger
9+
from odoo import _, api, fields, models
10+
from odoo.tools import SQL, mute_logger, sql
1111

1212
from odoo.addons.base_sparse_field.models.fields import Serialized
1313

@@ -428,3 +428,107 @@ def _setup_base(self):
428428
self._server_env_transform_field_to_read_from_env(field)
429429
self._server_env_add_is_editable_field(field)
430430
return
431+
432+
@api.model
433+
def restore_env_managed_columns(self, model_name, field_names, field_defaults=None):
434+
"""Restore database columns for fields formerly managed via server.env.mixin.
435+
436+
When an addon binds ``server.env.mixin`` to an existing model, the ORM
437+
drops the original stored columns. Call this helper from an
438+
``uninstall_hook`` so those columns are recreated and repopulated
439+
with their current effective values before the addon is removed.
440+
441+
The hook must run *while* the module's ORM extensions are still active
442+
(guaranteed by Odoo's uninstall sequence: hooks execute before
443+
``Module.module_uninstall()``), so the env-computed fields are still
444+
readable and their values can be written back to freshly created columns.
445+
446+
The operation is idempotent: calling it multiple times will not fail.
447+
448+
**Required fields:** If a field is required (has a NOT NULL constraint
449+
in the database), the helper needs a value for every record. If the
450+
computed env-value is empty (no config, no default), the helper will
451+
use the fallback from ``field_defaults`` if provided. If no fallback
452+
is available, a ``UserError`` is raised explaining which field needs
453+
a value.
454+
455+
:param str model_name: dotted model name, e.g. ``"ir.mail_server"``
456+
:param field_names: iterable of field names whose columns to restore
457+
:param dict field_defaults: optional mapping of field name to fallback
458+
value used when restoring a required column that has no effective
459+
env-computed value, e.g. ``{"smtp_authentication": "<login>"}``
460+
:raises UserError: if a required column has no value and no fallback
461+
is provided in ``field_defaults``
462+
"""
463+
from odoo.exceptions import UserError
464+
465+
model = self.env[model_name]
466+
cr = self.env.cr
467+
field_defaults = field_defaults or {}
468+
469+
for field_name in field_names:
470+
field = model._fields.get(field_name)
471+
if field is None:
472+
_logger.warning(
473+
"restore_env_managed_columns: field %r not found on %s, skipping",
474+
field_name,
475+
model_name,
476+
)
477+
continue
478+
column_type = field.column_type
479+
if column_type is None:
480+
_logger.warning(
481+
"restore_env_managed_columns: "
482+
"field %r on %s has no SQL column type, skipping",
483+
field_name,
484+
model_name,
485+
)
486+
continue
487+
table = model._table
488+
if not sql.column_exists(cr, table, field_name):
489+
sql.create_column(cr, table, field_name, column_type[1], field.string)
490+
_logger.info(
491+
"restore_env_managed_columns: created column %s.%s (%s)",
492+
table,
493+
field_name,
494+
column_type[1],
495+
)
496+
# Repopulate every existing record with the current computed value.
497+
# The hook runs while the ORM extensions are still active, so the
498+
# env-computed field is still readable via the normal accessor.
499+
for record in model.search([]):
500+
value = record[field_name]
501+
# The ORM returns False for NULL on non-boolean fields; map
502+
# that back to None so psycopg2 writes a proper SQL NULL.
503+
if value is False and field.type != "boolean":
504+
if field.type in ("integer", "float", "monetary"):
505+
value = 0
506+
else:
507+
value = None
508+
509+
# Handle required (NOT NULL) columns with no value.
510+
if value is None and field.required:
511+
if field_name in field_defaults:
512+
value = field_defaults[field_name]
513+
else:
514+
raise UserError(
515+
_(
516+
"Field %(field_name)s on %(model)s is required "
517+
"but has no value. Provide a fallback in "
518+
"field_defaults parameter."
519+
)
520+
% {
521+
"field_name": field_name,
522+
"model": model_name,
523+
}
524+
)
525+
526+
cr.execute(
527+
SQL(
528+
"UPDATE %s SET %s = %s WHERE id = %s",
529+
SQL.identifier(table),
530+
SQL.identifier(field_name),
531+
value,
532+
record.id,
533+
)
534+
)

server_environment/readme/USAGE.md

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,142 @@ If you want to have a technical name to reference:
1919
_inherit = ["storage.backend", "server.env.techname.mixin"]
2020

2121
[...]
22+
23+
## Restoring columns on uninstall
24+
25+
When `server.env.mixin` is bound to an existing model, the ORM drops the
26+
original stored columns for all env-managed fields. If the binding addon is
27+
later uninstalled, those columns must be recreated so the database remains
28+
usable.
29+
30+
Add an `uninstall_hook` to your addon and delegate to
31+
`restore_env_managed_columns`:
32+
33+
# your_addon/__init__.py
34+
from . import models
35+
36+
def uninstall_hook(env):
37+
env["server.env.mixin"].restore_env_managed_columns(
38+
"storage.backend",
39+
["directory_path", "other_field"],
40+
)
41+
42+
# your_addon/__manifest__.py
43+
{
44+
...
45+
"uninstall_hook": "uninstall_hook",
46+
}
47+
48+
The helper creates any missing columns (idempotent: safe to call multiple
49+
times) and repopulates them with each record's current effective value —
50+
whether that value came from an environment configuration file or from the
51+
stored default field (`x_<field>_env_default`).
52+
53+
The hook must run *before* the ORM extensions are removed, which is guaranteed
54+
by Odoo's uninstall sequence (hooks execute before `Module.module_uninstall()`).
55+
56+
### Handling required fields
57+
58+
If a restored column is **required** (has a `NOT NULL` constraint) but has no
59+
effective value (missing from environment config and no default field set), the
60+
restoration will fail with a `UserError`.
61+
62+
**Solution:** pass a `field_defaults` dictionary with fallback values:
63+
64+
def uninstall_hook(env):
65+
env["server.env.mixin"].restore_env_managed_columns(
66+
"ir.mail_server",
67+
["smtp_host", "smtp_authentication"],
68+
field_defaults={
69+
"smtp_authentication": "login", # fallback for required field
70+
},
71+
)
72+
73+
The helper will use the fallback value if provided and the computed field value
74+
is empty. If no fallback is provided but a required field has no value, a
75+
`UserError` is raised with instructions on how to provide a `field_defaults`
76+
parameter.
77+
78+
## Migrating when dropping server_environment dependency
79+
80+
When refactoring an existing addon that embeds a `server.env.mixin` binding, you
81+
may want to extract the binding into a separate *glue* addon and drop the
82+
`server_environment` dependency from the original. This keeps the base addon
83+
lightweight while preserving server-environment features for those who install
84+
the glue addon.
85+
86+
**Pattern:**
87+
88+
- **Original addon (v1)**: depends on `server_environment` and binds the mixin
89+
directly in model code.
90+
- **Refactored addon (v2)**: removes `server_environment` from dependencies,
91+
removes the mixin binding and the related ORM model inheritance.
92+
- **New glue addon** (optional, same version): depends on both `server_environment`
93+
and the original addon v2; re-adds the mixin binding in a separate module file.
94+
95+
**Migration checklist:**
96+
97+
1. In the **original addon's v2 `__manifest__.py`**:
98+
- Remove `"server_environment"` from `depends`.
99+
- Remove the model file(s) that contained the mixin binding.
100+
- Update `depends` to add the new glue addon *if* the base addon still needs it
101+
(otherwise, make the glue addon optional for users who want env-binding).
102+
103+
2. In the **original addon's v2 model code**:
104+
- Delete or simplify the model class that inherited from `server.env.mixin`.
105+
- If the model was only there for the binding, remove it entirely.
106+
- Restore the original field definitions (not as computed fields).
107+
108+
3. **Create a migration script** (if needed) to restore columns *during the addon
109+
upgrade*, before the ORM model extensions are unloaded. Use a `@post_load`
110+
hook or a dedicated migration script:
111+
112+
# migrations/18.0.1.0.0/post-restore-columns.py
113+
def migrate(cr, version):
114+
# Call the restoration logic while the v1 model is still active
115+
env = odoo.api.Environment(cr, odoo.SUPERUSER_ID, {})
116+
# If any field is required and may have no value in the environment,
117+
# provide a fallback via field_defaults
118+
env["server.env.mixin"].restore_env_managed_columns(
119+
"storage.backend",
120+
["directory_path", "other_field"],
121+
field_defaults={
122+
"directory_path": "/tmp", # fallback for required field
123+
},
124+
)
125+
126+
4. **Create the glue addon** with the model re-inheritance:
127+
128+
# your_addon_env/__init__.py
129+
from . import models
130+
131+
# your_addon_env/models/__init__.py
132+
from . import storage_backend
133+
134+
# your_addon_env/models/storage_backend.py
135+
class StorageBackend(models.Model):
136+
_name = "storage.backend"
137+
_inherit = ["storage.backend", "server.env.mixin"]
138+
139+
@property
140+
def _server_env_fields(self):
141+
return {"directory_path": {}}
142+
143+
# your_addon_env/__manifest__.py
144+
{
145+
"name": "Storage Backend – Server Environment",
146+
"version": "18.0.1.0.0",
147+
"depends": ["server_environment", "storage_backend"],
148+
"installable": True,
149+
}
150+
151+
**Key points:**
152+
153+
- Column restoration must happen *during the addon upgrade* (step 3), not as an
154+
uninstall hook, because the original model binding is still active.
155+
- The `restore_env_managed_columns` helper is idempotent and safe to call even
156+
if columns already exist.
157+
- Users who do not need server environment features simply do *not* install the
158+
glue addon—the base addon continues to work with plain database columns.
159+
- Users who do need server environment can install both the base addon (v2+) and
160+
the glue addon (same version) to get the binding back.

server_environment/tests/test_server_environment.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,3 +146,14 @@ def test_server_environment_disabled_overwrite_options_section_by_env(self):
146146
with self.set_config_dir("testfiles"):
147147
server_env._load_config()
148148
self.assertEqual(odoo_config["odoo_test_option"], "fake odoo config")
149+
150+
def test_restore_env_managed_columns_unknown_field(self):
151+
"""Helper gracefully skips a field that doesn't exist on the model."""
152+
# Must not raise even when the field name doesn't exist.
153+
self.env["server.env.mixin"].restore_env_managed_columns(
154+
"res.partner", ["__nonexistent_field_xyz__"]
155+
)
156+
157+
def test_restore_env_managed_columns_no_fields(self):
158+
"""Helper is a no-op when given an empty field list."""
159+
self.env["server.env.mixin"].restore_env_managed_columns("res.partner", [])

0 commit comments

Comments
 (0)