diff --git a/spp_change_request_v2/README.rst b/spp_change_request_v2/README.rst
index 4b687bcd..dc463b5b 100644
--- a/spp_change_request_v2/README.rst
+++ b/spp_change_request_v2/README.rst
@@ -853,6 +853,14 @@ Before declaring a new CR type complete:
Changelog
=========
+19.0.3.1.2
+~~~~~~~~~~
+
+- fix(change_request_v2): adding an ID now looks for a live one of that
+ type rather than any row at all, so an ID that was removed through a
+ change request no longer blocks adding a replacement of the same type
+ (#1136)
+
19.0.3.1.1
~~~~~~~~~~
diff --git a/spp_change_request_v2/__manifest__.py b/spp_change_request_v2/__manifest__.py
index cd24a9e2..181d8fec 100644
--- a/spp_change_request_v2/__manifest__.py
+++ b/spp_change_request_v2/__manifest__.py
@@ -1,6 +1,6 @@
{
"name": "OpenSPP Change Request V2",
- "version": "19.0.3.1.1",
+ "version": "19.0.3.1.2",
"sequence": 50,
"category": "OpenSPP",
"summary": "Configuration-driven change request system with UX improvements, conflict detection and duplicate prevention",
diff --git a/spp_change_request_v2/readme/HISTORY.md b/spp_change_request_v2/readme/HISTORY.md
index a87a4655..e6296543 100644
--- a/spp_change_request_v2/readme/HISTORY.md
+++ b/spp_change_request_v2/readme/HISTORY.md
@@ -1,3 +1,7 @@
+### 19.0.3.1.2
+
+- fix(change_request_v2): adding an ID now looks for a live one of that type rather than any row at all, so an ID that was removed through a change request no longer blocks adding a replacement of the same type (#1136)
+
### 19.0.3.1.1
- fix(change_request): enforce the `(cr_type_id, reason)` uniqueness of per-reason Required-Documents rules with `models.Constraint` (#394). The rule was previously declared via the legacy `_sql_constraints` attribute, which Odoo 19 ignores — the constraint was never created, so duplicate rules for the same reason could be saved silently since 19.0.3.0.0 and one WARNING line was logged on every registry load. A pre-migration removes duplicate rules (the lowest-id rule per pair is kept, matching which rule the runtime applied) so the constraint applies cleanly on upgrade.
diff --git a/spp_change_request_v2/static/description/index.html b/spp_change_request_v2/static/description/index.html
index d5ddbc6e..1587cb85 100644
--- a/spp_change_request_v2/static/description/index.html
+++ b/spp_change_request_v2/static/description/index.html
@@ -1339,6 +1339,15 @@
Changelog
+
19.0.3.1.2
+
+- fix(change_request_v2): adding an ID now looks for a live one of that
+type rather than any row at all, so an ID that was removed through a
+change request no longer blocks adding a replacement of the same type
+(#1136)
+
+
+
19.0.3.1.1
- fix(change_request): enforce the (cr_type_id, reason) uniqueness
@@ -1352,7 +1361,7 @@
19.0.3.1.1
applied) so the constraint applies cleanly on upgrade.
-
+
19.0.3.1.0
- revert(change_request): restore the create-a-new-individual Add
@@ -1370,7 +1379,7 @@
19.0.3.1.0
not restored here; reinstate separately if needed.
-
+
19.0.3.0.0
- feat(change_request): redesign the group/membership CR flows (#242) —
@@ -1392,7 +1401,7 @@
19.0.3.0.0
must adapt (see #1133).
-
+
19.0.2.0.8
- fix(views): disable inline creation of CR document types on the Change
@@ -1403,7 +1412,7 @@
19.0.2.0.8
Documents” modal (missing Name field) that blocked saving (#1125)
-
+
19.0.2.0.7
- fix(security): align CR Requestor / CR Local Validator / CR HQ
@@ -1415,7 +1424,7 @@
19.0.2.0.7
dependencies.
-
+
19.0.2.0.6
- fix(views): route post-submit CRs (pending / approved / applied /
@@ -1430,7 +1439,7 @@
19.0.2.0.6
list so row-click goes through the stage router.
-
+
19.0.2.0.5
- fix(security): add a global ir.rule on spp.change.request that
@@ -1443,27 +1452,27 @@
19.0.2.0.5
roles).
-
+
19.0.2.0.3
- fix: add HTML escaping to all computed Html fields with
sanitize=False to prevent stored XSS (#50)
-
+
19.0.2.0.2
- fix: fix batch approval wizard line deletion (#130)
-
+
19.0.2.0.1
- fix: skip field types before getattr and isolate detail prefetch
(#129)
-
+
19.0.2.0.0
- Initial migration to OpenSPP2
diff --git a/spp_change_request_v2/strategies/update_id.py b/spp_change_request_v2/strategies/update_id.py
index 30f61542..ca8694da 100644
--- a/spp_change_request_v2/strategies/update_id.py
+++ b/spp_change_request_v2/strategies/update_id.py
@@ -41,11 +41,16 @@ def _apply_add(self, registrant, detail, change_request):
if not detail.id_value:
raise UserError(_("ID value is required."))
- # Check if ID type already exists for this registrant
+ # Check if a *live* ID of this type already exists for this registrant.
+ # Removing an ID through a change request marks it Invalid rather than
+ # deleting it, so an unscoped search counted those dead rows and left
+ # the type permanently unusable — the same defect as the uniqueness
+ # index on spp.registry.id (OP#1136).
existing = self.env["spp.registry.id"].search(
[
("partner_id", "=", registrant.id),
("id_type_id", "=", detail.id_type_id.id),
+ ("status", "!=", "invalid"),
],
limit=1,
)
diff --git a/spp_change_request_v2/tests/test_update_id_strategy.py b/spp_change_request_v2/tests/test_update_id_strategy.py
index 1c888e4e..1654d1f9 100644
--- a/spp_change_request_v2/tests/test_update_id_strategy.py
+++ b/spp_change_request_v2/tests/test_update_id_strategy.py
@@ -186,6 +186,59 @@ def test_remove_id(self):
self.assertTrue(cr.is_applied)
self.assertEqual(id_to_remove.status, "invalid")
+ def test_readd_same_type_after_removal(self):
+ """OP#1136: removing an ID must free its type for a replacement.
+
+ The reported bug end to end — a removed ID is kept and marked Invalid,
+ and both the duplicate check here and the uniqueness rule on
+ spp.registry.id counted that dead row, so the registrant was left with
+ an Invalid ID and no way to add a valid one of the same type.
+ """
+ original = self.id_model.create(
+ {
+ "partner_id": self.individual.id,
+ "id_type_id": self.passport_type.id,
+ "value": "PP-ORIGINAL",
+ "status": "valid",
+ }
+ )
+
+ removal = self.cr_model.create({"request_type_id": self.cr_type.id, "registrant_id": self.individual.id})
+ removal.get_detail().write(
+ {
+ "operation": "remove",
+ "existing_id_record_id": original.id,
+ "id_type_id": self.passport_type.id,
+ }
+ )
+ removal.approval_state = "approved"
+ removal.action_apply()
+ self.assertEqual(original.status, "invalid")
+
+ # The replacement, through the same change-request route.
+ replacement = self.cr_model.create({"request_type_id": self.cr_type.id, "registrant_id": self.individual.id})
+ replacement.get_detail().write(
+ {
+ "operation": "add",
+ "id_type_id": self.passport_type.id,
+ "id_value": "PP-REPLACEMENT",
+ }
+ )
+ replacement.approval_state = "approved"
+ replacement.action_apply()
+
+ self.assertTrue(replacement.is_applied)
+ live = self.id_model.search(
+ [
+ ("partner_id", "=", self.individual.id),
+ ("id_type_id", "=", self.passport_type.id),
+ ("status", "!=", "invalid"),
+ ]
+ )
+ self.assertEqual(len(live), 1, "exactly one live ID of that type should remain")
+ self.assertEqual(live.value, "PP-REPLACEMENT")
+ self.assertEqual(original.status, "invalid", "the removed ID stays on file as Invalid")
+
def test_update_without_existing_id_fails(self):
"""Test update operation requires existing ID."""
diff --git a/spp_registry/README.rst b/spp_registry/README.rst
index a9369ecd..b4a09d08 100644
--- a/spp_registry/README.rst
+++ b/spp_registry/README.rst
@@ -139,6 +139,17 @@ Dependencies
Changelog
=========
+19.0.2.2.0
+~~~~~~~~~~
+
+- fix(registry): let an ID type be used again after its ID was removed.
+ Removing an ID through a change request keeps the row and marks it
+ Invalid, and the old uniqueness rule counted those dead rows — so the
+ registrant was left with an Invalid ID and no way to add a valid one
+ of the same type. Uniqueness now applies to live IDs only, and is
+ refused before the write so the message names the ID type rather than
+ surfacing a database error (#1136)
+
19.0.2.1.4
~~~~~~~~~~
diff --git a/spp_registry/__manifest__.py b/spp_registry/__manifest__.py
index 4b2a0948..f75d8539 100644
--- a/spp_registry/__manifest__.py
+++ b/spp_registry/__manifest__.py
@@ -3,7 +3,7 @@
{
"name": "OpenSPP Registry",
"category": "OpenSPP/Core",
- "version": "19.0.2.1.4",
+ "version": "19.0.2.2.0",
"sequence": 1,
"author": "OpenSPP.org",
"website": "https://github.com/OpenSPP/OpenSPP2",
diff --git a/spp_registry/migrations/19.0.2.2.0/pre-migration.py b/spp_registry/migrations/19.0.2.2.0/pre-migration.py
new file mode 100644
index 00000000..bc35408d
--- /dev/null
+++ b/spp_registry/migrations/19.0.2.2.0/pre-migration.py
@@ -0,0 +1,33 @@
+# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
+"""Drop the unconditional ID-type constraint this version replaces (OP#1136).
+
+``spp.registry.id`` used to carry ``UNIQUE(partner_id, id_type_id)``. Removing
+an ID through a change request keeps the row and marks it Invalid, so that
+constraint counted dead rows: once an ID had been removed, that type could never
+be used again for the registrant. It is replaced by a partial unique index that
+ignores invalid rows, declared on the model as ``models.UniqueIndex``.
+
+The replacement is created by the framework, but the old constraint has to be
+dropped here: Odoo only adds and updates the constraints a model declares, and
+never removes one that has simply stopped being declared. Left in place it would
+keep refusing exactly the Remove-then-Add sequence this version fixes.
+
+Runs pre-migration so the constraint is gone before the ORM reconciles the
+table, which is also when the new index is created.
+"""
+
+import logging
+
+_logger = logging.getLogger(__name__)
+
+
+def migrate(cr, version):
+ if not version:
+ return
+
+ # Written as a literal rather than composed: nothing here is dynamic, and a
+ # composed SQL string is the pitfall the injection check exists to catch.
+ # Odoo names a table constraint "{table}_{attribute with the leading
+ # underscore removed}", so `_unique_partner_id_type` became this.
+ cr.execute("ALTER TABLE spp_registry_id DROP CONSTRAINT IF EXISTS spp_registry_id_unique_partner_id_type")
+ _logger.info("Dropped the unconditional ID-type constraint; live-only uniqueness is now a partial index")
diff --git a/spp_registry/models/reg_id.py b/spp_registry/models/reg_id.py
index d7bdb64a..59af9a21 100644
--- a/spp_registry/models/reg_id.py
+++ b/spp_registry/models/reg_id.py
@@ -80,10 +80,76 @@ class SPPRegistrantID(models.Model):
help="Raw response or notes from verification",
)
- _unique_partner_id_type = models.Constraint(
- "UNIQUE(partner_id, id_type_id)",
- "A registrant cannot have duplicate ID types",
- )
+ # OP#1136: uniqueness applies to *live* IDs only. Removing an ID through a
+ # change request marks it Invalid rather than deleting it, and a plain
+ # UNIQUE(partner_id, id_type_id) counted those dead rows — so once an ID had
+ # been removed, that type could never be used again for that registrant.
+ #
+ # Enforced as a partial unique index rather than a table constraint, since
+ # the rule needs a WHERE clause. Note IS DISTINCT FROM, not != : a NULL
+ # status means an ID added straight through the registry, which is live and
+ # must still reserve its type.
+ # Declared rather than built with raw SQL in init(): models.UniqueIndex
+ # takes a WHERE clause, so the framework creates, drops and recreates the
+ # index as this definition changes, and there is no SQL string for the
+ # injection check to flag (OP#1136 review). The old unconditional
+ # constraint is dropped by migrations/19.0.2.2.0/pre-migration.py, since
+ # Odoo does not remove constraints that simply stop being declared.
+ _unique_active_id_type = models.UniqueIndex("(partner_id, id_type_id) WHERE status IS DISTINCT FROM 'invalid'")
+
+ def _assert_id_type_free(self, partner_id, id_type_id, status, exclude_id=None):
+ """Raise unless this registrant has no live ID of that type.
+
+ Checked ahead of the write rather than through ``@api.constrains``:
+ constraints run on flush, by which point the INSERT has already hit the
+ partial index and the user gets a raw database error instead of a
+ sentence. The index remains the race-safe guarantee; this is what makes
+ the refusal readable (OP#1136).
+
+ ``status`` of ``invalid`` is a removed ID and never conflicts. A NULL
+ status is an ID added straight through the registry — live, and it does.
+ """
+ if status == "invalid" or not partner_id or not id_type_id:
+ return
+ domain = [
+ ("partner_id", "=", partner_id),
+ ("id_type_id", "=", id_type_id),
+ ("status", "!=", "invalid"),
+ ]
+ if exclude_id:
+ domain.append(("id", "!=", exclude_id))
+ # Runs sudo because the rule is about the registrant's data, not the
+ # acting user's visibility: a clashing ID the user cannot read must
+ # still block the write, otherwise the partial index refuses the INSERT
+ # afterwards with a raw database error — the outcome this check exists to
+ # replace. Only the clashing row's type and registrant name are used, in
+ # the message the user already knows they are editing.
+ clash = self.sudo().search(domain, limit=1) # nosemgrep: odoo-sudo-without-context
+ if clash:
+ raise ValidationError(
+ _(
+ "%(registrant)s already has a valid %(id_type)s. Update the existing one, or remove it first.",
+ registrant=clash.partner_id.display_name,
+ id_type=clash.id_type_id.display_name,
+ )
+ )
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ for vals in vals_list:
+ self._assert_id_type_free(vals.get("partner_id"), vals.get("id_type_id"), vals.get("status"))
+ return super().create(vals_list)
+
+ def write(self, vals):
+ if {"partner_id", "id_type_id", "status"} & set(vals):
+ for rec in self:
+ self._assert_id_type_free(
+ vals.get("partner_id", rec.partner_id.id),
+ vals.get("id_type_id", rec.id_type_id.id),
+ vals.get("status", rec.status),
+ exclude_id=rec.id,
+ )
+ return super().write(vals)
def _compute_available_id_type_ids(self):
for rec in self:
diff --git a/spp_registry/readme/HISTORY.md b/spp_registry/readme/HISTORY.md
index 3eb56157..2dde423f 100644
--- a/spp_registry/readme/HISTORY.md
+++ b/spp_registry/readme/HISTORY.md
@@ -1,3 +1,7 @@
+### 19.0.2.2.0
+
+- fix(registry): let an ID type be used again after its ID was removed. Removing an ID through a change request keeps the row and marks it Invalid, and the old uniqueness rule counted those dead rows — so the registrant was left with an Invalid ID and no way to add a valid one of the same type. Uniqueness now applies to live IDs only, and is refused before the write so the message names the ID type rather than surfacing a database error (#1136)
+
### 19.0.2.1.4
- fix(registry): remove the dead `@api.constrains("age")` `_check_age_is_integer` guard. `age` is a non-stored compute derived from `birthdate`, so the constraint never fired and only emitted the registry-load warning `@constrains parameter 'age' is not writeable`. Computed `age` values are unchanged; stale i18n entries for the removed message are dropped
diff --git a/spp_registry/static/description/index.html b/spp_registry/static/description/index.html
index 0e678633..26a37f53 100644
--- a/spp_registry/static/description/index.html
+++ b/spp_registry/static/description/index.html
@@ -518,6 +518,18 @@
+
19.0.2.2.0
+
+- fix(registry): let an ID type be used again after its ID was removed.
+Removing an ID through a change request keeps the row and marks it
+Invalid, and the old uniqueness rule counted those dead rows — so the
+registrant was left with an Invalid ID and no way to add a valid one
+of the same type. Uniqueness now applies to live IDs only, and is
+refused before the write so the message names the ID type rather than
+surfacing a database error (#1136)
+
+
+
19.0.2.1.4
- fix(registry): remove the dead @api.constrains("age")
@@ -529,7 +541,7 @@
19.0.2.1.4
dropped
-
+
19.0.2.1.3
- fix(registry): show an ID Status column on the group form
@@ -540,7 +552,7 @@
19.0.2.1.3
(#1110)
-
+
19.0.2.1.1
- fix(views): add reusable x2many_no_padding JS widget that
@@ -550,7 +562,7 @@
19.0.2.1.1
don’t bloat the layout (#943).
-
+
19.0.2.0.0
- Initial migration to OpenSPP2
diff --git a/spp_registry/tests/test_reg_id.py b/spp_registry/tests/test_reg_id.py
index 161b5bcd..85b47cf1 100644
--- a/spp_registry/tests/test_reg_id.py
+++ b/spp_registry/tests/test_reg_id.py
@@ -317,6 +317,131 @@ def test_different_partners_same_type_allowed(self):
)
self.assertTrue(rec.id)
+ # ── OP#1136: an Invalid ID must not reserve its type forever ──
+
+ def test_new_id_allowed_when_existing_one_is_invalid(self):
+ """The reported bug.
+
+ Removing an ID through a change request marks it Invalid rather than
+ deleting it. The uniqueness rule counted that dead row, so the type
+ could never be used again for that registrant.
+ """
+ self.RegId.create(
+ {
+ "partner_id": self.individual_a.id,
+ "id_type_id": self.id_type_national.id,
+ "value": "removed-via-cr",
+ "status": "invalid",
+ }
+ )
+
+ replacement = self.RegId.create(
+ {
+ "partner_id": self.individual_a.id,
+ "id_type_id": self.id_type_national.id,
+ "value": "the-new-one",
+ "status": "valid",
+ }
+ )
+
+ self.env.flush_all()
+ self.assertTrue(replacement.id)
+
+ def test_second_valid_id_of_same_type_still_rejected(self):
+ """A live ID still reserves its type."""
+ self.RegId.create(
+ {
+ "partner_id": self.individual_a.id,
+ "id_type_id": self.id_type_national.id,
+ "value": "the-live-one",
+ "status": "valid",
+ }
+ )
+
+ with self.assertRaises(ValidationError):
+ self.RegId.create(
+ {
+ "partner_id": self.individual_a.id,
+ "id_type_id": self.id_type_national.id,
+ "value": "a-second-one",
+ "status": "valid",
+ }
+ )
+ self.env.flush_all()
+
+ def test_id_with_no_status_still_reserves_its_type(self):
+ """IDs added straight through the registry carry no status.
+
+ Those are live records, not removed ones, so they must keep blocking a
+ duplicate — otherwise the fix would open a hole for every ID that was
+ never touched by a change request.
+ """
+ self.RegId.create(
+ {
+ "partner_id": self.individual_a.id,
+ "id_type_id": self.id_type_national.id,
+ "value": "added-in-the-registry",
+ }
+ )
+
+ with self.assertRaises(ValidationError):
+ self.RegId.create(
+ {
+ "partner_id": self.individual_a.id,
+ "id_type_id": self.id_type_national.id,
+ "value": "a-duplicate",
+ }
+ )
+ self.env.flush_all()
+
+ def test_two_invalid_ids_of_the_same_type_are_tolerated(self):
+ """Successive removals leave more than one dead row behind."""
+ for value in ("first-removed", "second-removed"):
+ self.RegId.create(
+ {
+ "partner_id": self.individual_a.id,
+ "id_type_id": self.id_type_national.id,
+ "value": value,
+ "status": "invalid",
+ }
+ )
+ self.env.flush_all()
+
+ replacement = self.RegId.create(
+ {
+ "partner_id": self.individual_a.id,
+ "id_type_id": self.id_type_national.id,
+ "value": "current",
+ "status": "valid",
+ }
+ )
+ self.env.flush_all()
+ self.assertTrue(replacement.id)
+
+ def test_reviving_an_invalid_id_when_a_valid_one_exists_is_rejected(self):
+ """Flipping a dead row back to valid must not create two live ones."""
+ dead = self.RegId.create(
+ {
+ "partner_id": self.individual_a.id,
+ "id_type_id": self.id_type_national.id,
+ "value": "removed",
+ "status": "invalid",
+ }
+ )
+ self.RegId.create(
+ {
+ "partner_id": self.individual_a.id,
+ "id_type_id": self.id_type_national.id,
+ "value": "live",
+ "status": "valid",
+ }
+ )
+ self.env.flush_all()
+
+ with self.assertRaises(ValidationError):
+ dead.write({"status": "valid"})
+ self.env.flush_all()
+
@tagged("post_install", "-at_install")
class TestNameSearch(RegIdCommon):