Skip to content
Open
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
4 changes: 3 additions & 1 deletion deltatech_transport_change/__manifest__.py
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -9,6 +9,7 @@
"depends": [
"base",
"mail",
"deltatech_test_system",
],
"external_dependencies": {
"python": ["GitPython"],
Expand All @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions deltatech_transport_change/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
80 changes: 80 additions & 0 deletions deltatech_transport_change/models/config_lock_guard.py
Original file line number Diff line number Diff line change
@@ -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
48 changes: 48 additions & 0 deletions deltatech_transport_change/models/ir_model.py
Original file line number Diff line number Diff line change
@@ -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
24 changes: 24 additions & 0 deletions deltatech_transport_change/views/ir_model_view.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<odoo>
<!-- Add a checkbox on ir.model to mark configuration models -->
<record id="view_ir_model_form_transport_flag" model="ir.ui.view">
<field name="name">ir.model.form.configuration.flag</field>
<field name="model">ir.model</field>
<field name="inherit_id" ref="base.view_model_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='transient']" position="after">
<field name="is_config_model" groups="base.group_system"/>
</xpath>
</field>
</record>

<record id="view_ir_model_tree_transport_flag" model="ir.ui.view">
<field name="name">ir.model.tree.configuration.flag</field>
<field name="model">ir.model</field>
<field name="inherit_id" ref="base.view_model_tree"/>
<field name="arch" type="xml">
<xpath expr="//list" position="inside">
<field name="is_config_model" optional="hide" groups="base.group_system"/>
</xpath>
</field>
</record>
</odoo>
Loading