From 40c19b8b2e35322a8c25b9f8d07dfdaddc2a8a30 Mon Sep 17 00:00:00 2001 From: midhlaj-nk Date: Thu, 18 Jun 2026 10:43:49 +0530 Subject: [PATCH] [18.0][FIX] auditlog: prevent recursion on re-entrant write from compute/inverse write_full and write_fast set auditlog_disabled on a rebound local self via with_context(), but compute/inverse methods triggered by the original write (e.g. AccountAccount._compute_code / _inverse_code) call write() on the model class using their own self, which does not carry the modified context. This causes write_full/write_fast to re-enter indefinitely, hitting Python's recursion limit. Add an early-exit guard at the top of both closures: if auditlog_disabled is already in context, delegate immediately to the origin write and return. Closes #3512 --- auditlog/models/rule.py | 4 ++ auditlog/tests/test_auditlog.py | 71 +++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/auditlog/models/rule.py b/auditlog/models/rule.py index 7e914e7a7d5..b0f75155334 100644 --- a/auditlog/models/rule.py +++ b/auditlog/models/rule.py @@ -442,6 +442,8 @@ def _make_write(self): users_to_exclude = self.mapped("users_to_exclude_ids") def write_full(self, vals, **kwargs): + if self.env.context.get("auditlog_disabled"): + return write_full.origin(self, vals, **kwargs) self = self.with_context(auditlog_disabled=True) rule_model = self.env["auditlog.rule"] fields_list = rule_model.get_auditlog_fields(self) @@ -478,6 +480,8 @@ def write_full(self, vals, **kwargs): return result def write_fast(self, vals, **kwargs): + if self.env.context.get("auditlog_disabled"): + return write_fast.origin(self, vals, **kwargs) self = self.with_context(auditlog_disabled=True) rule_model = self.env["auditlog.rule"] # Log the user input only, no matter if the `vals` is updated diff --git a/auditlog/tests/test_auditlog.py b/auditlog/tests/test_auditlog.py index ecee5108f81..621da5c8aba 100644 --- a/auditlog/tests/test_auditlog.py +++ b/auditlog/tests/test_auditlog.py @@ -799,3 +799,74 @@ def test_01_AuditlogFast_field_exclude_write_log(self): ] ) ) + + +class TestAuditlogRecursion(AuditLogRuleCommon): + """Verify that re-entrant write calls caused by compute/inverse methods + do not produce a RecursionError when auditlog is active.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner_model_id = cls.env.ref("base.model_res_partner").id + cls.partner_rule_full = cls.env["auditlog.rule"].create( + { + "name": "testrule for partner full", + "model_id": cls.partner_model_id, + "log_create": True, + "log_write": True, + "log_type": "full", + } + ) + cls.partner_rule_fast = cls.env["auditlog.rule"].create( + { + "name": "testrule for partner fast", + "model_id": cls.partner_model_id, + "log_create": True, + "log_write": True, + "log_type": "fast", + } + ) + + def _make_company_with_child(self): + company = self.env["res.partner"].create( + {"name": "Test Company", "is_company": True, "street": "1 Old St"} + ) + child = self.env["res.partner"].create( + {"name": "Test Contact", "parent_id": company.id, "type": "contact"} + ) + return company, child + + def test_no_recursion_write_full_reentrant(self): + """write_full must not recurse when auditlog_disabled is already set. + + Simulate a compute/inverse method calling write() on an audited model + while already inside an auditlog write call (auditlog_disabled=True). + """ + self.partner_rule_full.subscribe() + company, child = self._make_company_with_child() + # Writing company address triggers _fields_sync which writes to child. + # Both are audited — without the guard this re-enters write_full. + company.write({"street": "2 New St"}) + self.assertEqual(child.street, "2 New St") + + def test_no_recursion_write_fast_reentrant(self): + """write_fast must not recurse when auditlog_disabled is already set.""" + self.partner_rule_fast.subscribe() + company, child = self._make_company_with_child() + company.write({"street": "3 Fast St"}) + self.assertEqual(child.street, "3 Fast St") + + def test_write_full_guard_skips_logging(self): + """write_full early-exit must skip logging when auditlog_disabled is set.""" + self.partner_rule_full.subscribe() + partner = self.env["res.partner"].create({"name": "Guard Test Partner"}) + log_count_before = self.env["auditlog.log"].search_count( + [("model_id", "=", self.partner_model_id), ("res_id", "=", partner.id)] + ) + partner.with_context(auditlog_disabled=True).write({"name": "Guard Updated"}) + log_count_after = self.env["auditlog.log"].search_count( + [("model_id", "=", self.partner_model_id), ("res_id", "=", partner.id)] + ) + self.assertEqual(log_count_before, log_count_after) + self.assertEqual(partner.name, "Guard Updated")