From 655e321572d6fd6f8583a195f98ee4915afc3402 Mon Sep 17 00:00:00 2001 From: emjay0921 Date: Mon, 10 Aug 2026 14:02:23 +0800 Subject: [PATCH 1/3] fix(starter_sp_mis): make registry access control persist and enforce The "Restrict Registry Edits to Admin Only" setting could not be turned off, and did not restrict anything when on. Odoo stores a False config_parameter by deleting the row, and default_get falls back to the field default when the row is missing, so default=True made "off" unrepresentable: the toggle sprang back on at every reload. The enforcement side read a missing row as False, so unticking the box in fact disabled the restriction while the form went on claiming the registry was locked. Persist the value explicitly as a string and take the install default from the data file, which is now noupdate so an upgrade stops re-locking a registry an administrator deliberately opened. Enforcement was client-side only: JavaScript hid buttons with display:none and nothing on the server refused the write, so RPC and import went straight through. The list "New" button was never hidden either, because the patch assigned a canCreate property that Odoo 19's ListController does not read - its template gates on activeActions.create. Replace all of it with a _check_access override on res.partner. That is the single chokepoint behind check_access, has_access and the ORM's own create/write/unlink guards, so one override refuses the change on every path and removes New/Edit/Delete from registry views for free: ir.ui.view._postprocess_access_rights stamps create="false" onto an arch whenever has_access('create') is False. Scoped to registrants so the setting cannot lock the Contacts app. The demo access-control tests assert the plain role model and were only passing because the restriction did nothing, so they now pin the switch off explicitly. --- spp_mis_demo_v2/tests/test_access_control.py | 7 + spp_starter_sp_mis/__init__.py | 1 - spp_starter_sp_mis/__manifest__.py | 5 - spp_starter_sp_mis/controllers/__init__.py | 2 - spp_starter_sp_mis/controllers/main.py | 13 - spp_starter_sp_mis/data/config_parameters.xml | 18 +- spp_starter_sp_mis/models/__init__.py | 1 + .../models/res_config_settings.py | 28 +- spp_starter_sp_mis/models/res_partner.py | 73 +++++ .../static/src/js/registry_restriction.js | 260 ------------------ spp_starter_sp_mis/tests/__init__.py | 1 + .../tests/test_registry_restriction.py | 237 ++++++++++++++++ 12 files changed, 358 insertions(+), 288 deletions(-) delete mode 100644 spp_starter_sp_mis/controllers/__init__.py delete mode 100644 spp_starter_sp_mis/controllers/main.py create mode 100644 spp_starter_sp_mis/models/res_partner.py delete mode 100644 spp_starter_sp_mis/static/src/js/registry_restriction.js create mode 100644 spp_starter_sp_mis/tests/test_registry_restriction.py diff --git a/spp_mis_demo_v2/tests/test_access_control.py b/spp_mis_demo_v2/tests/test_access_control.py index fc31ed59c..ed9bbf5b6 100644 --- a/spp_mis_demo_v2/tests/test_access_control.py +++ b/spp_mis_demo_v2/tests/test_access_control.py @@ -82,6 +82,13 @@ def setUpClass(cls): """Set up test users with different roles.""" super().setUpClass() + # These tests measure the role-based ACLs, so pin the SP-MIS registry + # access-control switch off: this bundle ships it on, and it withholds + # registrant create/write/unlink from everyone but admins regardless of + # role. It used to be a no-op on the server, which is why these tests + # never had to say so (OP#1142). + cls.env["ir.config_parameter"].sudo().set_param("spp_starter.registry_admin_only_crud", "False") + # Check which modules are installed cls.grm_installed = bool(_safe_ref(cls.env, "spp_grm.group_grm_viewer")) cls.case_installed = bool(_safe_ref(cls.env, "spp_case_base.group_case_viewer")) diff --git a/spp_starter_sp_mis/__init__.py b/spp_starter_sp_mis/__init__.py index 8042e9421..d33610325 100644 --- a/spp_starter_sp_mis/__init__.py +++ b/spp_starter_sp_mis/__init__.py @@ -1,3 +1,2 @@ # Part of OpenSPP. See LICENSE file for full copyright and licensing details. -from . import controllers from . import models diff --git a/spp_starter_sp_mis/__manifest__.py b/spp_starter_sp_mis/__manifest__.py index 254a020a8..d428fa8d1 100644 --- a/spp_starter_sp_mis/__manifest__.py +++ b/spp_starter_sp_mis/__manifest__.py @@ -24,11 +24,6 @@ "data/config_parameters.xml", "views/res_config_settings_views.xml", ], - "assets": { - "web.assets_backend": [ - "spp_starter_sp_mis/static/src/js/registry_restriction.js", - ], - }, "demo": [], "images": [], "application": False, diff --git a/spp_starter_sp_mis/controllers/__init__.py b/spp_starter_sp_mis/controllers/__init__.py deleted file mode 100644 index 972165815..000000000 --- a/spp_starter_sp_mis/controllers/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# Part of OpenSPP. See LICENSE file for full copyright and licensing details. -from . import main diff --git a/spp_starter_sp_mis/controllers/main.py b/spp_starter_sp_mis/controllers/main.py deleted file mode 100644 index ca73594fb..000000000 --- a/spp_starter_sp_mis/controllers/main.py +++ /dev/null @@ -1,13 +0,0 @@ -# Part of OpenSPP. See LICENSE file for full copyright and licensing details. - -from odoo import http -from odoo.http import request - - -class SPMISController(http.Controller): - @http.route("/spp_starter_sp_mis/registry_restriction", type="jsonrpc", auth="user") - def get_registry_restriction(self): - """Return whether registry CRUD is restricted to admin only.""" - # nosemgrep: odoo-sudo-without-context - value = request.env["ir.config_parameter"].sudo().get_param("spp_starter.registry_admin_only_crud", "False") - return {"restricted": value == "True"} diff --git a/spp_starter_sp_mis/data/config_parameters.xml b/spp_starter_sp_mis/data/config_parameters.xml index 346b54d85..04a4fee7a 100644 --- a/spp_starter_sp_mis/data/config_parameters.xml +++ b/spp_starter_sp_mis/data/config_parameters.xml @@ -9,9 +9,17 @@ sp_mis - - - spp_starter.registry_admin_only_crud - True - + + + + spp_starter.registry_admin_only_crud + True + + diff --git a/spp_starter_sp_mis/models/__init__.py b/spp_starter_sp_mis/models/__init__.py index cdb421fac..db6e0091d 100644 --- a/spp_starter_sp_mis/models/__init__.py +++ b/spp_starter_sp_mis/models/__init__.py @@ -1,2 +1,3 @@ # Part of OpenSPP. See LICENSE file for full copyright and licensing details. from . import res_config_settings +from . import res_partner diff --git a/spp_starter_sp_mis/models/res_config_settings.py b/spp_starter_sp_mis/models/res_config_settings.py index 86a6520d8..aa46668d5 100644 --- a/spp_starter_sp_mis/models/res_config_settings.py +++ b/spp_starter_sp_mis/models/res_config_settings.py @@ -2,6 +2,10 @@ from odoo import fields, models +# Storage key for the registry access-control setting. Imported by res_partner +# so the toggle and its enforcement can never drift apart. +REGISTRY_ADMIN_ONLY_CRUD_PARAM = "spp_starter.registry_admin_only_crud" + class ResConfigSettings(models.TransientModel): _inherit = "res.config.settings" @@ -12,6 +16,26 @@ class ResConfigSettings(models.TransientModel): "Only administrators can add, modify, or remove registrants. " "Other users can still view all registry data but cannot make changes." ), - default=True, - config_parameter="spp_starter.registry_admin_only_crud", + config_parameter=REGISTRY_ADMIN_ONLY_CRUD_PARAM, ) + + def set_values(self): + """Persist the toggle explicitly, including when it is off (OP#1142). + + Odoo stores a False ``config_parameter`` by *deleting* the row, and + ``default_get`` falls back to the field's ``default`` when the row is + missing. Pairing that with ``default=True`` made "off" unrepresentable: + the toggle sprang back on at every reload, while enforcement — which + reads a missing row as False — quietly went unrestricted, so the form + claimed the registry was locked when it was open. + + Writing the value as a string keeps "off" a stored fact rather than an + absence, which is what makes the two sides agree. The install default + now comes from ``data/config_parameters.xml`` instead of a field + default, so a missing row can no longer mean "on". + """ + super().set_values() + self.env["ir.config_parameter"].sudo().set_param( + REGISTRY_ADMIN_ONLY_CRUD_PARAM, + "True" if self.is_registry_admin_only_crud else "False", + ) diff --git a/spp_starter_sp_mis/models/res_partner.py b/spp_starter_sp_mis/models/res_partner.py new file mode 100644 index 000000000..23e56f041 --- /dev/null +++ b/spp_starter_sp_mis/models/res_partner.py @@ -0,0 +1,73 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from odoo import _, models +from odoo.exceptions import AccessError + +from .res_config_settings import REGISTRY_ADMIN_ONLY_CRUD_PARAM + +ADMIN_GROUP = "spp_security.group_spp_admin" + +# Operations the setting withholds from non-admins. ``read`` is deliberately +# absent: the whole point is that everyone keeps visibility of the registry. +RESTRICTED_OPERATIONS = ("create", "write", "unlink") + + +class ResPartner(models.Model): + _inherit = "res.partner" + + def _is_registry_crud_restricted(self): + """Whether registry changes are currently withheld from this user.""" + if self.env.user.has_group(ADMIN_GROUP): + return False + param = self.env["ir.config_parameter"].sudo().get_param(REGISTRY_ADMIN_ONLY_CRUD_PARAM, "False") + return param == "True" + + def _make_registry_access_error(self): + return AccessError( + _( + "Registry records are restricted to administrators. Ask an administrator to make " + "this change, or turn off 'Restrict Registry Edits to Admin Only' in SP-MIS Settings." + ) + ) + + def _check_access(self, operation): + """Withhold registrant create/write/unlink from non-admins (OP#1142). + + This is the single chokepoint behind ``check_access``, ``has_access`` + and the ORM's own create/write/unlink guards, so one override both + refuses the change — over RPC and import as much as through the UI — + and takes New/Edit/Delete off registry views for free, because + ``ir.ui.view._postprocess_access_rights`` stamps ``create="false"`` onto + an arch whenever ``has_access('create')`` comes back False. That is what + actually removes the button; the previous JavaScript patch assigned a + ``canCreate`` property Odoo 19's ListController never reads. + + Enforcement is scoped to registrants so the setting cannot lock the + whole Contacts app. On a populated recordset that is a plain filter, and + it is the check the ORM applies to real writes — including the + post-create pass — so it holds regardless of how the call arrives. + + The empty recordset is the model-level probe, used by views and by + ``create()`` before any record exists. There is nothing to filter there, + so registrant intent is read from the context both registry actions + carry. Note that ``get_view`` documents its result as depending only on + access rights and a few context keys, so should that ever stop flowing, + the button reappears but the refusal above still stands — the failure + mode is cosmetic, not a loss of enforcement. + """ + result = super()._check_access(operation) + if result is not None or operation not in RESTRICTED_OPERATIONS: + return result + if not self._is_registry_crud_restricted(): + return None + + if self: + forbidden = self.browse(self.sudo().filtered("is_registrant").ids) + if not forbidden: + return None + elif self.env.context.get("default_is_registrant"): + forbidden = self + else: + return None + + return forbidden, self._make_registry_access_error diff --git a/spp_starter_sp_mis/static/src/js/registry_restriction.js b/spp_starter_sp_mis/static/src/js/registry_restriction.js deleted file mode 100644 index 9f41fe02d..000000000 --- a/spp_starter_sp_mis/static/src/js/registry_restriction.js +++ /dev/null @@ -1,260 +0,0 @@ -/** @odoo-module **/ - -/** - * Registry Access Restriction - * - * When the config setting "Restrict Registry Edits to Admin Only" is enabled, - * this patch enforces read-only mode on res.partner views for non-admin users. - * - * The restriction check is cached and resolved before the form/list setup - * so that modelParams can return mode: "readonly" before the model is created. - */ - -import {FormController} from "@web/views/form/form_controller"; -import {ListController} from "@web/views/list/list_controller"; -import {patch} from "@web/core/utils/patch"; -import {user} from "@web/core/user"; -import {rpc} from "@web/core/network/rpc"; -import {onMounted, onPatched, onWillStart, onWillUnmount} from "@odoo/owl"; - -// Models affected by the registry restriction -const REGISTRY_MODELS = ["res.partner"]; - -// Cache the restriction check to avoid repeated RPC calls within the same session. -// Resolved once at module load time so it's available synchronously in setup(). -let _restrictionResult = null; -let _restrictionPromise = null; - -function fetchRestriction() { - if (_restrictionPromise) { - return _restrictionPromise; - } - _restrictionPromise = (async () => { - try { - const isAdmin = await user.hasGroup("spp_security.group_spp_admin"); - if (isAdmin) { - _restrictionResult = false; - return false; - } - const result = await rpc("/spp_starter_sp_mis/registry_restriction", {}); - _restrictionResult = result.restricted === true; - } catch { - _restrictionResult = false; - } - // Reset promise after TTL so it re-fetches - setTimeout(() => { - _restrictionPromise = null; - }, 30000); - return _restrictionResult; - })(); - return _restrictionPromise; -} - -// Eagerly fetch at module load time so the result is available -// synchronously when the first FormController.setup() runs. -fetchRestriction(); - -// Actions to strip from the action menu when restricted -const BLOCKED_ACTIONS = ["delete", "archive", "unarchive", "duplicate"]; - -/** - * Patch FormController to enforce read-only on registry forms when restricted. - * - * Key insight: Odoo 19 creates the model in setup() using get modelParams(), - * which sets mode to "edit" by default. We must override modelParams to return - * mode: "readonly" BEFORE the model is created. Since setup() is synchronous, - * we resolve the restriction check in onWillStart of the first controller - * instance, and use the cached result for subsequent instances. - */ -patch(FormController.prototype, { - setup() { - const modelName = this.props.resModel; - // Check synchronous cache BEFORE super.setup() creates the model - this._registryRestricted = - REGISTRY_MODELS.includes(modelName) && _restrictionResult === true; - - super.setup(...arguments); - - if (!REGISTRY_MODELS.includes(modelName)) { - return; - } - - this._restrictionObserver = null; - - onWillStart(async () => { - // Ensure restriction is resolved (first load or cache expired) - const restricted = await fetchRestriction(); - this._registryRestricted = restricted; - - if (this._registryRestricted) { - this.canEdit = false; - this.canCreate = false; - } - }); - - const enforceRestriction = () => { - if (!this._registryRestricted) return; - - const rootEl = this.rootRef?.el; - if (!rootEl) return; - - const container = - rootEl.closest(".o_action") || - rootEl.closest(".o_dialog") || - document.body; - - const applyRestriction = () => { - // Force form into readonly CSS state - const formView = container.querySelector(".o_form_view"); - if (formView) { - formView.classList.remove("o_form_editable"); - formView.classList.add("o_form_readonly"); - } - - // Hide buttons that shouldn't be visible - const selectors = [ - ".o_form_button_create", - ".o_form_button_save", - ".o_form_button_cancel", - ".o_statusbar_buttons", - ]; - - for (const selector of selectors) { - container.querySelectorAll(selector).forEach((el) => { - el.style.display = "none"; - }); - } - }; - - applyRestriction(); - setTimeout(applyRestriction, 100); - setTimeout(applyRestriction, 300); - - if (!this._restrictionObserver) { - this._restrictionObserver = new MutationObserver(applyRestriction); - this._restrictionObserver.observe(container, { - childList: true, - subtree: true, - }); - } - }; - - onMounted(enforceRestriction); - onPatched(enforceRestriction); - - onWillUnmount(() => { - if (this._restrictionObserver) { - this._restrictionObserver.disconnect(); - this._restrictionObserver = null; - } - }); - }, - - get modelParams() { - const params = super.modelParams; - // Force readonly mode when registry is restricted - if (this._registryRestricted) { - params.config.mode = "readonly"; - } - return params; - }, - - get actionMenuItems() { - const menuItems = super.actionMenuItems; - - if (this._registryRestricted && REGISTRY_MODELS.includes(this.props.resModel)) { - if (menuItems.action) { - menuItems.action = menuItems.action.filter( - (item) => !BLOCKED_ACTIONS.includes(item.key) - ); - } - } - - return menuItems; - }, -}); - -/** - * Patch ListController to hide Create button on registry lists when restricted. - */ -patch(ListController.prototype, { - setup() { - super.setup(...arguments); - - const modelName = this.props.resModel; - if (!REGISTRY_MODELS.includes(modelName)) { - return; - } - - this._registryRestricted = false; - this._restrictionObserver = null; - - onWillStart(async () => { - this._registryRestricted = await fetchRestriction(); - if (this._registryRestricted) { - this.canCreate = false; - } - }); - - const enforceRestriction = () => { - if (!this._registryRestricted) return; - - const rootEl = this.rootRef?.el; - if (!rootEl) return; - - const container = - rootEl.closest(".o_action") || - rootEl.closest(".o_dialog") || - document.body; - - const hideButtons = () => { - container.querySelectorAll(".o_list_button_add").forEach((el) => { - el.style.display = "none"; - }); - container - .querySelectorAll(".o_cp_buttons .btn-primary") - .forEach((el) => { - if (el.textContent.trim() === "New") { - el.style.display = "none"; - } - }); - }; - - hideButtons(); - setTimeout(hideButtons, 100); - setTimeout(hideButtons, 300); - - if (!this._restrictionObserver) { - this._restrictionObserver = new MutationObserver(hideButtons); - this._restrictionObserver.observe(container, { - childList: true, - subtree: true, - }); - } - }; - - onMounted(enforceRestriction); - onPatched(enforceRestriction); - - onWillUnmount(() => { - if (this._restrictionObserver) { - this._restrictionObserver.disconnect(); - this._restrictionObserver = null; - } - }); - }, - - get actionMenuItems() { - const menuItems = super.actionMenuItems; - - if (this._registryRestricted && REGISTRY_MODELS.includes(this.props.resModel)) { - if (menuItems.action) { - menuItems.action = menuItems.action.filter( - (item) => !BLOCKED_ACTIONS.includes(item.key) - ); - } - } - - return menuItems; - }, -}); diff --git a/spp_starter_sp_mis/tests/__init__.py b/spp_starter_sp_mis/tests/__init__.py index f8ba0b3d9..8bcf6eef5 100644 --- a/spp_starter_sp_mis/tests/__init__.py +++ b/spp_starter_sp_mis/tests/__init__.py @@ -1,2 +1,3 @@ # Part of OpenSPP. See LICENSE file for full copyright and licensing details. +from . import test_registry_restriction from . import test_starter_sp_mis diff --git a/spp_starter_sp_mis/tests/test_registry_restriction.py b/spp_starter_sp_mis/tests/test_registry_restriction.py new file mode 100644 index 000000000..08c26aa2c --- /dev/null +++ b/spp_starter_sp_mis/tests/test_registry_restriction.py @@ -0,0 +1,237 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Registry access-control setting: persistence and enforcement (OP#1142). + +Two independent defects are covered here. + +1. The setting could not be switched off. Odoo stores a False + ``config_parameter`` by *deleting* the row, and ``default_get`` then falls + back to the field default — so ``default=True`` made "off" unrepresentable + and the form kept springing back to on. Worse, the enforcement side read a + missing row as False, so the form claimed "restricted" while the registry + was in fact wide open. + +2. The restriction was cosmetic. It hid buttons from the DOM in JavaScript and + nothing on the server refused the write, so RPC and import went straight + through — and the list "New" button was never hidden at all, because the + patch assigned a ``canCreate`` property that Odoo 19's ListController does + not read (its template gates on ``activeActions.create``). +""" + +from lxml import etree + +from odoo.exceptions import AccessError +from odoo.tests import TransactionCase, tagged + +PARAM = "spp_starter.registry_admin_only_crud" + + +class RegistryRestrictionCommon(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.ICP = cls.env["ir.config_parameter"].sudo() + cls.Partner = cls.env["res.partner"] + cls.Settings = cls.env["res.config.settings"] + + # Mirrors the Global Registrar role — see + # spp_base_common/data/global_roles.xml. This is the profile QA used. + cls.registrar = cls._make_user( + "op1142_registrar", + [ + "base.group_user", + "base.group_partner_manager", + "spp_registry.group_registry_officer", + ], + ) + # A non-admin who *can* delete registrants today, so the unlink test + # proves this gate bites rather than re-testing spp_registry's own + # officer deletion block. + cls.manager = cls._make_user( + "op1142_manager", + [ + "base.group_user", + "base.group_partner_manager", + "spp_registry.group_registry_officer", + "spp_registry.group_registry_manager", + ], + ) + cls.spp_admin = cls._make_user( + "op1142_admin", + [ + "base.group_user", + "base.group_partner_manager", + "spp_registry.group_registry_officer", + "spp_security.group_spp_admin", + ], + ) + + @classmethod + def _make_user(cls, login, group_xmlids): + groups = cls.env["res.groups"] + for xmlid in group_xmlids: + groups |= cls.env.ref(xmlid) + return cls.env["res.users"].create( + { + "name": login, + "login": login, + "email": f"{login}@example.test", + "group_ids": [(6, 0, groups.ids)], + } + ) + + @classmethod + def _restrict(cls, enabled): + cls.ICP.set_param(PARAM, "True" if enabled else "False") + + def _new_registrant(self): + return self.Partner.create({"name": "OP1142 Registrant", "is_registrant": True, "is_group": False}) + + +@tagged("post_install", "-at_install") +class TestRegistryRestrictionSetting(RegistryRestrictionCommon): + """Defect 1 — the toggle must be able to hold "off".""" + + def test_setting_can_be_turned_off(self): + """Unticking and saving leaves the setting off, not back on.""" + self._restrict(True) + + self.Settings.create({"is_registry_admin_only_crud": False}).execute() + + defaults = self.Settings.default_get(["is_registry_admin_only_crud"]) + self.assertFalse( + defaults["is_registry_admin_only_crud"], + "Settings form still reports the restriction as enabled after it was turned off", + ) + + def test_off_is_stored_not_deleted(self): + """"Off" is a stored fact, so it cannot be mistaken for "never set".""" + self._restrict(True) + + self.Settings.create({"is_registry_admin_only_crud": False}).execute() + + self.assertEqual(self.ICP.get_param(PARAM, "MISSING"), "False") + + def test_setting_can_be_turned_back_on(self): + """The toggle still works in the enabling direction.""" + self._restrict(False) + + self.Settings.create({"is_registry_admin_only_crud": True}).execute() + + self.assertEqual(self.ICP.get_param(PARAM, "MISSING"), "True") + defaults = self.Settings.default_get(["is_registry_admin_only_crud"]) + self.assertTrue(defaults["is_registry_admin_only_crud"]) + + def test_form_and_enforcement_never_disagree(self): + """What the form shows is what the server enforces, both ways. + + The original defect made these two diverge: the form fell back to + ``default=True`` while the enforcement read a missing row as False. + """ + for enabled in (True, False): + with self.subTest(enabled=enabled): + self.Settings.create({"is_registry_admin_only_crud": enabled}).execute() + + shown = self.Settings.default_get(["is_registry_admin_only_crud"])["is_registry_admin_only_crud"] + enforced = self.Partner.with_user(self.registrar)._is_registry_crud_restricted() + + self.assertEqual(bool(shown), enabled) + self.assertEqual(enforced, enabled) + + +@tagged("post_install", "-at_install") +class TestRegistryRestrictionEnforcement(RegistryRestrictionCommon): + """Defect 2 — the restriction must be enforced by the server.""" + + def test_non_admin_cannot_create_registrant(self): + self._restrict(True) + with self.assertRaises(AccessError): + self.Partner.with_user(self.registrar).create( + {"name": "Blocked Registrant", "is_registrant": True, "is_group": False} + ) + + def test_non_admin_cannot_create_registrant_group(self): + self._restrict(True) + with self.assertRaises(AccessError): + self.Partner.with_user(self.registrar).create( + {"name": "Blocked Group", "is_registrant": True, "is_group": True} + ) + + def test_non_admin_cannot_write_registrant(self): + self._restrict(True) + registrant = self._new_registrant() + with self.assertRaises(AccessError): + registrant.with_user(self.registrar).write({"name": "Renamed"}) + + def test_non_admin_cannot_unlink_registrant(self): + """Even a Registry Manager, who may delete registrants normally.""" + self._restrict(False) + registrant = self._new_registrant() + registrant.with_user(self.manager).unlink() # allowed while unrestricted + + self._restrict(True) + blocked = self._new_registrant() + with self.assertRaises(AccessError): + blocked.with_user(self.manager).unlink() + + def test_plain_contacts_are_not_restricted(self): + """The setting governs registrants — it must not lock the Contacts app.""" + self._restrict(True) + contact = self.Partner.with_user(self.registrar).create({"name": "Ordinary Contact"}) + contact.write({"name": "Ordinary Contact Renamed"}) + self.assertEqual(contact.name, "Ordinary Contact Renamed") + + def test_admin_is_exempt(self): + self._restrict(True) + registrant = self.Partner.with_user(self.spp_admin).create( + {"name": "Admin Registrant", "is_registrant": True, "is_group": False} + ) + registrant.write({"name": "Admin Registrant Renamed"}) + self.assertEqual(registrant.name, "Admin Registrant Renamed") + + def test_non_admin_unaffected_when_setting_is_off(self): + self._restrict(False) + registrant = self.Partner.with_user(self.registrar).create( + {"name": "Allowed Registrant", "is_registrant": True, "is_group": False} + ) + registrant.write({"name": "Allowed Registrant Renamed"}) + self.assertEqual(registrant.name, "Allowed Registrant Renamed") + + +@tagged("post_install", "-at_install") +class TestRegistryRestrictionArch(RegistryRestrictionCommon): + """The "New" button follows server access, so no JavaScript is needed. + + ``ir.ui.view._postprocess_access_rights`` stamps ``create="false"`` onto the + arch when ``has_access('create')`` is False, which is what actually removes + the button from the control panel. + """ + + def _create_disabled(self, user, **context): + """Whether the arch tells the client not to offer New. + + Read off the root node rather than by searching the arch text: fields + carry their own ``can_create`` attribute, so a substring test matches + ``can_create="False"`` and reports True for every view. + """ + arch = self.Partner.with_user(user).with_context(**context).get_view(view_type="list")["arch"] + return etree.fromstring(arch).get("create") in ("False", "false", "0") + + def test_registry_list_drops_create_for_non_admin(self): + self._restrict(True) + self.assertTrue( + self._create_disabled(self.registrar, default_is_registrant=True), + "Registry list still offers New to a restricted user", + ) + + def test_registry_list_keeps_create_for_admin(self): + self._restrict(True) + self.assertFalse(self._create_disabled(self.spp_admin, default_is_registrant=True)) + + def test_registry_list_keeps_create_when_setting_is_off(self): + self._restrict(False) + self.assertFalse(self._create_disabled(self.registrar, default_is_registrant=True)) + + def test_contacts_list_is_unaffected(self): + """Outside the registry context the Contacts app keeps its New button.""" + self._restrict(True) + self.assertFalse(self._create_disabled(self.registrar)) From 1bf76f677491c4039f1e8147d9e449d1bbc751e8 Mon Sep 17 00:00:00 2001 From: emjay0921 Date: Wed, 12 Aug 2026 11:07:36 +0800 Subject: [PATCH 2/3] chore(starter_sp_mis): justify the sudo() calls for semgrep CI's semgrep flags odoo-sudo-without-context on all three sudo() calls. Each is deliberate, so record why next to it: - writing the config parameter is a Settings-manager operation, and the settings form is already gated on that group; - reading it is how the guard decides whether to withhold access, so every user has to be able to read it; - filtering on is_registrant as the acting user would recurse straight back into the access check being evaluated. The pragma has to sit on the line immediately above the match, not at the head of the comment block, or semgrep does not associate the two. Also applies ruff's preferred spacing on a docstring that opens with a quoted word. --- spp_starter_sp_mis/models/res_config_settings.py | 4 ++++ spp_starter_sp_mis/models/res_partner.py | 8 ++++++++ spp_starter_sp_mis/tests/test_registry_restriction.py | 2 +- 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/spp_starter_sp_mis/models/res_config_settings.py b/spp_starter_sp_mis/models/res_config_settings.py index aa46668d5..86937f489 100644 --- a/spp_starter_sp_mis/models/res_config_settings.py +++ b/spp_starter_sp_mis/models/res_config_settings.py @@ -35,6 +35,10 @@ def set_values(self): default, so a missing row can no longer mean "on". """ super().set_values() + # Writing a system configuration parameter. ir.config_parameter is + # restricted to Settings managers, and the settings form is already + # gated on that group, so this widens nothing. + # nosemgrep: odoo-sudo-without-context self.env["ir.config_parameter"].sudo().set_param( REGISTRY_ADMIN_ONLY_CRUD_PARAM, "True" if self.is_registry_admin_only_crud else "False", diff --git a/spp_starter_sp_mis/models/res_partner.py b/spp_starter_sp_mis/models/res_partner.py index 23e56f041..3a1b96d7c 100644 --- a/spp_starter_sp_mis/models/res_partner.py +++ b/spp_starter_sp_mis/models/res_partner.py @@ -19,6 +19,10 @@ def _is_registry_crud_restricted(self): """Whether registry changes are currently withheld from this user.""" if self.env.user.has_group(ADMIN_GROUP): return False + # Reads one system setting in order to decide whether to *withhold* + # access. Every user has to be able to read it, and it exposes nothing + # beyond the flag itself. + # nosemgrep: odoo-sudo-without-context param = self.env["ir.config_parameter"].sudo().get_param(REGISTRY_ADMIN_ONLY_CRUD_PARAM, "False") return param == "True" @@ -62,6 +66,10 @@ def _check_access(self, operation): return None if self: + # Reads is_registrant to decide what to refuse. Filtering as the + # user would recurse straight back into this check, and nothing is + # returned but ids the caller already holds. + # nosemgrep: odoo-sudo-without-context forbidden = self.browse(self.sudo().filtered("is_registrant").ids) if not forbidden: return None diff --git a/spp_starter_sp_mis/tests/test_registry_restriction.py b/spp_starter_sp_mis/tests/test_registry_restriction.py index 08c26aa2c..72709f8f0 100644 --- a/spp_starter_sp_mis/tests/test_registry_restriction.py +++ b/spp_starter_sp_mis/tests/test_registry_restriction.py @@ -104,7 +104,7 @@ def test_setting_can_be_turned_off(self): ) def test_off_is_stored_not_deleted(self): - """"Off" is a stored fact, so it cannot be mistaken for "never set".""" + """ "Off" is a stored fact, so it cannot be mistaken for "never set".""" self._restrict(True) self.Settings.create({"is_registry_admin_only_crud": False}).execute() From 9a3a9a3e605911954e626088843740e1495bd035 Mon Sep 17 00:00:00 2001 From: emjay0921 Date: Wed, 19 Aug 2026 12:22:24 +0800 Subject: [PATCH 3/3] fix(starter_sp_mis): make the noupdate move real and close the promotion bypass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marking the config parameter noupdate only governs xml_ids created from then on: _build_update_xmlids_query upserts with DO UPDATE SET (model, res_id, write_date) and never rewrites an existing row's noupdate flag. Every database that already carries this xml_id therefore kept re-applying value=True on each upgrade — silently re-locking a registry an administrator had deliberately opened, which is the bug the change set out to fix. A migration flips the flag on the row itself, and a test asserts it is set. The access check filters on a record's current values and there is no post-write pass, so a restricted user could create a plain contact and then set is_registrant on it — two allowed steps adding up to a registrant they were never allowed to create, and the same move promotes any existing contact. write() now refuses that flip while the restriction is on. Unflagging needs no guard: the record is already a registrant when the check runs. Version bump with its changelog entry. This one is load-bearing rather than convention: without an upgrade a deployment keeps referencing the deleted JS asset and never gains the server-side enforcement. DESCRIPTION.md described the mechanism this branch removes — a JavaScript patch, a JSON-RPC endpoint and a MutationObserver — so it advertised client-side enforcement that no longer exists. Rewritten for what the module now does. --- spp_starter_sp_mis/README.rst | 47 ++++++++++------ spp_starter_sp_mis/__manifest__.py | 2 +- .../migrations/19.0.2.1.0/post-migration.py | 35 ++++++++++++ spp_starter_sp_mis/models/res_partner.py | 19 +++++++ spp_starter_sp_mis/readme/DESCRIPTION.md | 16 +++--- spp_starter_sp_mis/readme/HISTORY.md | 4 ++ .../static/description/index.html | 48 ++++++++++------ .../tests/test_registry_restriction.py | 55 +++++++++++++++++++ 8 files changed, 185 insertions(+), 41 deletions(-) create mode 100644 spp_starter_sp_mis/migrations/19.0.2.1.0/post-migration.py diff --git a/spp_starter_sp_mis/README.rst b/spp_starter_sp_mis/README.rst index dd132e795..f17a89b0f 100644 --- a/spp_starter_sp_mis/README.rst +++ b/spp_starter_sp_mis/README.rst @@ -25,8 +25,8 @@ OpenSPP Starter: SP-MIS Starter bundle for Social Protection Management Information System (SP-MIS) deployments. Extends ``spp_starter_social_registry`` with program management, approval workflows, and service delivery -capabilities. Adds optional client-side registry access control to -restrict registrant editing to administrators. +capabilities. Adds optional registry access control, enforced on the +server, to restrict registrant editing to administrators. Key Capabilities ~~~~~~~~~~~~~~~~ @@ -35,11 +35,12 @@ Key Capabilities program management modules in a single deployment - **Starter Type Configuration**: Sets system identifier to "sp_mis" for deployment classification -- **Registry Access Control**: Optional JavaScript-based restriction - that makes registrant forms read-only for non-admin users -- **Client-Side Enforcement**: Patches ``FormController`` and - ``ListController`` to hide Create/Edit/Delete buttons and force - readonly mode +- **Registry Access Control**: Optional restriction withholding create, + write and delete on registrant records from non-admin users +- **Server-Side Enforcement**: Applied in the access check every write + passes through, so it holds over RPC and data import as well as in the + web client — the New, Edit and Delete buttons disappear because Odoo + stamps the view from the same access result Key Models ~~~~~~~~~~ @@ -61,8 +62,9 @@ After installing: 1. Navigate to **Settings > SP-MIS Settings** 2. Enable **Restrict Registry Edits to Admin Only** to enforce read-only registry access for non-admin users -3. When enabled, non-admin users see registrant forms in readonly mode - with hidden create/edit/delete buttons +3. When enabled, non-admin users can still read the registry, but + creating, editing and deleting registrants is refused — and the + corresponding buttons are not shown 4. Restriction applies only to ``res.partner`` views; program-related operations remain available based on role @@ -82,15 +84,17 @@ Implementation Details The registry restriction uses: - **Config Parameter**: ``spp_starter.registry_admin_only_crud`` - (default: True) -- **JSON-RPC Endpoint**: ``/spp_starter_sp_mis/registry_restriction`` - checks restriction status -- **JavaScript Patches**: Modifies ``FormController`` and - ``ListController`` for ``res.partner`` model + (default: True), marked ``noupdate`` so an administrator's choice + survives module upgrades +- **Access Check**: ``res.partner._check_access`` withholds create, + write and unlink on records flagged ``is_registrant`` +- **Promotion Guard**: ``write`` refuses setting ``is_registrant`` on a + plain contact, which would otherwise add a registrant in two allowed + steps - **Admin Check**: Users in ``spp_security.group_spp_admin`` bypass all restrictions -- **MutationObserver**: Monitors DOM changes to re-apply restrictions - dynamically +- **Scope**: Only registrant records are affected, so the Contacts app + stays usable Included Modules ~~~~~~~~~~~~~~~~ @@ -119,6 +123,17 @@ Dependencies Changelog ========= +19.0.2.1.0 +~~~~~~~~~~ + +- fix(starter_sp_mis): make the registry restriction hold and stop it + re-locking itself. Enforcement moves from a JavaScript patch Odoo 19 + no longer reads to the access check every create, write and delete + passes through, so it applies over RPC and data import too; promoting + a plain contact into the registry is refused as well. The setting is + marked ``noupdate``, with a migration for databases where an upgrade + would otherwise keep switching it back on (#1142) + 19.0.2.0.0 ~~~~~~~~~~ diff --git a/spp_starter_sp_mis/__manifest__.py b/spp_starter_sp_mis/__manifest__.py index d428fa8d1..3bc4ee1b7 100644 --- a/spp_starter_sp_mis/__manifest__.py +++ b/spp_starter_sp_mis/__manifest__.py @@ -4,7 +4,7 @@ "name": "OpenSPP Starter: SP-MIS", "summary": "Complete SP-MIS bundle with Social Registry, Programs, and Service Points", "category": "OpenSPP", - "version": "19.0.2.0.0", + "version": "19.0.2.1.0", "sequence": 1, "author": "OpenSPP.org", "website": "https://github.com/OpenSPP/OpenSPP2", diff --git a/spp_starter_sp_mis/migrations/19.0.2.1.0/post-migration.py b/spp_starter_sp_mis/migrations/19.0.2.1.0/post-migration.py new file mode 100644 index 000000000..4b5437af8 --- /dev/null +++ b/spp_starter_sp_mis/migrations/19.0.2.1.0/post-migration.py @@ -0,0 +1,35 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Stop re-locking the registry on every upgrade (OP#1142). + +``config_registry_admin_only_crud`` is now declared ``noupdate="1"`` so an +administrator's choice survives module upgrades. That declaration only governs +xml_ids created from now on: ``_build_update_xmlids_query`` upserts with +``ON CONFLICT … DO UPDATE SET (model, res_id, write_date)`` and never touches +the stored ``noupdate`` flag of an existing row. So every database that already +carries this xml_id keeps ``noupdate = false``, and each upgrade re-applies +``value = True`` — silently re-locking a registry that was deliberately opened, +which is the bug this module set out to fix. + +The flag has to be flipped in the row itself, once. +""" + +import logging + +_logger = logging.getLogger(__name__) + + +def migrate(cr, version): + if not version: + return + + cr.execute( + """ + UPDATE ir_model_data + SET noupdate = true + WHERE module = 'spp_starter_sp_mis' + AND name = 'config_registry_admin_only_crud' + AND noupdate IS NOT TRUE + """ + ) + if cr.rowcount: + _logger.info("Marked config_registry_admin_only_crud noupdate; the setting now survives upgrades") diff --git a/spp_starter_sp_mis/models/res_partner.py b/spp_starter_sp_mis/models/res_partner.py index 3a1b96d7c..b4f24c688 100644 --- a/spp_starter_sp_mis/models/res_partner.py +++ b/spp_starter_sp_mis/models/res_partner.py @@ -34,6 +34,25 @@ def _make_registry_access_error(self): ) ) + def write(self, vals): + """Refuse promoting a plain contact into the registry (OP#1142 review). + + ``_check_access('write')`` filters on the record's *current* values, and + there is no post-write pass, so a restricted user could create a plain + contact and then flip ``is_registrant`` on it — two allowed steps adding + up to a registrant they were never allowed to create. The same move + promotes any existing contact. Unflagging needs no guard: the record is + already a registrant when the check runs. + """ + if vals.get("is_registrant") and self._is_registry_crud_restricted(): + # Reading the current flag to find what is being promoted; filtering + # as the user would recurse back into the access check. + # nosemgrep: odoo-sudo-without-context + promoted = self.sudo().filtered(lambda partner: not partner.is_registrant) + if promoted: + raise self._make_registry_access_error() + return super().write(vals) + def _check_access(self, operation): """Withhold registrant create/write/unlink from non-admins (OP#1142). diff --git a/spp_starter_sp_mis/readme/DESCRIPTION.md b/spp_starter_sp_mis/readme/DESCRIPTION.md index 60fba3cb2..a0ab4c171 100644 --- a/spp_starter_sp_mis/readme/DESCRIPTION.md +++ b/spp_starter_sp_mis/readme/DESCRIPTION.md @@ -1,11 +1,11 @@ -Starter bundle for Social Protection Management Information System (SP-MIS) deployments. Extends `spp_starter_social_registry` with program management, approval workflows, and service delivery capabilities. Adds optional client-side registry access control to restrict registrant editing to administrators. +Starter bundle for Social Protection Management Information System (SP-MIS) deployments. Extends `spp_starter_social_registry` with program management, approval workflows, and service delivery capabilities. Adds optional registry access control, enforced on the server, to restrict registrant editing to administrators. ### Key Capabilities - **Bundle Management**: Installs social registry foundation plus program management modules in a single deployment - **Starter Type Configuration**: Sets system identifier to "sp_mis" for deployment classification -- **Registry Access Control**: Optional JavaScript-based restriction that makes registrant forms read-only for non-admin users -- **Client-Side Enforcement**: Patches `FormController` and `ListController` to hide Create/Edit/Delete buttons and force readonly mode +- **Registry Access Control**: Optional restriction withholding create, write and delete on registrant records from non-admin users +- **Server-Side Enforcement**: Applied in the access check every write passes through, so it holds over RPC and data import as well as in the web client — the New, Edit and Delete buttons disappear because Odoo stamps the view from the same access result ### Key Models @@ -21,7 +21,7 @@ After installing: 1. Navigate to **Settings > SP-MIS Settings** 2. Enable **Restrict Registry Edits to Admin Only** to enforce read-only registry access for non-admin users -3. When enabled, non-admin users see registrant forms in readonly mode with hidden create/edit/delete buttons +3. When enabled, non-admin users can still read the registry, but creating, editing and deleting registrants is refused — and the corresponding buttons are not shown 4. Restriction applies only to `res.partner` views; program-related operations remain available based on role ### UI Location @@ -34,11 +34,11 @@ After installing: The registry restriction uses: -- **Config Parameter**: `spp_starter.registry_admin_only_crud` (default: True) -- **JSON-RPC Endpoint**: `/spp_starter_sp_mis/registry_restriction` checks restriction status -- **JavaScript Patches**: Modifies `FormController` and `ListController` for `res.partner` model +- **Config Parameter**: `spp_starter.registry_admin_only_crud` (default: True), marked `noupdate` so an administrator's choice survives module upgrades +- **Access Check**: `res.partner._check_access` withholds create, write and unlink on records flagged `is_registrant` +- **Promotion Guard**: `write` refuses setting `is_registrant` on a plain contact, which would otherwise add a registrant in two allowed steps - **Admin Check**: Users in `spp_security.group_spp_admin` bypass all restrictions -- **MutationObserver**: Monitors DOM changes to re-apply restrictions dynamically +- **Scope**: Only registrant records are affected, so the Contacts app stays usable ### Included Modules diff --git a/spp_starter_sp_mis/readme/HISTORY.md b/spp_starter_sp_mis/readme/HISTORY.md index 4aaf9afef..41131d098 100644 --- a/spp_starter_sp_mis/readme/HISTORY.md +++ b/spp_starter_sp_mis/readme/HISTORY.md @@ -1,3 +1,7 @@ +### 19.0.2.1.0 + +- fix(starter_sp_mis): make the registry restriction hold and stop it re-locking itself. Enforcement moves from a JavaScript patch Odoo 19 no longer reads to the access check every create, write and delete passes through, so it applies over RPC and data import too; promoting a plain contact into the registry is refused as well. The setting is marked `noupdate`, with a migration for databases where an upgrade would otherwise keep switching it back on (#1142) + ### 19.0.2.0.0 - Initial migration to OpenSPP2 diff --git a/spp_starter_sp_mis/static/description/index.html b/spp_starter_sp_mis/static/description/index.html index 623eada22..df0a16983 100644 --- a/spp_starter_sp_mis/static/description/index.html +++ b/spp_starter_sp_mis/static/description/index.html @@ -373,8 +373,8 @@

OpenSPP Starter: SP-MIS

Starter bundle for Social Protection Management Information System (SP-MIS) deployments. Extends spp_starter_social_registry with program management, approval workflows, and service delivery -capabilities. Adds optional client-side registry access control to -restrict registrant editing to administrators.

+capabilities. Adds optional registry access control, enforced on the +server, to restrict registrant editing to administrators.

Key Capabilities

    @@ -382,11 +382,12 @@

    Key Capabilities

    program management modules in a single deployment
  • Starter Type Configuration: Sets system identifier to “sp_mis” for deployment classification
  • -
  • Registry Access Control: Optional JavaScript-based restriction -that makes registrant forms read-only for non-admin users
  • -
  • Client-Side Enforcement: Patches FormController and -ListController to hide Create/Edit/Delete buttons and force -readonly mode
  • +
  • Registry Access Control: Optional restriction withholding create, +write and delete on registrant records from non-admin users
  • +
  • Server-Side Enforcement: Applied in the access check every write +passes through, so it holds over RPC and data import as well as in the +web client — the New, Edit and Delete buttons disappear because Odoo +stamps the view from the same access result
@@ -417,8 +418,9 @@

Configuration

  • Navigate to Settings > SP-MIS Settings
  • Enable Restrict Registry Edits to Admin Only to enforce read-only registry access for non-admin users
  • -
  • When enabled, non-admin users see registrant forms in readonly mode -with hidden create/edit/delete buttons
  • +
  • When enabled, non-admin users can still read the registry, but +creating, editing and deleting registrants is refused — and the +corresponding buttons are not shown
  • Restriction applies only to res.partner views; program-related operations remain available based on role
  • @@ -439,15 +441,17 @@

    Implementation Details

    The registry restriction uses:

    • Config Parameter: spp_starter.registry_admin_only_crud -(default: True)
    • -
    • JSON-RPC Endpoint: /spp_starter_sp_mis/registry_restriction -checks restriction status
    • -
    • JavaScript Patches: Modifies FormController and -ListController for res.partner model
    • +(default: True), marked noupdate so an administrator’s choice +survives module upgrades +
    • Access Check: res.partner._check_access withholds create, +write and unlink on records flagged is_registrant
    • +
    • Promotion Guard: write refuses setting is_registrant on a +plain contact, which would otherwise add a registrant in two allowed +steps
    • Admin Check: Users in spp_security.group_spp_admin bypass all restrictions
    • -
    • MutationObserver: Monitors DOM changes to re-apply restrictions -dynamically
    • +
    • Scope: Only registrant records are affected, so the Contacts app +stays usable
    @@ -478,6 +482,18 @@

    Changelog

    +

    19.0.2.1.0

    +
      +
    • fix(starter_sp_mis): make the registry restriction hold and stop it +re-locking itself. Enforcement moves from a JavaScript patch Odoo 19 +no longer reads to the access check every create, write and delete +passes through, so it applies over RPC and data import too; promoting +a plain contact into the registry is refused as well. The setting is +marked noupdate, with a migration for databases where an upgrade +would otherwise keep switching it back on (#1142)
    • +
    +
    +

    19.0.2.0.0

    • Initial migration to OpenSPP2
    • diff --git a/spp_starter_sp_mis/tests/test_registry_restriction.py b/spp_starter_sp_mis/tests/test_registry_restriction.py index 72709f8f0..be038e993 100644 --- a/spp_starter_sp_mis/tests/test_registry_restriction.py +++ b/spp_starter_sp_mis/tests/test_registry_restriction.py @@ -173,6 +173,61 @@ def test_non_admin_cannot_unlink_registrant(self): with self.assertRaises(AccessError): blocked.with_user(self.manager).unlink() + def test_non_admin_cannot_promote_a_contact_into_the_registry(self): + """Two allowed steps that add up to a forbidden one (OP#1142 review). + + The access check filters on the record's *current* values and there is + no post-write pass, so creating a plain contact and then flipping + is_registrant would have produced a registrant the user was never + allowed to create — and would promote any existing contact the same way. + """ + self._restrict(True) + contact = self.Partner.with_user(self.registrar).create({"name": "Contact To Promote"}) + + with self.assertRaises(AccessError): + contact.with_user(self.registrar).write({"is_registrant": True}) + + self.assertFalse(contact.is_registrant) + + def test_promotion_is_allowed_when_the_setting_is_off(self): + self._restrict(False) + contact = self.Partner.with_user(self.registrar).create({"name": "Contact To Promote Freely"}) + + contact.with_user(self.registrar).write({"is_registrant": True}) + + self.assertTrue(contact.is_registrant) + + def test_admin_can_still_promote_a_contact(self): + self._restrict(True) + contact = self.Partner.with_user(self.spp_admin).create({"name": "Contact For Admin"}) + + contact.with_user(self.spp_admin).write({"is_registrant": True}) + + self.assertTrue(contact.is_registrant) + + def test_unflagging_a_registrant_is_still_refused(self): + """No guard needed for this direction, but it must not have regressed.""" + self._restrict(True) + registrant = self._new_registrant() + + with self.assertRaises(AccessError): + registrant.with_user(self.registrar).write({"is_registrant": False}) + + def test_the_setting_row_is_marked_noupdate(self): + """Otherwise every upgrade re-applies value=True and re-locks the registry. + + Declaring noupdate in the data file only governs xml_ids created from + then on — the upsert never rewrites an existing row's flag — so the + migration flips it for databases that already have this one. + """ + record = self.env["ir.model.data"].search( + [("module", "=", "spp_starter_sp_mis"), ("name", "=", "config_registry_admin_only_crud")], + limit=1, + ) + + self.assertTrue(record, "the config parameter should be an xml_id-tracked record") + self.assertTrue(record.noupdate, "an administrator's choice must survive an upgrade") + def test_plain_contacts_are_not_restricted(self): """The setting governs registrants — it must not lock the Contacts app.""" self._restrict(True)