Skip to content

fix(registry): let a removed ID type be used again - #437

Open
emjay0921 wants to merge 2 commits into
19.0from
fix/1136-reinstate-id-after-cr-removal
Open

fix(registry): let a removed ID type be used again#437
emjay0921 wants to merge 2 commits into
19.0from
fix/1136-reinstate-id-after-cr-removal

Conversation

@emjay0921

Copy link
Copy Markdown
Contributor

Why is this change needed?

Removing an ID through a change request keeps the record and marks it Invalid rather than deleting it. Two separate checks counted that dead row, so once an ID had been removed the registrant was left with an Invalid ID and no way to add a valid one of the same type — which defeats the point of the Remove action (OP#1136).

Both checks had to change; fixing either alone still leaves the user stuck.

How was the change implemented?

  • UNIQUE(partner_id, id_type_id) on spp.registry.id becomes a partial unique index over rows WHERE status IS DISTINCT FROM 'invalid'. A table constraint cannot express the condition. IS DISTINCT FROM rather than != is what keeps a NULL status blocking: those are IDs added straight through the registry, which are live records, not removed ones.
  • The change request add path searched for any ID of the type regardless of status and refused on the strength of it. It now looks for a live one, so the Remove-then-Add sequence in the ticket completes.
  • Uniqueness is asserted before the write rather than through @api.constrains. Constraints run on flush, by which point the INSERT has already hit the index and the user sees a raw psycopg UniqueViolation instead of a sentence naming the ID type. The index stays as the race-safe guarantee.

Note for upgrades: init() drops the old constraint explicitly rather than relying on the ORM noticing it is no longer declared, so an existing database needs spp_registry upgraded, not merely restarted.

New unit tests

  • spp_registry/tests/test_reg_id.py — a live ID still reserves its type, an ID with no status still reserves its type, successive removals leave several Invalid rows behind without blocking, and flipping an Invalid row back to valid while a live one exists is refused.
  • spp_change_request_v2/tests/test_update_id_strategy.py — the add path accepts a type whose only existing row is Invalid, and still refuses one with a live row.

Unit tests executed by the author

Run per module, the way CI's matrix does, on this branch after merging 19.0 in:

  • spp_registry250 tests, 0 failed, 0 errors
  • spp_change_request_v2331 tests, 0 failed, 0 errors

How to test manually

  1. Open a registrant and add an ID — say National ID with some number.
  2. Raise a change request to remove that ID and complete it. The ID stays on the record marked Invalid.
  3. Raise a change request to add a National ID again: it is accepted, where before it was refused because the Invalid row still counted.
  4. Try to add a second National ID while a valid one exists: still refused, with a message naming the type.
  5. Repeat remove-then-add twice more — the extra Invalid rows do not block.

Related links

Removing an ID through a change request keeps the record and marks it
Invalid rather than deleting it. Two separate checks counted that dead row,
so once an ID had been removed the registrant was left with an Invalid ID
and no way to add a valid one of the same type — which defeats the point of
the Remove action.

Both had to change; fixing either alone still leaves the user stuck.

UNIQUE(partner_id, id_type_id) on spp.registry.id becomes a partial unique
index over rows WHERE status IS DISTINCT FROM 'invalid'. A table constraint
cannot express the condition. IS DISTINCT FROM rather than != is what keeps
a NULL status blocking: those are IDs added straight through the registry,
which are live records, not removed ones.

The change request add path searched for any ID of the type regardless of
status and refused on the strength of it. It now looks for a live one, so
the Remove-then-Add sequence the ticket describes completes.

Uniqueness is asserted before the write rather than through @api.constrains.
Constraints run on flush, by which point the INSERT has already hit the
index and the user sees a raw psycopg UniqueViolation instead of a sentence
naming the ID type. The index stays as the race-safe guarantee.

Covers the spec exactly: a live ID still reserves its type, an ID with no
status still reserves its type, successive removals leave several Invalid
rows behind without blocking, and flipping an Invalid row back to valid
while a live one exists is refused.

Note for upgrades: init() drops the old constraint explicitly rather than
relying on the ORM noticing it is no longer declared, so an existing
database needs spp_registry upgraded, not merely restarted.

OP#1136
@emjay0921
emjay0921 marked this pull request as ready for review August 18, 2026 08:01
Comment on lines +102 to +108
self.env.cr.execute(
f"""
CREATE UNIQUE INDEX IF NOT EXISTS {self._UNIQUE_ACTIVE_INDEX}
ON spp_registry_id (partner_id, id_type_id)
WHERE status IS DISTINCT FROM 'invalid'
"""
)
]
if exclude_id:
domain.append(("id", "!=", exclude_id))
clash = self.sudo().search(domain, limit=1)
@codecov

