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/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/__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..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",
@@ -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/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/__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..86937f489 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,30 @@ 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()
+ # 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
new file mode 100644
index 000000000..b4f24c688
--- /dev/null
+++ b/spp_starter_sp_mis/models/res_partner.py
@@ -0,0 +1,100 @@
+# 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
+ # 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"
+
+ 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 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).
+
+ 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:
+ # 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
+ 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/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
+
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/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..be038e993
--- /dev/null
+++ b/spp_starter_sp_mis/tests/test_registry_restriction.py
@@ -0,0 +1,292 @@
+# 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_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)
+ 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))