Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion mail_environment/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

{
"name": "Mail configuration with server_environment",
"version": "19.0.1.1.0",
"version": "19.0.1.1.1",
Comment thread
yankinmax marked this conversation as resolved.
"category": "Tools",
"summary": "Configure mail servers with server_environment_files",
"author": "Camptocamp, Odoo Community Association (OCA)",
Expand Down
9 changes: 9 additions & 0 deletions mail_environment/migrations/19.0.1.1.1/post-migration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Copyright 2026 ACSONE SA/NV
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

from openupgradelib import openupgrade


@openupgrade.migrate()
def migrate(env, version):
env["ir.mail_server"]._preserve_not_env_managed_data(["smtp_authentication"])
Comment thread
yankinmax marked this conversation as resolved.
2 changes: 1 addition & 1 deletion mail_environment/static/description/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="generator" content="Docutils: https://docutils.sourceforge.io/" />
<title>README.rst</title>
<title>Mail configuration with server_environment</title>
<style type="text/css">

/*
Expand Down
2 changes: 1 addition & 1 deletion server_environment/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

{
"name": "server configuration environment files",
"version": "19.0.1.0.2",
"version": "19.0.1.0.3",
"depends": ["base", "base_sparse_field"],
"author": "Camptocamp,Odoo Community Association (OCA)",
"summary": "move some configurations out of the database",
Expand Down
44 changes: 44 additions & 0 deletions server_environment/models/server_env_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -423,3 +423,47 @@ def _post_model_setup__(self):
self._server_env_transform_field_to_read_from_env(field)
self._server_env_add_is_editable_field(field)
return super()._post_model_setup__()

@api.model
def _preserve_not_env_managed_data(self, field_name_list):
"""
Helper function typically used for hooks and migration scripts.
Restores database values for fields transitioning to 'server env managed'.

When a field is defined as managed by the server environment, Odoo
ignores the value stored in the database, prioritizing the environment
configuration instead. If no environment configuration exists, the field
may effectively lose its previous value.

This method forces to 'persist' these values if they are not
explicitly overridden by the current environment configuration.

Note: if a field is already server-env managed (ie it already has a
value stored in its ``<field>_env_default`` companion field, for
instance because the mixin was already applied to it by another
module before), its raw column may only contain stale data from
before it became a non-stored field. In that case, we must not
overwrite the existing (up to date) default value with it.
"""
self.env.cr.execute(f"SELECT * FROM {self._table}")
for row in self.env.cr.dictfetchall():
record = (
self.env[self._name]
.with_context(active_test=False)
.search([("id", "=", row["id"])])
)
if record:
record_values = {}
for field_name in field_name_list:
if field_name not in row:
continue
default_field = self._server_env_default_fieldname(field_name)
if default_field and record[default_field]:
# A value is already preserved for this field
# (eg. set by a previous server-env managed version
# of the field): keep it, do not clobber it with the
# (possibly stale) raw column value.
continue
record_values[field_name] = row[field_name]
if record_values:
record.update(record_values)
2 changes: 1 addition & 1 deletion server_environment/static/description/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="generator" content="Docutils: https://docutils.sourceforge.io/" />
<title>README.rst</title>
<title>server configuration environment files</title>
<style type="text/css">

/*
Expand Down
1 change: 1 addition & 0 deletions server_environment/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from . import test_server_environment
from . import test_server_environment_config
from . import test_environment_variable
from . import test_preserve_not_env_managed_data
22 changes: 22 additions & 0 deletions server_environment/tests/fake_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo import api, models

# pylint: disable=consider-merging-classes-inherited


class FakePartner(models.Model):
_name = "res.partner"
_inherit = ["res.partner", "server.env.mixin"]

@property
def _server_env_fields(self):
base_fields = super()._server_env_fields
partner_fields = {
"city": {},
}
partner_fields.update(base_fields)
return partner_fields

@api.model
def _server_env_global_section_name(self):
return "partner"
87 changes: 87 additions & 0 deletions server_environment/tests/test_preserve_not_env_managed_data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Copyright 2026 Camptocamp SA
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html)

from odoo.orm.model_classes import add_to_registry

from . import common


class TestPreserveNotEnvManagedData(common.ServerEnvironmentCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
from .fake_models import FakePartner

add_to_registry(cls.registry, FakePartner)
cls.registry._setup_models__(cls.env.cr, ["res.partner"])
cls.registry.init_models(cls.env.cr, ["res.partner"], {"models_to_check": True})

def setUp(self):
super().setUp()
# Deliberately not setting "city" here: writing it would already
# populate x_city_env_default through the mixin's inverse method,
# which would defeat the "no default yet" scenario below.
self.partner = self.env["res.partner"].create({"name": "Test partner"})

def _set_raw_city_column(self, value):
"""Bypass the ORM to simulate a stale/orphaned raw column value.

Once a field is taken over by the mixin it becomes non-stored, so
the ORM never reads or writes its physical column again. The column
itself is never dropped though, so it can keep holding old data from
before the field became server-env managed.
"""
self.env.cr.execute(
"UPDATE res_partner SET city = %s WHERE id = %s",
(value, self.partner.id),
)
self.partner.invalidate_recordset(["city"])

def test_preserve_rescues_value_when_no_default_yet(self):
"""Rescue the raw column value when there is no default yet.

First-time adoption: no default stored yet, so the raw column
value must be rescued into the new default field.
"""
self._set_raw_city_column("Legacy Raw City")
self.env["res.partner"]._preserve_not_env_managed_data(["city"])
self.partner.invalidate_recordset()
self.assertEqual(self.partner.x_city_env_default, "Legacy Raw City")
self.assertEqual(self.partner.city, "Legacy Raw City")

def test_preserve_does_not_overwrite_existing_default(self):
"""Do not overwrite a default value that is already set.

A default already set (e.g. because the field was already
server-env managed by another module before) must not be clobbered
by a stale raw column value.
"""
# Simulate the field having already been server-env managed: a
# legitimate, up to date default is already stored.
self.partner.write({"city": "Current Default City"})
self.assertEqual(self.partner.x_city_env_default, "Current Default City")
# The underlying (now unused) raw column still holds ancient data
# from before the field became non-stored.
self._set_raw_city_column("Ancient Stale City")
self.env["res.partner"]._preserve_not_env_managed_data(["city"])
self.partner.invalidate_recordset()
self.assertEqual(self.partner.x_city_env_default, "Current Default City")
self.assertEqual(self.partner.city, "Current Default City")

def test_preserve_ignores_unknown_column(self):
"""Fields without a matching raw column are silently skipped."""
# Must not raise even though the field name doesn't exist as a
# column on the table.
self.env["res.partner"]._preserve_not_env_managed_data(["field_not_a_column"])

def test_preserve_does_not_leak_into_env_configured_field(self):
"""Keep reading from the environment when a config key is defined.

When a config key is defined, the field must keep reading from
the environment, regardless of any raw column value.
"""
self._set_raw_city_column("Legacy Raw City")
with self.load_config(public="[partner]\ncity = From Env\n"):
self.env["res.partner"]._preserve_not_env_managed_data(["city"])
self.partner.invalidate_recordset()
self.assertEqual(self.partner.city, "From Env")
Loading