codecov Bot commented Aug 18, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 72.69%. Comparing base (0820667) to head (7754ea1).

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             19.0     #437      +/-   ##
==========================================
+ Coverage   72.24%   72.69%   +0.45%     
==========================================
  Files         419      560     +141     
  Lines       29813    38397    +8584     
==========================================
+ Hits        21539    27914    +6375     
- Misses       8274    10483    +2209     
Flag Coverage Δ
spp_analytics 93.25% <ø> (ø)
spp_api_v2 79.99% <ø> (?)
spp_api_v2_change_request 66.53% <ø> (ø)
spp_api_v2_cycles 71.03% <ø> (ø)
spp_api_v2_data 77.77% <ø> (ø)
spp_api_v2_entitlements 70.23% <ø> (ø)
spp_api_v2_gis 71.57% <ø> (ø)
spp_api_v2_products 65.86% <ø> (?)
spp_api_v2_programs 92.22% <ø> (ø)
spp_api_v2_service_points 71.03% <ø> (ø)
spp_api_v2_simulation 71.19% <ø> (?)
spp_api_v2_vocabulary 57.75% <ø> (?)
spp_approval 50.34% <ø> (?)
spp_area 80.16% <ø> (?)
spp_area_hdx 81.60% <ø> (?)
spp_base_common 91.07% <ø> (ø)
spp_change_request_v2 77.83% <ø> (ø)
spp_programs 65.27% <ø> (ø)
spp_registry 87.57% <100.00%> (+0.42%) ⬆️
spp_security 69.56% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
spp_change_request_v2/strategies/update_id.py 89.87% <ø> (ø)
spp_registry/models/reg_id.py 96.42% <100.00%> (+0.92%) ⬆️

... and 142 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@gonzalesedwin1123 gonzalesedwin1123 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The analysis and the shape of the fix are right — both checks fixed together (either alone leaves the user stuck), IS DISTINCT FROM for the NULL-status registry-added IDs, and a readable pre-write assert in front of the race-safe index. What I verified:

  • status = 'invalid' is exactly what the CR removal writes (update_id.py:117), and the selection has no other terminal value to worry about.
  • The old constraint's generated name is correct: Odoo 19's TableObject.full_name produces {table}_{attribute-minus-underscore} (odoo/orm/table_objects.py), so spp_registry_id_unique_partner_id_type is the right target — and Odoo does not auto-drop constraints that disappear from the model (apply_to_database only adds/updates declared ones), so your explicit drop is genuinely required, not just prudent.
  • Existing data cannot violate the new index: the old constraint was strictly stronger, so CREATE UNIQUE INDEX succeeds on any database that held it.
  • Tests cover both sides of both checks, the multiple-invalid-rows case, and the flip-back-to-valid refusal.

Two blockers:

1. CI is red — and the better fix is a construct you'll like

Semgrep (odoo-sql-injection-fstring, both the pre-commit hook and the standalone Semgrep OSS check) fires on the CREATE INDEX f-string in init() — the known repo pitfall: SQL literal even when the composition is a compile-time constant. But rather than a nosemgrep, Odoo 19 has the idiomatic construct for exactly this:

_unique_active_id_type = models.UniqueIndex(
    "(partner_id, id_type_id) WHERE status IS DISTINCT FROM 'invalid'"
)

models.UniqueIndex supports WHERE clauses — the docstring's own example in odoo/orm/table_objects.py is a partial index ((group_id, active) WHERE active IS TRUE), and core uses the pattern in res_device and account_bank_statement_line. That deletes the raw CREATE INDEX (and most of init()), gets you framework-managed drop/recreate when the definition changes, and takes pylint's missing-return on init with it. The only SQL left is the one-time legacy-constraint DROP, as a literal string.

2. Version bumps + upgrade path — the fix currently reaches fresh installs only

This is a real schema change on two released modules, and neither manifest moved (spp_registry 19.0.2.1.4, spp_change_request_v2 19.0.3.1.1). Your own upgrade note says an existing database needs spp_registry upgraded — but with no version bump, nothing ever triggers that upgrade, so Remove-then-Add stays broken everywhere the bug actually lives. Per the schema-change rule (and Edwin's merge-time-assignment ruling), please bump both modules with HISTORY entries; the legacy-constraint drop fits naturally in migrations/<version>/ as literal SQL (with UniqueIndex handling creation declaratively, the migration is just the drop).

With those two, this is an approve — the core of the fix doesn't need to change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants