diff --git a/deltatech_transport_change/__manifest__.py b/deltatech_transport_change/__manifest__.py index 384ef9c77..99bef744d 100644 --- a/deltatech_transport_change/__manifest__.py +++ b/deltatech_transport_change/__manifest__.py @@ -1,6 +1,6 @@ { "name": "DeltaTech Transport Change", - "version": "19.0.0.1.4", + "version": "19.0.0.1.8", "license": "LGPL-3", "category": "Technical", "summary": "Export configuration changes to CSV and manage transport through Git", @@ -9,6 +9,7 @@ "depends": [ "base", "mail", + "deltatech_test_system", ], "external_dependencies": { "python": ["GitPython"], @@ -17,6 +18,7 @@ "security/ir.model.access.csv", "views/transport_config_views.xml", "views/transport_repo_views.xml", + "views/ir_model_view.xml", ], "installable": True, "application": False, diff --git a/deltatech_transport_change/models/__init__.py b/deltatech_transport_change/models/__init__.py index 6d8fc04af..b2c3f4e23 100644 --- a/deltatech_transport_change/models/__init__.py +++ b/deltatech_transport_change/models/__init__.py @@ -6,3 +6,5 @@ from . import transport_config from . import transport_repo from . import transport_utils +from . import config_lock_guard +from . import ir_model diff --git a/deltatech_transport_change/models/config_lock_guard.py b/deltatech_transport_change/models/config_lock_guard.py new file mode 100644 index 000000000..209270778 --- /dev/null +++ b/deltatech_transport_change/models/config_lock_guard.py @@ -0,0 +1,80 @@ +# © 2025 Deltatech / Terrabit +# Global guard to block create/write/unlink on selected configuration models in production + +import logging +from functools import wraps + +from odoo import models, tools +from odoo.tools import str2bool + +_logger = logging.getLogger(__name__) + +_PARAM_LOCK_ENABLED = "deltatech_transport_change.config_lock_enabled" + + +class ConfigLockGuard(models.AbstractModel): + _name = "config.lock.guard" + _description = "Configuration Models Lock Guard" + + @tools.ormcache() + def _protected_model_names(self): + """Cached set of model technical names flagged as configuration models.""" + names = self.env["ir.model"].sudo().search([("is_config_model", "=", True)]).mapped("model") + # Return an immutable for hashing across workers + return frozenset(names) + + def _should_block(self, model_name): + """Return True if ops on model_name should be blocked for current user. + Conditions: + - NOT neutralized (production): ir.config_parameter('database.is_neutralized') is False + - Model is flagged on ir.model (is_config_model = True) + - Current user is not superuser (and not bypass, if enabled later) + Additionally, guard is disabled during module install/update to allow data loading. + """ + try: + # Fast-path exits + if self.env.user._is_superuser(): + return False + # Allow during module install/update and data loading + ctx = self.env.context or {} + if ctx.get("install_mode") or ctx.get("module") or ctx.get("loading_modules"): + return False + + # If model is not marked as protected, skip + if model_name not in self._protected_model_names(): + return False + + # Only block in production (non-neutralized) + icp = self.env["ir.config_parameter"].sudo() + neutral = icp.get_param("database.is_neutralized", default=False) + if str2bool(str(neutral)): + return False + + return True + except Exception as e: # Never break core ops if guard fails; just log and allow + _logger.exception("[ConfigLock] Guard check failed: %s", e) + return False + + def _register_hook(self): + # Patch BaseModel methods once per registry load + Base = models.BaseModel + for method_name in ("create", "write", "unlink"): + original = getattr(Base, method_name) + # Prevent double wrapping + if getattr(original, "_config_lock_wrapped", False): # pragma: no cover + continue + wrapped = self._wrap_method(original) + wrapped._config_lock_wrapped = True # type: ignore[attr-defined] + setattr(Base, method_name, wrapped) + return super()._register_hook() + + def _wrap_method(self, original): + @wraps(original) + def wrapper(recordset, *args, **kwargs): + if self._should_block(recordset._name): + from odoo.exceptions import UserError + + raise UserError("This model is protected in production. Direct create/write/delete is not allowed.") + return original(recordset, *args, **kwargs) + + return wrapper diff --git a/deltatech_transport_change/models/ir_model.py b/deltatech_transport_change/models/ir_model.py new file mode 100644 index 000000000..affb5dc22 --- /dev/null +++ b/deltatech_transport_change/models/ir_model.py @@ -0,0 +1,48 @@ +# © 2025 Deltatech / Terrabit +# Extend ir.model to mark configuration models + +from odoo import fields, models + + +class IrModel(models.Model): + _inherit = "ir.model" + + is_config_model = fields.Boolean( + string="Configuration Model", + help=( + "If enabled, this model is considered a configuration model. " + "When the database is NOT neutralized (production), direct create/write/delete " + "operations can be blocked by deltatech_transport_change according to the guard rules." + ), + ) + + def _clear_guard_cache(self): + # Clear the ormcache for protected model names + try: + self.env["config.lock.guard"]._protected_model_names.clear_cache(self.env["config.lock.guard"]) + except Exception: + # Best effort; do not block admin actions + pass + + def write(self, vals): + res = super().write(vals) + if "is_config_model" in vals: + self._clear_guard_cache() + return res + + def create(self, vals_list): + records = super().create(vals_list) + # New models might be flagged as configuration + if isinstance(vals_list, dict): + vals_iter = [vals_list] + else: + vals_iter = vals_list + if any("is_config_model" in v for v in vals_iter): + self._clear_guard_cache() + return records + + def unlink(self): + res = super().unlink() + # Model set changed; clear cache + self._clear_guard_cache() + return res diff --git a/deltatech_transport_change/views/ir_model_view.xml b/deltatech_transport_change/views/ir_model_view.xml new file mode 100644 index 000000000..93d99a157 --- /dev/null +++ b/deltatech_transport_change/views/ir_model_view.xml @@ -0,0 +1,24 @@ + + + + ir.model.form.configuration.flag + ir.model + + + + + + + + + + ir.model.tree.configuration.flag + ir.model + + + + + + + +