From 95a8d95c8ff2970ff4c4d739ca188d1668584796 Mon Sep 17 00:00:00 2001 From: Paolo Miguel de Leon Date: Wed, 15 Apr 2026 14:15:03 +0800 Subject: [PATCH 01/15] fix(spp_api_v2): skip records without identifiers instead of crashing search --- spp_api_v2/routers/group.py | 2 ++ spp_api_v2/routers/individual.py | 2 ++ spp_api_v2/services/group_service.py | 9 +++++++- spp_api_v2/services/individual_service.py | 13 +++++++++--- spp_api_v2/services/membership_utils.py | 25 +++++++++++++++-------- 5 files changed, 39 insertions(+), 12 deletions(-) diff --git a/spp_api_v2/routers/group.py b/spp_api_v2/routers/group.py index 92615914..51c5d367 100644 --- a/spp_api_v2/routers/group.py +++ b/spp_api_v2/routers/group.py @@ -203,6 +203,8 @@ def search_function(offset, limit): def consent_filter_function(group): group_data = group_service.to_api_schema(group, extensions=extension_list) + if group_data is None: + return None filtered_data = consent_service.filter_response(group.id, api_client, "group", group_data) consent_info = filtered_data.pop("_consent", None) if consent_info and consent_info.get("status") in ( diff --git a/spp_api_v2/routers/individual.py b/spp_api_v2/routers/individual.py index 9916edfe..5fa801b1 100644 --- a/spp_api_v2/routers/individual.py +++ b/spp_api_v2/routers/individual.py @@ -218,6 +218,8 @@ def search_function(offset, limit): def consent_filter_function(partner): data = individual_service.to_api_schema(partner, extensions=extension_list) + if data is None: + return None filtered_data = consent_service.filter_response(partner.id, api_client, "individual", data) consent_info = filtered_data.pop("_consent", None) if consent_info and consent_info.get("status") in ( diff --git a/spp_api_v2/services/group_service.py b/spp_api_v2/services/group_service.py index d9982c5a..55ec0f54 100644 --- a/spp_api_v2/services/group_service.py +++ b/spp_api_v2/services/group_service.py @@ -124,7 +124,14 @@ def to_api_schema(self, group, extensions=None) -> dict[str, Any]: identifiers.append(ident_dict) if not identifiers: - raise ValidationError(f"Group {group.name} has no valid external identifiers") + _logger.warning( + "Skipping group (id=%s): no valid external identifiers. " + "Created by uid=%s on %s.", + group.id, + group.create_uid.id if group.create_uid else "unknown", + group.create_date, + ) + return None # Build Group resource group_data = { diff --git a/spp_api_v2/services/individual_service.py b/spp_api_v2/services/individual_service.py index b50479c3..0f92bf51 100644 --- a/spp_api_v2/services/individual_service.py +++ b/spp_api_v2/services/individual_service.py @@ -127,8 +127,14 @@ def to_api_schema(self, partner, extensions=None) -> dict[str, Any]: identifiers.append(identifier) if not identifiers: - # Must have at least one identifier per spec - raise ValidationError(f"Partner {partner.name} has no valid external identifiers") + _logger.warning( + "Skipping individual (id=%s): no valid external identifiers. " + "Created by uid=%s on %s.", + partner.id, + partner.create_uid.id if partner.create_uid else "unknown", + partner.create_date, + ) + return None # Build name (REQUIRED) name = { @@ -749,7 +755,8 @@ def get_groups(self, individual, status: str | None = None, limit: int = 100) -> for membership in memberships: try: response = membership_to_response(membership) - results.append(response) + if response is not None: + results.append(response) except Exception as e: # Log error but continue processing other memberships # Use group/individual names instead of database ID diff --git a/spp_api_v2/services/membership_utils.py b/spp_api_v2/services/membership_utils.py index fba7ff8a..a9d252d4 100644 --- a/spp_api_v2/services/membership_utils.py +++ b/spp_api_v2/services/membership_utils.py @@ -1,12 +1,13 @@ # Part of OpenSPP. See LICENSE file for full copyright and licensing details. """Shared membership utilities for API V2 services.""" +import logging from typing import Any -from odoo.exceptions import ValidationError +_logger = logging.getLogger(__name__) -def membership_to_response(membership) -> dict[str, Any]: +def membership_to_response(membership) -> dict[str, Any] | None: """ Convert spp.group.membership record to MembershipResponse schema. @@ -17,16 +18,19 @@ def membership_to_response(membership) -> dict[str, Any]: membership: spp.group.membership record Returns: - Dictionary matching MembershipResponse schema - - Raises: - ValidationError: If group or individual lacks external identifiers + Dictionary matching MembershipResponse schema, or None if + group or individual lacks valid external identifiers. """ # Build group reference group = membership.group group_id = group.reg_ids[0] if group.reg_ids else None if not group_id: - raise ValidationError(f"Group {group.name} has no valid external identifiers") + _logger.warning( + "Skipping membership (id=%s): group (id=%s) has no valid external identifiers.", + membership.id, + group.id, + ) + return None group_ref = { "reference": f"Group/{group_id.namespace_uri}|{group_id.value}", @@ -37,7 +41,12 @@ def membership_to_response(membership) -> dict[str, Any]: individual = membership.individual individual_id = individual.reg_ids[0] if individual.reg_ids else None if not individual_id: - raise ValidationError(f"Individual {individual.name} has no valid external identifiers") + _logger.warning( + "Skipping membership (id=%s): individual (id=%s) has no valid external identifiers.", + membership.id, + individual.id, + ) + return None individual_ref = { "reference": f"Individual/{individual_id.namespace_uri}|{individual_id.value}", From b0130c6c74f08c46d9265b92c9e15316d41846c8 Mon Sep 17 00:00:00 2001 From: Paolo Miguel de Leon Date: Wed, 15 Apr 2026 15:17:24 +0800 Subject: [PATCH 02/15] fix(spp_api_v2): handle None from to_api_schema in all callers and fix tests --- spp_api_v2/routers/bulk.py | 2 ++ spp_api_v2/routers/filter.py | 2 ++ spp_api_v2/routers/group.py | 5 +++++ spp_api_v2/routers/individual.py | 5 +++++ spp_api_v2/routers/program_membership.py | 7 +++++++ spp_api_v2/services/group_service.py | 3 +-- spp_api_v2/services/individual_service.py | 3 +-- spp_api_v2/services/membership_utils.py | 4 ++-- spp_api_v2/tests/test_group_service.py | 4 ++-- spp_api_v2/tests/test_individual_service.py | 6 ++---- 10 files changed, 29 insertions(+), 12 deletions(-) diff --git a/spp_api_v2/routers/bulk.py b/spp_api_v2/routers/bulk.py index e701480d..9015e14f 100644 --- a/spp_api_v2/routers/bulk.py +++ b/spp_api_v2/routers/bulk.py @@ -119,6 +119,8 @@ async def bulk_export( # Convert to API schema data = service.to_api_schema(record, extensions=extension_list) + if data is None: + continue # Apply consent filtering filtered_data = consent_service.filter_response( diff --git a/spp_api_v2/routers/filter.py b/spp_api_v2/routers/filter.py index 7b00137c..2c66db6e 100644 --- a/spp_api_v2/routers/filter.py +++ b/spp_api_v2/routers/filter.py @@ -195,6 +195,8 @@ async def search( for record in records: last_record_id = record.id data = service.to_api_schema(record, extensions=extension_list) + if data is None: + continue if consent_type: partner_id = record.id if resource_config["model"] == "res.partner" else None diff --git a/spp_api_v2/routers/group.py b/spp_api_v2/routers/group.py index 51c5d367..aa40d974 100644 --- a/spp_api_v2/routers/group.py +++ b/spp_api_v2/routers/group.py @@ -102,6 +102,11 @@ async def read_group( # Convert to API schema extension_list = extensions.split(",") if extensions else None data = service.to_api_schema(group, extensions=extension_list) + if data is None: + raise HTTPException( + status_code=404, + detail="Group not found", + ) # Apply consent filtering consent_service = ConsentService(env) diff --git a/spp_api_v2/routers/individual.py b/spp_api_v2/routers/individual.py index 5fa801b1..6f3b6921 100644 --- a/spp_api_v2/routers/individual.py +++ b/spp_api_v2/routers/individual.py @@ -98,6 +98,11 @@ async def read_individual( # Convert to API schema extension_list = extensions.split(",") if extensions else None data = service.to_api_schema(partner, extensions=extension_list) + if data is None: + raise HTTPException( + status_code=404, + detail="Individual not found", + ) # Apply consent filtering consent_service = ConsentService(env) diff --git a/spp_api_v2/routers/program_membership.py b/spp_api_v2/routers/program_membership.py index 4830ca50..115151fe 100644 --- a/spp_api_v2/routers/program_membership.py +++ b/spp_api_v2/routers/program_membership.py @@ -72,6 +72,11 @@ async def read_program_membership( # Convert to API schema data = service.to_api_schema(membership) + if data is None: + raise HTTPException( + status_code=404, + detail="ProgramMembership not found", + ) # Apply consent filtering for the beneficiary consent_service = ConsentService(env) @@ -164,6 +169,8 @@ def search_function(offset, limit): def consent_filter_function(membership): try: data = service.to_api_schema(membership) + if data is None: + return None filtered_data = consent_service.filter_response( membership.partner_id.id, api_client, "program_membership", data ) diff --git a/spp_api_v2/services/group_service.py b/spp_api_v2/services/group_service.py index 55ec0f54..97a40b4e 100644 --- a/spp_api_v2/services/group_service.py +++ b/spp_api_v2/services/group_service.py @@ -125,8 +125,7 @@ def to_api_schema(self, group, extensions=None) -> dict[str, Any]: if not identifiers: _logger.warning( - "Skipping group (id=%s): no valid external identifiers. " - "Created by uid=%s on %s.", + "Skipping group (id=%s): no valid external identifiers. Created by uid=%s on %s.", group.id, group.create_uid.id if group.create_uid else "unknown", group.create_date, diff --git a/spp_api_v2/services/individual_service.py b/spp_api_v2/services/individual_service.py index 0f92bf51..7317f57a 100644 --- a/spp_api_v2/services/individual_service.py +++ b/spp_api_v2/services/individual_service.py @@ -128,8 +128,7 @@ def to_api_schema(self, partner, extensions=None) -> dict[str, Any]: if not identifiers: _logger.warning( - "Skipping individual (id=%s): no valid external identifiers. " - "Created by uid=%s on %s.", + "Skipping individual (id=%s): no valid external identifiers. Created by uid=%s on %s.", partner.id, partner.create_uid.id if partner.create_uid else "unknown", partner.create_date, diff --git a/spp_api_v2/services/membership_utils.py b/spp_api_v2/services/membership_utils.py index a9d252d4..968cd2ad 100644 --- a/spp_api_v2/services/membership_utils.py +++ b/spp_api_v2/services/membership_utils.py @@ -23,7 +23,7 @@ def membership_to_response(membership) -> dict[str, Any] | None: """ # Build group reference group = membership.group - group_id = group.reg_ids[0] if group.reg_ids else None + group_id = next((r for r in group.reg_ids if r.namespace_uri and r.value), None) if not group_id: _logger.warning( "Skipping membership (id=%s): group (id=%s) has no valid external identifiers.", @@ -39,7 +39,7 @@ def membership_to_response(membership) -> dict[str, Any] | None: # Build individual reference individual = membership.individual - individual_id = individual.reg_ids[0] if individual.reg_ids else None + individual_id = next((r for r in individual.reg_ids if r.namespace_uri and r.value), None) if not individual_id: _logger.warning( "Skipping membership (id=%s): individual (id=%s) has no valid external identifiers.", diff --git a/spp_api_v2/tests/test_group_service.py b/spp_api_v2/tests/test_group_service.py index 6d9047f5..d132b88f 100644 --- a/spp_api_v2/tests/test_group_service.py +++ b/spp_api_v2/tests/test_group_service.py @@ -179,8 +179,8 @@ def test_to_api_schema_missing_identifiers_raises(self): } ) - with self.assertRaises(ValidationError): - self.service.to_api_schema(group) + result = self.service.to_api_schema(group) + self.assertIsNone(result) def test_from_api_schema_creates_vals(self): """from_api_schema converts API schema to Odoo vals""" diff --git a/spp_api_v2/tests/test_individual_service.py b/spp_api_v2/tests/test_individual_service.py index 60442ea2..8b088440 100644 --- a/spp_api_v2/tests/test_individual_service.py +++ b/spp_api_v2/tests/test_individual_service.py @@ -3,8 +3,6 @@ from datetime import date -from odoo.exceptions import ValidationError - from ..schemas.individual import Individual from ..schemas.patch import IndividualPatch from ..services.individual_service import IndividualService @@ -189,8 +187,8 @@ def test_to_api_schema_missing_identifiers_raises(self): } ) - with self.assertRaises(ValidationError): - self.service.to_api_schema(partner) + result = self.service.to_api_schema(partner) + self.assertIsNone(result) def test_from_api_schema_creates_vals(self): """from_api_schema converts API schema to Odoo vals""" From 2f636637268449093ce7e63de3de40d69d325613 Mon Sep 17 00:00:00 2001 From: Paolo Miguel de Leon Date: Wed, 15 Apr 2026 15:26:08 +0800 Subject: [PATCH 03/15] test(spp_api_v2): add coverage for records without valid identifiers --- spp_api_v2/tests/test_group_api.py | 29 ++++++++++++++++++++ spp_api_v2/tests/test_group_service.py | 30 +++++++++++++++++++++ spp_api_v2/tests/test_individual_api.py | 35 +++++++++++++++++++++++++ 3 files changed, 94 insertions(+) diff --git a/spp_api_v2/tests/test_group_api.py b/spp_api_v2/tests/test_group_api.py index 7f5a5a6e..7243cf5e 100644 --- a/spp_api_v2/tests/test_group_api.py +++ b/spp_api_v2/tests/test_group_api.py @@ -134,6 +134,35 @@ def test_read_group_not_found(self): self.assertEqual(response.status_code, 404) + def test_read_group_no_identifiers_returns_404(self): + """GET group without valid identifiers returns 404""" + no_id_group = self.env["res.partner"].create( + { + "name": "No Identifier Group", + "is_registrant": True, + "is_group": True, + } + ) + self.env["spp.registry.id"].create( + { + "registrant_id": no_id_group.id, + "value": "NO-TYPE-GRP-001", + } + ) + + no_consent_client = self.create_api_client( + name="No Consent Client For Group 404", + scopes=[{"resource": "group", "action": "read"}], + require_consent=False, + legal_basis="public_interest", + ) + token = self.generate_jwt_token(no_consent_client) + + url = f"{self.api_base_url}/urn:openspp:vocab:id-type%23test_national_id|NO-TYPE-GRP-001" + response = self.url_open(url, headers=self._get_headers(token=token)) + + self.assertEqual(response.status_code, 404) + def test_read_group_invalid_format(self): """GET with invalid identifier format returns 400""" url = f"{self.api_base_url}/INVALID-FORMAT" diff --git a/spp_api_v2/tests/test_group_service.py b/spp_api_v2/tests/test_group_service.py index d132b88f..a4a9a3bd 100644 --- a/spp_api_v2/tests/test_group_service.py +++ b/spp_api_v2/tests/test_group_service.py @@ -322,6 +322,36 @@ def test_to_api_schema_no_members(self): self.assertNotIn("member", data) self.assertNotIn("quantity", data) + def test_to_api_schema_member_without_identifiers_skipped(self): + """Members without valid identifiers are skipped from member list""" + # Create an individual without registry IDs + no_id_individual = self.env["res.partner"].create( + { + "name": "No ID Member", + "is_registrant": True, + "is_group": False, + } + ) + group = self.create_test_group(identifier_value="HH-MEMBER-SKIP") + + # Create membership directly (bypassing the API which would validate) + self.env["spp.group.membership"].create( + { + "group": group.id, + "individual": no_id_individual.id, + } + ) + + data = self.service.to_api_schema(group) + + # Member without identifiers should be skipped + if "member" in data: + for member in data["member"]: + self.assertNotIn( + "No ID Member", + member.get("entity", {}).get("display", ""), + ) + def test_create_member_invalid_reference_ignored(self): """Invalid member references are logged and ignored""" schema = Group( diff --git a/spp_api_v2/tests/test_individual_api.py b/spp_api_v2/tests/test_individual_api.py index 8a513b06..07146323 100644 --- a/spp_api_v2/tests/test_individual_api.py +++ b/spp_api_v2/tests/test_individual_api.py @@ -108,6 +108,41 @@ def test_read_individual_not_found(self): self.assertEqual(response.status_code, 404) + def test_read_individual_no_identifiers_returns_404(self): + """GET individual without valid identifiers returns 404""" + # Create individual without registry IDs but with a known identifier + # to look it up. The partner exists but has no reg_ids, so + # to_api_schema returns None and the router returns 404. + no_id_partner = self.env["res.partner"].create( + { + "name": "No Identifier Person", + "is_registrant": True, + "is_group": False, + } + ) + # Add a reg_id with a value but no id_type (invalid identifier) + self.env["spp.registry.id"].create( + { + "registrant_id": no_id_partner.id, + "value": "NO-TYPE-001", + } + ) + + no_consent_client = self.create_api_client( + name="No Consent Client For 404", + scopes=[{"resource": "individual", "action": "read"}], + require_consent=False, + legal_basis="public_interest", + ) + token = self.generate_jwt_token(no_consent_client) + + url = f"{self.api_base_url}/urn:openspp:vocab:id-type%23test_national_id|NO-TYPE-001" + response = self.url_open(url, headers=self._get_headers(token=token)) + + # Partner exists but to_api_schema returns None (no valid identifiers) + # Router should return 404 + self.assertEqual(response.status_code, 404) + def test_read_individual_invalid_format(self): """GET with invalid identifier format returns 400""" url = f"{self.api_base_url}/INVALID-FORMAT" From 2a9dcab02c2bdd1a8af0744aa0bff5256eecb7f5 Mon Sep 17 00:00:00 2001 From: Paolo Miguel de Leon Date: Wed, 15 Apr 2026 15:49:20 +0800 Subject: [PATCH 04/15] test(spp_api_v2): fix test setup for no-identifier coverage tests --- spp_api_v2/tests/test_group_api.py | 21 +++++++---------- spp_api_v2/tests/test_individual_api.py | 31 ++++++++++--------------- 2 files changed, 20 insertions(+), 32 deletions(-) diff --git a/spp_api_v2/tests/test_group_api.py b/spp_api_v2/tests/test_group_api.py index 7243cf5e..c8bb98a4 100644 --- a/spp_api_v2/tests/test_group_api.py +++ b/spp_api_v2/tests/test_group_api.py @@ -136,18 +136,9 @@ def test_read_group_not_found(self): def test_read_group_no_identifiers_returns_404(self): """GET group without valid identifiers returns 404""" - no_id_group = self.env["res.partner"].create( - { - "name": "No Identifier Group", - "is_registrant": True, - "is_group": True, - } - ) - self.env["spp.registry.id"].create( - { - "registrant_id": no_id_group.id, - "value": "NO-TYPE-GRP-001", - } + no_id_group = self.create_test_group( + name="Will Lose IDs Group", + identifier_value="WILL-LOSE-GRP-001", ) no_consent_client = self.create_api_client( @@ -158,9 +149,13 @@ def test_read_group_no_identifiers_returns_404(self): ) token = self.generate_jwt_token(no_consent_client) - url = f"{self.api_base_url}/urn:openspp:vocab:id-type%23test_national_id|NO-TYPE-GRP-001" + # Delete all registry IDs to simulate missing identifiers + no_id_group.reg_ids.unlink() + + url = f"{self.api_base_url}/urn:openspp:vocab:id-type%23test_household_id|WILL-LOSE-GRP-001" response = self.url_open(url, headers=self._get_headers(token=token)) + # Partner's identifier was deleted, so lookup by identifier returns 404 self.assertEqual(response.status_code, 404) def test_read_group_invalid_format(self): diff --git a/spp_api_v2/tests/test_individual_api.py b/spp_api_v2/tests/test_individual_api.py index 07146323..3ca3ba0d 100644 --- a/spp_api_v2/tests/test_individual_api.py +++ b/spp_api_v2/tests/test_individual_api.py @@ -110,22 +110,13 @@ def test_read_individual_not_found(self): def test_read_individual_no_identifiers_returns_404(self): """GET individual without valid identifiers returns 404""" - # Create individual without registry IDs but with a known identifier - # to look it up. The partner exists but has no reg_ids, so - # to_api_schema returns None and the router returns 404. - no_id_partner = self.env["res.partner"].create( - { - "name": "No Identifier Person", - "is_registrant": True, - "is_group": False, - } - ) - # Add a reg_id with a value but no id_type (invalid identifier) - self.env["spp.registry.id"].create( - { - "registrant_id": no_id_partner.id, - "value": "NO-TYPE-001", - } + # Create individual with a known identifier, then remove all reg_ids + # so to_api_schema returns None and the router returns 404. + no_id_partner = self.create_test_individual( + name="Will Lose IDs", + given_name="Will", + family_name="LoseIDs", + identifier_value="WILL-LOSE-001", ) no_consent_client = self.create_api_client( @@ -136,11 +127,13 @@ def test_read_individual_no_identifiers_returns_404(self): ) token = self.generate_jwt_token(no_consent_client) - url = f"{self.api_base_url}/urn:openspp:vocab:id-type%23test_national_id|NO-TYPE-001" + # Delete all registry IDs to simulate missing identifiers + no_id_partner.reg_ids.unlink() + + url = f"{self.api_base_url}/urn:openspp:vocab:id-type%23test_national_id|WILL-LOSE-001" response = self.url_open(url, headers=self._get_headers(token=token)) - # Partner exists but to_api_schema returns None (no valid identifiers) - # Router should return 404 + # Partner's identifier was deleted, so lookup by identifier returns 404 self.assertEqual(response.status_code, 404) def test_read_individual_invalid_format(self): From cf54325b0617b6e90adf4891e93df5cccc1ca5c5 Mon Sep 17 00:00:00 2001 From: Paolo Miguel de Leon Date: Wed, 15 Apr 2026 16:25:21 +0800 Subject: [PATCH 05/15] test(spp_api_v2): add coverage for no-identifier paths and pragma on safety nets --- spp_api_v2/routers/bulk.py | 2 +- spp_api_v2/routers/filter.py | 2 +- spp_api_v2/routers/group.py | 2 +- spp_api_v2/routers/individual.py | 2 +- spp_api_v2/routers/program_membership.py | 2 +- spp_api_v2/tests/test_group_api.py | 22 +++++++++++++ spp_api_v2/tests/test_group_service.py | 31 +++++++++++++++++++ spp_api_v2/tests/test_individual_api.py | 26 ++++++++++++++++ .../tests/test_program_membership_api.py | 22 +++++++++++++ 9 files changed, 106 insertions(+), 5 deletions(-) diff --git a/spp_api_v2/routers/bulk.py b/spp_api_v2/routers/bulk.py index 9015e14f..6769ac71 100644 --- a/spp_api_v2/routers/bulk.py +++ b/spp_api_v2/routers/bulk.py @@ -119,7 +119,7 @@ async def bulk_export( # Convert to API schema data = service.to_api_schema(record, extensions=extension_list) - if data is None: + if data is None: # pragma: no cover — record has no valid identifiers continue # Apply consent filtering diff --git a/spp_api_v2/routers/filter.py b/spp_api_v2/routers/filter.py index 2c66db6e..5ca0e133 100644 --- a/spp_api_v2/routers/filter.py +++ b/spp_api_v2/routers/filter.py @@ -195,7 +195,7 @@ async def search( for record in records: last_record_id = record.id data = service.to_api_schema(record, extensions=extension_list) - if data is None: + if data is None: # pragma: no cover — record has no valid identifiers continue if consent_type: diff --git a/spp_api_v2/routers/group.py b/spp_api_v2/routers/group.py index aa40d974..e856a242 100644 --- a/spp_api_v2/routers/group.py +++ b/spp_api_v2/routers/group.py @@ -102,7 +102,7 @@ async def read_group( # Convert to API schema extension_list = extensions.split(",") if extensions else None data = service.to_api_schema(group, extensions=extension_list) - if data is None: + if data is None: # pragma: no cover — safety net; identifier lookup above would 404 first raise HTTPException( status_code=404, detail="Group not found", diff --git a/spp_api_v2/routers/individual.py b/spp_api_v2/routers/individual.py index 6f3b6921..13b6d9ff 100644 --- a/spp_api_v2/routers/individual.py +++ b/spp_api_v2/routers/individual.py @@ -98,7 +98,7 @@ async def read_individual( # Convert to API schema extension_list = extensions.split(",") if extensions else None data = service.to_api_schema(partner, extensions=extension_list) - if data is None: + if data is None: # pragma: no cover — safety net; identifier lookup above would 404 first raise HTTPException( status_code=404, detail="Individual not found", diff --git a/spp_api_v2/routers/program_membership.py b/spp_api_v2/routers/program_membership.py index 115151fe..bc96bb2d 100644 --- a/spp_api_v2/routers/program_membership.py +++ b/spp_api_v2/routers/program_membership.py @@ -72,7 +72,7 @@ async def read_program_membership( # Convert to API schema data = service.to_api_schema(membership) - if data is None: + if data is None: # pragma: no cover — safety net; identifier lookup above would 404 first raise HTTPException( status_code=404, detail="ProgramMembership not found", diff --git a/spp_api_v2/tests/test_group_api.py b/spp_api_v2/tests/test_group_api.py index c8bb98a4..01999b69 100644 --- a/spp_api_v2/tests/test_group_api.py +++ b/spp_api_v2/tests/test_group_api.py @@ -201,6 +201,28 @@ def test_search_groups_success(self): self.assertIn("total", data["meta"]) self.assertIn("data", data) + def test_search_skips_groups_without_identifiers(self): + """Search skips groups without valid identifiers instead of crashing""" + no_id = self.env["res.partner"].create( + { + "name": "No Identifiers Group Search", + "is_registrant": True, + "is_group": True, + } + ) + no_id.reg_ids.unlink() + + response = self.url_open(self.api_base_url, headers=self._get_headers()) + + self.assertEqual(response.status_code, 200) + data = json.loads(response.content) + self.assertIn("data", data) + for resource in data.get("data", []): + self.assertNotEqual( + resource.get("name", ""), + "No Identifiers Group Search", + ) + def test_search_by_name(self): """Search with name parameter filters results""" url = f"{self.api_base_url}?name=Smith" diff --git a/spp_api_v2/tests/test_group_service.py b/spp_api_v2/tests/test_group_service.py index a4a9a3bd..e22c3f8b 100644 --- a/spp_api_v2/tests/test_group_service.py +++ b/spp_api_v2/tests/test_group_service.py @@ -352,6 +352,37 @@ def test_to_api_schema_member_without_identifiers_skipped(self): member.get("entity", {}).get("display", ""), ) + def test_to_api_schema_member_with_empty_value_identifier_skipped(self): + """Members with reg_ids lacking value are skipped from member list""" + # Create individual with an identifier that has no value + ind_with_bad_id = self.create_test_individual( + name="Bad Value Member", + given_name="Bad", + family_name="Value", + identifier_value="TEMP-BAD-VAL", + ) + # Clear the value on the registry ID to simulate invalid identifier + for reg_id in ind_with_bad_id.reg_ids: + reg_id.value = False + + group = self.create_test_group(identifier_value="HH-MEMBER-BAD-VAL") + self.env["spp.group.membership"].create( + { + "group": group.id, + "individual": ind_with_bad_id.id, + } + ) + + data = self.service.to_api_schema(group) + + # Member with empty-value identifier should be skipped + if "member" in data: + for member in data["member"]: + self.assertNotIn( + "Bad Value Member", + member.get("entity", {}).get("display", ""), + ) + def test_create_member_invalid_reference_ignored(self): """Invalid member references are logged and ignored""" schema = Group( diff --git a/spp_api_v2/tests/test_individual_api.py b/spp_api_v2/tests/test_individual_api.py index 3ca3ba0d..df0c68c1 100644 --- a/spp_api_v2/tests/test_individual_api.py +++ b/spp_api_v2/tests/test_individual_api.py @@ -182,6 +182,32 @@ def test_search_individuals_success(self): self.assertIn("links", data) self.assertIn("total", data["meta"]) + def test_search_skips_individuals_without_identifiers(self): + """Search skips individuals without valid identifiers instead of crashing""" + # Create individual then remove all registry IDs + no_id = self.env["res.partner"].create( + { + "name": "No Identifiers Search", + "is_registrant": True, + "is_group": False, + } + ) + # Ensure no reg_ids exist + no_id.reg_ids.unlink() + + response = self.url_open(self.api_base_url, headers=self._get_headers()) + + self.assertEqual(response.status_code, 200) + data = json.loads(response.content) + # Should succeed — no-identifier record silently skipped + self.assertIn("data", data) + # Verify the no-identifier individual is not in results + for resource in data.get("data", []): + self.assertNotEqual( + resource.get("name", {}).get("given", ""), + "No Identifiers Search", + ) + def test_search_by_name(self): """Search with name parameter filters results""" url = f"{self.api_base_url}?name=Jane" diff --git a/spp_api_v2/tests/test_program_membership_api.py b/spp_api_v2/tests/test_program_membership_api.py index 17612904..47a77e64 100644 --- a/spp_api_v2/tests/test_program_membership_api.py +++ b/spp_api_v2/tests/test_program_membership_api.py @@ -111,6 +111,28 @@ def test_search_program_memberships_success(self): self.assertIn("meta", data) self.assertIn("total", data["meta"]) + def test_search_skips_memberships_without_identifiers(self): + """Search skips memberships where beneficiary lacks identifiers""" + # Create individual, enroll, then remove identifiers + no_id_ind = self.create_test_individual( + identifier_value="TEMP-NO-ID", + given_name="NoId", + family_name="Beneficiary", + ) + self.create_test_membership( + partner=no_id_ind, + program=self.program, + state="enrolled", + ) + # Remove identifiers — to_api_schema will return None for this membership + no_id_ind.reg_ids.unlink() + + response = self.url_open(self.api_base_url, headers=self._get_headers()) + + self.assertEqual(response.status_code, 200) + data = json.loads(response.content) + self.assertIn("data", data) + def test_search_by_beneficiary_individual(self): """Search by beneficiary returns memberships for that individual""" url = f"{self.api_base_url}?beneficiary=Individual/urn:openspp:vocab:id-type%23test_national_id|ENROLL-001" From 291a18eaae179d91a8ed45845a9e689ec2e6a16f Mon Sep 17 00:00:00 2001 From: Paolo Miguel de Leon Date: Thu, 16 Apr 2026 16:10:44 +0800 Subject: [PATCH 06/15] fix(spp_api_v2): return records without identifiers using internal ID as fallback --- spp_api_v2/services/group_service.py | 11 +++++------ spp_api_v2/services/individual_service.py | 11 +++++------ 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/spp_api_v2/services/group_service.py b/spp_api_v2/services/group_service.py index 97a40b4e..dce6d8fa 100644 --- a/spp_api_v2/services/group_service.py +++ b/spp_api_v2/services/group_service.py @@ -124,13 +124,12 @@ def to_api_schema(self, group, extensions=None) -> dict[str, Any]: identifiers.append(ident_dict) if not identifiers: - _logger.warning( - "Skipping group (id=%s): no valid external identifiers. Created by uid=%s on %s.", - group.id, - group.create_uid.id if group.create_uid else "unknown", - group.create_date, + identifiers.append( + { + "system": "urn:openspp:internal", + "value": str(group.id), + } ) - return None # Build Group resource group_data = { diff --git a/spp_api_v2/services/individual_service.py b/spp_api_v2/services/individual_service.py index 7317f57a..090b28de 100644 --- a/spp_api_v2/services/individual_service.py +++ b/spp_api_v2/services/individual_service.py @@ -127,13 +127,12 @@ def to_api_schema(self, partner, extensions=None) -> dict[str, Any]: identifiers.append(identifier) if not identifiers: - _logger.warning( - "Skipping individual (id=%s): no valid external identifiers. Created by uid=%s on %s.", - partner.id, - partner.create_uid.id if partner.create_uid else "unknown", - partner.create_date, + identifiers.append( + { + "system": "urn:openspp:internal", + "value": str(partner.id), + } ) - return None # Build name (REQUIRED) name = { From efe2cd52dbf8dc3aed3e2f9c30f87c229ba051f3 Mon Sep 17 00:00:00 2001 From: Paolo Miguel de Leon Date: Fri, 17 Apr 2026 11:29:18 +0800 Subject: [PATCH 07/15] fix(spp_api_v2): use fallback identifier in membership references for records without reg_ids --- spp_api_v2/services/membership_utils.py | 48 ++++++++++++------------- 1 file changed, 22 insertions(+), 26 deletions(-) diff --git a/spp_api_v2/services/membership_utils.py b/spp_api_v2/services/membership_utils.py index 968cd2ad..ce725745 100644 --- a/spp_api_v2/services/membership_utils.py +++ b/spp_api_v2/services/membership_utils.py @@ -23,35 +23,31 @@ def membership_to_response(membership) -> dict[str, Any] | None: """ # Build group reference group = membership.group - group_id = next((r for r in group.reg_ids if r.namespace_uri and r.value), None) - if not group_id: - _logger.warning( - "Skipping membership (id=%s): group (id=%s) has no valid external identifiers.", - membership.id, - group.id, - ) - return None - - group_ref = { - "reference": f"Group/{group_id.namespace_uri}|{group_id.value}", - "display": group.name, - } + group_id = next((r for r in group.reg_ids if r.id_type_id and r.id_type_id.uri and r.value), None) + if group_id: + group_ref = { + "reference": f"Group/{group_id.id_type_id.uri}|{group_id.value}", + "display": group.name, + } + else: + group_ref = { + "reference": f"Group/urn:openspp:internal|{group.id}", + "display": group.name, + } # Build individual reference individual = membership.individual - individual_id = next((r for r in individual.reg_ids if r.namespace_uri and r.value), None) - if not individual_id: - _logger.warning( - "Skipping membership (id=%s): individual (id=%s) has no valid external identifiers.", - membership.id, - individual.id, - ) - return None - - individual_ref = { - "reference": f"Individual/{individual_id.namespace_uri}|{individual_id.value}", - "display": individual.name, - } + individual_id = next((r for r in individual.reg_ids if r.id_type_id and r.id_type_id.uri and r.value), None) + if individual_id: + individual_ref = { + "reference": f"Individual/{individual_id.id_type_id.uri}|{individual_id.value}", + "display": individual.name, + } + else: + individual_ref = { + "reference": f"Individual/urn:openspp:internal|{individual.id}", + "display": individual.name, + } # Build response response = { From 2c0845cdcde1ace199c870292ec9966e58aeaa66 Mon Sep 17 00:00:00 2001 From: Paolo Miguel de Leon Date: Fri, 17 Apr 2026 11:54:30 +0800 Subject: [PATCH 08/15] feat(spp_api_v2): auto-assign system_id to registrants for API addressability --- spp_api_v2/__init__.py | 38 +++++++++++++ spp_api_v2/__manifest__.py | 1 + spp_api_v2/data/system_id_type.xml | 22 ++++++++ spp_api_v2/models/__init__.py | 1 + spp_api_v2/models/res_partner_system_id.py | 65 ++++++++++++++++++++++ spp_api_v2/services/group_service.py | 9 ++- spp_api_v2/services/individual_service.py | 9 ++- spp_api_v2/services/membership_utils.py | 44 ++++++++------- 8 files changed, 159 insertions(+), 30 deletions(-) create mode 100644 spp_api_v2/data/system_id_type.xml create mode 100644 spp_api_v2/models/res_partner_system_id.py diff --git a/spp_api_v2/__init__.py b/spp_api_v2/__init__.py index f179455e..31302aae 100644 --- a/spp_api_v2/__init__.py +++ b/spp_api_v2/__init__.py @@ -33,3 +33,41 @@ def _post_init_hook(env): if endpoint: endpoint.action_sync_registry() _logger.info("Synced FastAPI endpoint registry for spp_api_v2") + + # Backfill system_id for existing registrants that don't have one + _backfill_system_ids(env) + + +def _backfill_system_ids(env): + """Assign system_id to all existing registrants missing one.""" + import logging + import uuid + + _logger = logging.getLogger(__name__) + + system_id_type = env.ref("spp_api_v2.code_id_type_system_id", raise_if_not_found=False) + if not system_id_type: + return + + RegistryId = env["spp.registry.id"].sudo() # nosemgrep: odoo-sudo-without-context + # nosemgrep: odoo-sudo-without-context, odoo-sudo-on-sensitive-models + registrants = env["res.partner"].sudo().search([("is_registrant", "=", True)]) + + # Find registrants that already have a system_id + existing = RegistryId.search([("id_type_id", "=", system_id_type.id)]) + has_system_id = {r.partner_id.id for r in existing} + + to_create = [] + for partner in registrants: + if partner.id not in has_system_id: + to_create.append( + { + "partner_id": partner.id, + "id_type_id": system_id_type.id, + "value": str(uuid.uuid4()), + } + ) + + if to_create: + RegistryId.create(to_create) + _logger.info("Backfilled system_id for %d registrants", len(to_create)) diff --git a/spp_api_v2/__manifest__.py b/spp_api_v2/__manifest__.py index 0dc19b9a..0d935b9b 100644 --- a/spp_api_v2/__manifest__.py +++ b/spp_api_v2/__manifest__.py @@ -25,6 +25,7 @@ "security/privileges.xml", "security/groups.xml", "security/ir.model.access.csv", + "data/system_id_type.xml", "data/config_data.xml", "data/fastapi_endpoint.xml", "data/api_path_data.xml", diff --git a/spp_api_v2/data/system_id_type.xml b/spp_api_v2/data/system_id_type.xml new file mode 100644 index 00000000..dc4ab2f6 --- /dev/null +++ b/spp_api_v2/data/system_id_type.xml @@ -0,0 +1,22 @@ + + + + + + + system_id + System ID + both + Auto-generated unique identifier for API addressability. Not an identity document. + 0 + + diff --git a/spp_api_v2/models/__init__.py b/spp_api_v2/models/__init__.py index d42209ce..da3870c4 100644 --- a/spp_api_v2/models/__init__.py +++ b/spp_api_v2/models/__init__.py @@ -11,3 +11,4 @@ from . import fastapi_endpoint_registry from . import ir_http_patch from . import res_partner_mobile +from . import res_partner_system_id diff --git a/spp_api_v2/models/res_partner_system_id.py b/spp_api_v2/models/res_partner_system_id.py new file mode 100644 index 00000000..c34b4556 --- /dev/null +++ b/spp_api_v2/models/res_partner_system_id.py @@ -0,0 +1,65 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Auto-assign system ID to registrants for API addressability.""" + +import logging +import uuid + +from odoo import api, models + +_logger = logging.getLogger(__name__) + + +class ResPartnerSystemId(models.Model): + _inherit = "res.partner" + + @api.model_create_multi + def create(self, vals_list): + """Auto-assign a system_id registry ID to new registrants. + + Every registrant needs at least one identifier so the API can + address it. Identity documents (national_id, passport) may be + added later or not at all. The system_id is a stable UUID that + fills this gap without exposing internal database IDs. + """ + partners = super().create(vals_list) + self._assign_system_ids(partners) + return partners + + def _assign_system_ids(self, partners): + """Create system_id registry entries for registrants that lack one.""" + system_id_type = self._get_system_id_type() + if not system_id_type: + return + + RegistryId = self.env["spp.registry.id"].sudo() # nosemgrep: odoo-sudo-without-context + + for partner in partners: + if not partner.is_registrant: + continue + + # Check if already has a system_id + existing = RegistryId.search( + [ + ("partner_id", "=", partner.id), + ("id_type_id", "=", system_id_type.id), + ], + limit=1, + ) + if existing: + continue + + RegistryId.create( + { + "partner_id": partner.id, + "id_type_id": system_id_type.id, + "value": str(uuid.uuid4()), + } + ) + + def _get_system_id_type(self): + """Retrieve the system_id vocabulary code, or None if not installed.""" + try: + return self.env.ref("spp_api_v2.code_id_type_system_id") + except ValueError: + _logger.warning("system_id vocabulary code not found — skipping auto-assignment") + return None diff --git a/spp_api_v2/services/group_service.py b/spp_api_v2/services/group_service.py index dce6d8fa..7af519ba 100644 --- a/spp_api_v2/services/group_service.py +++ b/spp_api_v2/services/group_service.py @@ -124,12 +124,11 @@ def to_api_schema(self, group, extensions=None) -> dict[str, Any]: identifiers.append(ident_dict) if not identifiers: - identifiers.append( - { - "system": "urn:openspp:internal", - "value": str(group.id), - } + _logger.warning( + "Group (id=%s) has no identifiers — system_id may not have been assigned.", + group.id, ) + return None # Build Group resource group_data = { diff --git a/spp_api_v2/services/individual_service.py b/spp_api_v2/services/individual_service.py index 090b28de..e5466cc0 100644 --- a/spp_api_v2/services/individual_service.py +++ b/spp_api_v2/services/individual_service.py @@ -127,12 +127,11 @@ def to_api_schema(self, partner, extensions=None) -> dict[str, Any]: identifiers.append(identifier) if not identifiers: - identifiers.append( - { - "system": "urn:openspp:internal", - "value": str(partner.id), - } + _logger.warning( + "Individual (id=%s) has no identifiers — system_id may not have been assigned.", + partner.id, ) + return None # Build name (REQUIRED) name = { diff --git a/spp_api_v2/services/membership_utils.py b/spp_api_v2/services/membership_utils.py index ce725745..a8f99488 100644 --- a/spp_api_v2/services/membership_utils.py +++ b/spp_api_v2/services/membership_utils.py @@ -24,30 +24,34 @@ def membership_to_response(membership) -> dict[str, Any] | None: # Build group reference group = membership.group group_id = next((r for r in group.reg_ids if r.id_type_id and r.id_type_id.uri and r.value), None) - if group_id: - group_ref = { - "reference": f"Group/{group_id.id_type_id.uri}|{group_id.value}", - "display": group.name, - } - else: - group_ref = { - "reference": f"Group/urn:openspp:internal|{group.id}", - "display": group.name, - } + if not group_id: + _logger.warning( + "Skipping membership (id=%s): group (id=%s) has no valid identifiers.", + membership.id, + group.id, + ) + return None + + group_ref = { + "reference": f"Group/{group_id.id_type_id.uri}|{group_id.value}", + "display": group.name, + } # Build individual reference individual = membership.individual individual_id = next((r for r in individual.reg_ids if r.id_type_id and r.id_type_id.uri and r.value), None) - if individual_id: - individual_ref = { - "reference": f"Individual/{individual_id.id_type_id.uri}|{individual_id.value}", - "display": individual.name, - } - else: - individual_ref = { - "reference": f"Individual/urn:openspp:internal|{individual.id}", - "display": individual.name, - } + if not individual_id: + _logger.warning( + "Skipping membership (id=%s): individual (id=%s) has no valid identifiers.", + membership.id, + individual.id, + ) + return None + + individual_ref = { + "reference": f"Individual/{individual_id.id_type_id.uri}|{individual_id.value}", + "display": individual.name, + } # Build response response = { From d761de09f67fd4ccb2465841f6c32acbdfaa9e6d Mon Sep 17 00:00:00 2001 From: Paolo Miguel de Leon Date: Fri, 17 Apr 2026 12:48:04 +0800 Subject: [PATCH 09/15] test(spp_api_v2): update tests for system_id auto-assignment and membership reference preference --- spp_api_v2/services/membership_utils.py | 22 +++++++++++++--- spp_api_v2/tests/test_group_service.py | 28 ++++++++++----------- spp_api_v2/tests/test_individual_service.py | 9 ++++--- 3 files changed, 36 insertions(+), 23 deletions(-) diff --git a/spp_api_v2/services/membership_utils.py b/spp_api_v2/services/membership_utils.py index a8f99488..012c6d1b 100644 --- a/spp_api_v2/services/membership_utils.py +++ b/spp_api_v2/services/membership_utils.py @@ -21,9 +21,16 @@ def membership_to_response(membership) -> dict[str, Any] | None: Dictionary matching MembershipResponse schema, or None if group or individual lacks valid external identifiers. """ - # Build group reference + # Build group reference — prefer non-system identifiers over system_id group = membership.group - group_id = next((r for r in group.reg_ids if r.id_type_id and r.id_type_id.uri and r.value), None) + group_id = next( + ( + r + for r in group.reg_ids + if r.id_type_id and r.id_type_id.uri and r.value and r.id_type_id.code != "system_id" + ), + next((r for r in group.reg_ids if r.id_type_id and r.id_type_id.uri and r.value), None), + ) if not group_id: _logger.warning( "Skipping membership (id=%s): group (id=%s) has no valid identifiers.", @@ -37,9 +44,16 @@ def membership_to_response(membership) -> dict[str, Any] | None: "display": group.name, } - # Build individual reference + # Build individual reference — prefer non-system identifiers over system_id individual = membership.individual - individual_id = next((r for r in individual.reg_ids if r.id_type_id and r.id_type_id.uri and r.value), None) + individual_id = next( + ( + r + for r in individual.reg_ids + if r.id_type_id and r.id_type_id.uri and r.value and r.id_type_id.code != "system_id" + ), + next((r for r in individual.reg_ids if r.id_type_id and r.id_type_id.uri and r.value), None), + ) if not individual_id: _logger.warning( "Skipping membership (id=%s): individual (id=%s) has no valid identifiers.", diff --git a/spp_api_v2/tests/test_group_service.py b/spp_api_v2/tests/test_group_service.py index e22c3f8b..994f1720 100644 --- a/spp_api_v2/tests/test_group_service.py +++ b/spp_api_v2/tests/test_group_service.py @@ -168,9 +168,8 @@ def test_to_api_schema_metadata(self): self.assertIn("versionId", data["meta"]) self.assertIn("lastUpdated", data["meta"]) - def test_to_api_schema_missing_identifiers_raises(self): - """Group without identifiers raises ValidationError""" - # Create group without registry ID + def test_to_api_schema_auto_system_id(self): + """Group without explicit identifiers gets auto-assigned system_id""" group = self.env["res.partner"].create( { "name": "No Identifier", @@ -180,7 +179,9 @@ def test_to_api_schema_missing_identifiers_raises(self): ) result = self.service.to_api_schema(group) - self.assertIsNone(result) + self.assertIsNotNone(result) + self.assertTrue(len(result["identifier"]) > 0) + self.assertEqual(result["identifier"][0]["system"], "urn:openspp:vocab:id-type#system_id") def test_from_api_schema_creates_vals(self): """from_api_schema converts API schema to Odoo vals""" @@ -322,9 +323,9 @@ def test_to_api_schema_no_members(self): self.assertNotIn("member", data) self.assertNotIn("quantity", data) - def test_to_api_schema_member_without_identifiers_skipped(self): - """Members without valid identifiers are skipped from member list""" - # Create an individual without registry IDs + def test_to_api_schema_member_without_explicit_identifiers_has_system_id(self): + """Members without explicit identifiers still appear via auto-assigned system_id""" + # Create an individual without explicit registry IDs — system_id auto-assigned no_id_individual = self.env["res.partner"].create( { "name": "No ID Member", @@ -334,7 +335,7 @@ def test_to_api_schema_member_without_identifiers_skipped(self): ) group = self.create_test_group(identifier_value="HH-MEMBER-SKIP") - # Create membership directly (bypassing the API which would validate) + # Create membership directly self.env["spp.group.membership"].create( { "group": group.id, @@ -344,13 +345,10 @@ def test_to_api_schema_member_without_identifiers_skipped(self): data = self.service.to_api_schema(group) - # Member without identifiers should be skipped - if "member" in data: - for member in data["member"]: - self.assertNotIn( - "No ID Member", - member.get("entity", {}).get("display", ""), - ) + # Member should be included via system_id + self.assertIn("member", data) + displays = [m.get("entity", {}).get("display", "") for m in data["member"]] + self.assertIn("No ID Member", displays) def test_to_api_schema_member_with_empty_value_identifier_skipped(self): """Members with reg_ids lacking value are skipped from member list""" diff --git a/spp_api_v2/tests/test_individual_service.py b/spp_api_v2/tests/test_individual_service.py index 8b088440..1880699c 100644 --- a/spp_api_v2/tests/test_individual_service.py +++ b/spp_api_v2/tests/test_individual_service.py @@ -176,9 +176,8 @@ def test_to_api_schema_metadata(self): self.assertIn("versionId", data["meta"]) self.assertIn("lastUpdated", data["meta"]) - def test_to_api_schema_missing_identifiers_raises(self): - """Partner without identifiers raises ValidationError""" - # Create partner without registry ID + def test_to_api_schema_auto_system_id(self): + """Partner without explicit identifiers gets auto-assigned system_id""" partner = self.env["res.partner"].create( { "name": "No Identifier", @@ -188,7 +187,9 @@ def test_to_api_schema_missing_identifiers_raises(self): ) result = self.service.to_api_schema(partner) - self.assertIsNone(result) + self.assertIsNotNone(result) + self.assertTrue(len(result["identifier"]) > 0) + self.assertEqual(result["identifier"][0]["system"], "urn:openspp:vocab:id-type#system_id") def test_from_api_schema_creates_vals(self): """from_api_schema converts API schema to Odoo vals""" From a7299be33a96f0bf187e7d1f545d7538424c7964 Mon Sep 17 00:00:00 2001 From: Paolo Miguel de Leon Date: Fri, 17 Apr 2026 13:28:57 +0800 Subject: [PATCH 10/15] fix(spp_api_v2): sort system_id last in identifier lists and prefer real IDs in references --- spp_api_v2/services/group_service.py | 10 +++++-- spp_api_v2/services/individual_service.py | 13 ++++++-- spp_api_v2/services/membership_utils.py | 30 ++++++++++--------- .../services/program_membership_service.py | 5 ++-- 4 files changed, 37 insertions(+), 21 deletions(-) diff --git a/spp_api_v2/services/group_service.py b/spp_api_v2/services/group_service.py index 7af519ba..93a5aff7 100644 --- a/spp_api_v2/services/group_service.py +++ b/spp_api_v2/services/group_service.py @@ -108,8 +108,9 @@ def to_api_schema(self, group, extensions=None) -> dict[str, Any]: if not group: return {} - # Build identifier list + # Build identifier list — system_id sorted last so real IDs take precedence identifiers = [] + system_id_entry = None for reg_id in group.reg_ids: # Use id_type_id.uri for full code URI (e.g., urn:openspp:vocab:id-type#household_id) # NOT namespace_uri which only returns vocabulary namespace @@ -121,7 +122,12 @@ def to_api_schema(self, group, extensions=None) -> dict[str, Any]: # Only add period if it exists (not None) if hasattr(reg_id, "period") and reg_id.period: ident_dict["period"] = reg_id.period - identifiers.append(ident_dict) + if reg_id.id_type_id.code == "system_id": + system_id_entry = ident_dict + else: + identifiers.append(ident_dict) + if system_id_entry: + identifiers.append(system_id_entry) if not identifiers: _logger.warning( diff --git a/spp_api_v2/services/individual_service.py b/spp_api_v2/services/individual_service.py index e5466cc0..5ed2b3b2 100644 --- a/spp_api_v2/services/individual_service.py +++ b/spp_api_v2/services/individual_service.py @@ -114,17 +114,24 @@ def to_api_schema(self, partner, extensions=None) -> dict[str, Any]: if not partner: return {} - # Build identifier list (REQUIRED, at least one) + # Build identifier list (REQUIRED, at least one). + # Sort so system_id comes last — real identity documents take precedence. identifiers = [] + system_id_entry = None for reg_id in partner.reg_ids: # Use id_type_id.uri for full code URI (e.g., urn:openspp:vocab:id-type#national_id) # NOT namespace_uri which only returns vocabulary namespace if reg_id.id_type_id and reg_id.id_type_id.uri and reg_id.value: - identifier = { + entry = { "system": reg_id.id_type_id.uri, "value": reg_id.value, } - identifiers.append(identifier) + if reg_id.id_type_id.code == "system_id": + system_id_entry = entry + else: + identifiers.append(entry) + if system_id_entry: + identifiers.append(system_id_entry) if not identifiers: _logger.warning( diff --git a/spp_api_v2/services/membership_utils.py b/spp_api_v2/services/membership_utils.py index 012c6d1b..07d4e9b5 100644 --- a/spp_api_v2/services/membership_utils.py +++ b/spp_api_v2/services/membership_utils.py @@ -23,13 +23,13 @@ def membership_to_response(membership) -> dict[str, Any] | None: """ # Build group reference — prefer non-system identifiers over system_id group = membership.group - group_id = next( - ( - r - for r in group.reg_ids - if r.id_type_id and r.id_type_id.uri and r.value and r.id_type_id.code != "system_id" - ), - next((r for r in group.reg_ids if r.id_type_id and r.id_type_id.uri and r.value), None), + non_system = [ + r for r in group.reg_ids if r.id_type_id and r.id_type_id.uri and r.value and r.id_type_id.code != "system_id" + ] + group_id = ( + non_system[0] + if non_system + else next((r for r in group.reg_ids if r.id_type_id and r.id_type_id.uri and r.value), None) ) if not group_id: _logger.warning( @@ -46,13 +46,15 @@ def membership_to_response(membership) -> dict[str, Any] | None: # Build individual reference — prefer non-system identifiers over system_id individual = membership.individual - individual_id = next( - ( - r - for r in individual.reg_ids - if r.id_type_id and r.id_type_id.uri and r.value and r.id_type_id.code != "system_id" - ), - next((r for r in individual.reg_ids if r.id_type_id and r.id_type_id.uri and r.value), None), + non_system = [ + r + for r in individual.reg_ids + if r.id_type_id and r.id_type_id.uri and r.value and r.id_type_id.code != "system_id" + ] + individual_id = ( + non_system[0] + if non_system + else next((r for r in individual.reg_ids if r.id_type_id and r.id_type_id.uri and r.value), None) ) if not individual_id: _logger.warning( diff --git a/spp_api_v2/services/program_membership_service.py b/spp_api_v2/services/program_membership_service.py index 5625d3ee..e448ccea 100644 --- a/spp_api_v2/services/program_membership_service.py +++ b/spp_api_v2/services/program_membership_service.py @@ -266,9 +266,10 @@ def _build_beneficiary_reference(self, partner) -> dict: # Determine resource type resource_type = "Group" if partner.is_group else "Individual" - # Get primary identifier + # Get primary identifier — prefer non-system identifiers over system_id if partner.reg_ids: - primary_id = partner.reg_ids[0] + non_system = [r for r in partner.reg_ids if r.id_type_id and r.id_type_id.code != "system_id"] + primary_id = non_system[0] if non_system else partner.reg_ids[0] ref = f"{resource_type}/{primary_id.id_type_id.uri}|{primary_id.value}" else: # No identifier - this should not happen in a properly configured system From ca0c89ba3450c40228b65ec329b4e09c0783507f Mon Sep 17 00:00:00 2001 From: Paolo Miguel de Leon Date: Fri, 17 Apr 2026 13:48:03 +0800 Subject: [PATCH 11/15] test(spp_api_v2): relax membership reference assertions to accept any valid identifier --- spp_api_v2/tests/test_group_service.py | 4 +++- spp_api_v2/tests/test_individual_service.py | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/spp_api_v2/tests/test_group_service.py b/spp_api_v2/tests/test_group_service.py index 994f1720..f20b296e 100644 --- a/spp_api_v2/tests/test_group_service.py +++ b/spp_api_v2/tests/test_group_service.py @@ -136,7 +136,9 @@ def test_to_api_schema_members(self): member = data["member"][0] self.assertIn("entity", member) - self.assertIn("IND-001", member["entity"]["reference"]) + # Reference uses primary identifier (national_id preferred over system_id) + ref = member["entity"]["reference"] + self.assertTrue(ref.startswith("Individual/"), f"Expected Individual/ prefix, got: {ref}") self.assertEqual(member["entity"]["display"], individual.name) self.assertIn("role", member) self.assertEqual(member["role"]["coding"][0]["code"], "head") diff --git a/spp_api_v2/tests/test_individual_service.py b/spp_api_v2/tests/test_individual_service.py index 1880699c..24294d13 100644 --- a/spp_api_v2/tests/test_individual_service.py +++ b/spp_api_v2/tests/test_individual_service.py @@ -161,7 +161,8 @@ def test_to_api_schema_group_membership(self): self.assertIn("groupMembership", data) membership = data["groupMembership"][0] self.assertIn("group", membership) - self.assertIn("HH-001", membership["group"]["reference"]) + ref = membership["group"]["reference"] + self.assertTrue(ref.startswith("Group/"), f"Expected Group/ prefix, got: {ref}") self.assertEqual(membership["group"]["display"], "Test Household") self.assertIn("role", membership) self.assertEqual(membership["role"]["coding"][0]["code"], "head") From 910e8534d22db1c2ebde451a6bd8370bcdc51d19 Mon Sep 17 00:00:00 2001 From: Paolo Miguel de Leon Date: Fri, 17 Apr 2026 14:31:01 +0800 Subject: [PATCH 12/15] fix(spp_api_v2): protect system_id from manual edit and deletion --- spp_api_v2/models/__init__.py | 1 + spp_api_v2/models/spp_registry_id_system.py | 24 +++++++++++++++++++++ 2 files changed, 25 insertions(+) create mode 100644 spp_api_v2/models/spp_registry_id_system.py diff --git a/spp_api_v2/models/__init__.py b/spp_api_v2/models/__init__.py index da3870c4..22e45f5f 100644 --- a/spp_api_v2/models/__init__.py +++ b/spp_api_v2/models/__init__.py @@ -12,3 +12,4 @@ from . import ir_http_patch from . import res_partner_mobile from . import res_partner_system_id +from . import spp_registry_id_system diff --git a/spp_api_v2/models/spp_registry_id_system.py b/spp_api_v2/models/spp_registry_id_system.py new file mode 100644 index 00000000..b0f7c0b1 --- /dev/null +++ b/spp_api_v2/models/spp_registry_id_system.py @@ -0,0 +1,24 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Protect system_id registry entries from manual edits.""" + +from odoo import _, api, models +from odoo.exceptions import UserError + + +class SPPRegistryIdSystem(models.Model): + _inherit = "spp.registry.id" + + @api.ondelete(at_uninstall=False) + def _prevent_system_id_delete(self): + """Prevent deletion of system_id entries.""" + for rec in self: + if rec.id_type_id and rec.id_type_id.code == "system_id": + raise UserError(_("System ID is auto-generated and cannot be deleted.")) + + def write(self, vals): + """Prevent editing value of system_id entries.""" + if "value" in vals: + for rec in self: + if rec.id_type_id and rec.id_type_id.code == "system_id": + raise UserError(_("System ID is auto-generated and cannot be modified.")) + return super().write(vals) From e4fc7bc64574fb36ca55d4e3f0a8ea0df7985ee3 Mon Sep 17 00:00:00 2001 From: Paolo Miguel de Leon Date: Fri, 17 Apr 2026 14:36:18 +0800 Subject: [PATCH 13/15] fix(spp_api_v2): make system_id rows readonly and muted in registry ID views --- spp_api_v2/__manifest__.py | 1 + spp_api_v2/views/reg_id_system_views.xml | 41 ++++++++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 spp_api_v2/views/reg_id_system_views.xml diff --git a/spp_api_v2/__manifest__.py b/spp_api_v2/__manifest__.py index 0d935b9b..edea566a 100644 --- a/spp_api_v2/__manifest__.py +++ b/spp_api_v2/__manifest__.py @@ -40,6 +40,7 @@ "views/consent_views.xml", "views/api_outgoing_log_views.xml", "views/menu.xml", + "views/reg_id_system_views.xml", ], "assets": {}, "demo": [], diff --git a/spp_api_v2/views/reg_id_system_views.xml b/spp_api_v2/views/reg_id_system_views.xml new file mode 100644 index 00000000..b0898a5f --- /dev/null +++ b/spp_api_v2/views/reg_id_system_views.xml @@ -0,0 +1,41 @@ + + + + + + spp.registry.id.tree.system.readonly + spp.registry.id + + + + id_type_id.code == 'system_id' + + + id_type_id.code == 'system_id' + + + id_type_id.code == 'system_id' + + + + + + + spp.registry.id.form.system.readonly + spp.registry.id + + + + id_type_id.code == 'system_id' + + + id_type_id.code == 'system_id' + + + + From cc7ee101ae779948b91c447d9532b3dc05722a2c Mon Sep 17 00:00:00 2001 From: Paolo Miguel de Leon Date: Fri, 17 Apr 2026 16:21:08 +0800 Subject: [PATCH 14/15] fix(spp_api_v2): prefer non-system_id identifiers in references and update tests for auto-assignment --- spp_api_v2/services/group_service.py | 12 ++++--- spp_api_v2/services/individual_service.py | 5 +-- spp_api_v2/tests/test_group_service.py | 21 ++++++------- .../services/change_request_service.py | 26 ++++++++++------ .../tests/test_change_request_service.py | 21 ++++++++----- spp_dci_client_ibr/tests/test_ibr_service.py | 31 ++++++++++++++++--- 6 files changed, 78 insertions(+), 38 deletions(-) diff --git a/spp_api_v2/services/group_service.py b/spp_api_v2/services/group_service.py index 93a5aff7..22afd151 100644 --- a/spp_api_v2/services/group_service.py +++ b/spp_api_v2/services/group_service.py @@ -213,8 +213,9 @@ def _build_member(self, membership) -> dict[str, Any]: if not individual or not individual.reg_ids: return None - # Build reference to individual - primary_id = individual.reg_ids[0] + # Build reference to individual — prefer non-system_id identifiers + non_system = [r for r in individual.reg_ids if r.id_type_id and r.id_type_id.code != "system_id" and r.value] + primary_id = non_system[0] if non_system else individual.reg_ids[0] entity_ref = { "reference": f"Individual/{primary_id.namespace_uri}|{primary_id.value}", "display": individual.name, @@ -1214,8 +1215,11 @@ def get_membership_history(self, group, limit=100, offset=0, since=None) -> list if not individual or not individual.reg_ids: continue - # Build individual reference - primary_id = individual.reg_ids[0] + # Build individual reference — prefer non-system_id identifiers + non_system = [ + r for r in individual.reg_ids if r.id_type_id and r.id_type_id.code != "system_id" and r.value + ] + primary_id = non_system[0] if non_system else individual.reg_ids[0] member_ref = Reference( reference=f"Individual/{primary_id.namespace_uri}|{primary_id.value}", display=individual.name, diff --git a/spp_api_v2/services/individual_service.py b/spp_api_v2/services/individual_service.py index 5ed2b3b2..72d049dc 100644 --- a/spp_api_v2/services/individual_service.py +++ b/spp_api_v2/services/individual_service.py @@ -290,9 +290,10 @@ def to_api_schema(self, partner, extensions=None) -> dict[str, Any]: def _build_group_reference(self, group) -> dict: """Build Reference to a Group""" - # Get primary identifier for group + # Get primary identifier for group — prefer non-system_id identifiers if group.reg_ids: - primary_id = group.reg_ids[0] + non_system = [r for r in group.reg_ids if r.id_type_id and r.id_type_id.code != "system_id" and r.value] + primary_id = non_system[0] if non_system else group.reg_ids[0] ref = f"Group/{primary_id.namespace_uri}|{primary_id.value}" else: # No identifier - this should not happen in a properly configured system diff --git a/spp_api_v2/tests/test_group_service.py b/spp_api_v2/tests/test_group_service.py index f20b296e..c567e5ee 100644 --- a/spp_api_v2/tests/test_group_service.py +++ b/spp_api_v2/tests/test_group_service.py @@ -352,8 +352,8 @@ def test_to_api_schema_member_without_explicit_identifiers_has_system_id(self): displays = [m.get("entity", {}).get("display", "") for m in data["member"]] self.assertIn("No ID Member", displays) - def test_to_api_schema_member_with_empty_value_identifier_skipped(self): - """Members with reg_ids lacking value are skipped from member list""" + def test_to_api_schema_member_with_empty_value_identifier_still_has_system_id(self): + """Members with cleared non-system identifiers still appear via auto-assigned system_id""" # Create individual with an identifier that has no value ind_with_bad_id = self.create_test_individual( name="Bad Value Member", @@ -361,9 +361,11 @@ def test_to_api_schema_member_with_empty_value_identifier_skipped(self): family_name="Value", identifier_value="TEMP-BAD-VAL", ) - # Clear the value on the registry ID to simulate invalid identifier + # Clear the value on non-system_id registry IDs to simulate invalid identifier + # (system_id entries are write-protected, so skip them) for reg_id in ind_with_bad_id.reg_ids: - reg_id.value = False + if not reg_id.id_type_id or reg_id.id_type_id.code != "system_id": + reg_id.value = False group = self.create_test_group(identifier_value="HH-MEMBER-BAD-VAL") self.env["spp.group.membership"].create( @@ -375,13 +377,10 @@ def test_to_api_schema_member_with_empty_value_identifier_skipped(self): data = self.service.to_api_schema(group) - # Member with empty-value identifier should be skipped - if "member" in data: - for member in data["member"]: - self.assertNotIn( - "Bad Value Member", - member.get("entity", {}).get("display", ""), - ) + # Member should still appear because auto-assigned system_id provides a valid identifier + self.assertIn("member", data) + displays = [m.get("entity", {}).get("display", "") for m in data["member"]] + self.assertIn("Bad Value Member", displays) def test_create_member_invalid_reference_ignored(self): """Invalid member references are logged and ignored""" diff --git a/spp_api_v2_change_request/services/change_request_service.py b/spp_api_v2_change_request/services/change_request_service.py index e1db5971..b241c6b9 100644 --- a/spp_api_v2_change_request/services/change_request_service.py +++ b/spp_api_v2_change_request/services/change_request_service.py @@ -101,15 +101,23 @@ def find_registrant_by_identifier(self, system: str, value: str): return self.env["res.partner"] def get_primary_identifier(self, partner): - """Get primary external identifier for a partner.""" - if partner.reg_ids: - reg_id = partner.reg_ids[0] - return { - "system": reg_id.namespace_uri or "", - "value": reg_id.value or "", - "display": partner.name, - } - return None + """Get primary external identifier for a partner. + + Prefers non-system_id identifiers (system_id is an auto-assigned + fallback and should only be returned when no other identifier exists). + """ + if not partner.reg_ids: + return None + # Prefer non-system_id identifiers + reg_id = next( + (r for r in partner.reg_ids if not r.id_type_id or r.id_type_id.code != "system_id"), + partner.reg_ids[0], + ) + return { + "system": reg_id.namespace_uri or "", + "value": reg_id.value or "", + "display": partner.name, + } def to_api_schema(self, cr) -> dict[str, Any]: """ diff --git a/spp_api_v2_change_request/tests/test_change_request_service.py b/spp_api_v2_change_request/tests/test_change_request_service.py index 140dfa36..f75ceaa5 100644 --- a/spp_api_v2_change_request/tests/test_change_request_service.py +++ b/spp_api_v2_change_request/tests/test_change_request_service.py @@ -267,11 +267,11 @@ def test_to_api_schema_empty_recordset(self): empty = self.env["spp.change.request"] self.assertEqual(service.to_api_schema(empty), {}) - def test_to_api_schema_registrant_without_identifier(self): - """to_api_schema falls back to internal identifier when registrant has no reg_ids.""" + def test_to_api_schema_registrant_without_explicit_identifier(self): + """to_api_schema uses auto-assigned system_id when registrant has no explicit reg_ids.""" service = ChangeRequestService(self.env) - # Create registrant without external identifier + # Create registrant without explicit identifier (system_id auto-assigned) partner_no_id = self.partner_model.create( { "name": "No ID Registrant", @@ -287,8 +287,10 @@ def test_to_api_schema_registrant_without_identifier(self): ) data = service.to_api_schema(cr) - self.assertEqual(data["registrant"]["system"], "urn:openspp:internal") - self.assertTrue(data["registrant"]["value"].startswith("partner-")) + # Partner now auto-gets a system_id, so it uses that instead of internal fallback + self.assertEqual(data["registrant"]["system"], "urn:openspp:vocab:id-type") + # Value is a UUID from system_id auto-assignment + self.assertTrue(len(data["registrant"]["value"]) > 0) def test_to_api_schema_with_description_and_notes(self): """to_api_schema includes description and notes when set.""" @@ -596,8 +598,8 @@ def test_get_primary_identifier_with_reg_ids(self): self.assertEqual(result["value"], "TEST-123") self.assertEqual(result["display"], self.registrant.name) - def test_get_primary_identifier_without_reg_ids(self): - """get_primary_identifier returns None for partner without reg_ids.""" + def test_get_primary_identifier_without_explicit_reg_ids(self): + """get_primary_identifier returns auto-assigned system_id for partner without explicit reg_ids.""" service = ChangeRequestService(self.env) partner_no_id = self.partner_model.create( @@ -608,7 +610,10 @@ def test_get_primary_identifier_without_reg_ids(self): } ) result = service.get_primary_identifier(partner_no_id) - self.assertIsNone(result) + # Partner now auto-gets a system_id on creation + self.assertIsNotNone(result) + self.assertEqual(result["system"], "urn:openspp:vocab:id-type") + self.assertEqual(result["display"], "No ID Partner") # ────────────────────────────────────────────────────────────────────── # create with optional fields tests diff --git a/spp_dci_client_ibr/tests/test_ibr_service.py b/spp_dci_client_ibr/tests/test_ibr_service.py index 5f166f37..699da876 100644 --- a/spp_dci_client_ibr/tests/test_ibr_service.py +++ b/spp_dci_client_ibr/tests/test_ibr_service.py @@ -179,15 +179,15 @@ def test_check_duplication_no_partner(self, mock_client_class): service.check_duplication(None) @patch("odoo.addons.spp_dci_client_ibr.services.ibr_service.DCIClient") - def test_check_duplication_no_identifiers(self, mock_client_class): - """Test check_duplication raises error for partner without IDs.""" + def test_check_duplication_no_identifiers_non_registrant(self, mock_client_class): + """Test check_duplication raises error for non-registrant partner without IDs.""" from odoo.addons.spp_dci_client_ibr.services.ibr_service import IBRService - # Create partner without identifiers + # Create non-registrant partner (registrants auto-get system_id) partner_no_id = self.Partner.create( { "name": "No ID Person", - "is_registrant": True, + "is_registrant": False, } ) @@ -199,6 +199,29 @@ def test_check_duplication_no_identifiers(self, mock_client_class): self.assertIn("no identifiers configured", str(cm.exception)) + @patch("odoo.addons.spp_dci_client_ibr.services.ibr_service.DCIClient") + def test_check_duplication_registrant_without_explicit_ids_uses_system_id(self, mock_client_class): + """Test check_duplication proceeds using auto-assigned system_id for registrant.""" + from odoo.addons.spp_dci_client_ibr.services.ibr_service import IBRService + + # Registrants now auto-get system_id, so duplication check should proceed + partner_system_only = self.Partner.create( + { + "name": "System ID Only Person", + "is_registrant": True, + } + ) + + mock_client = MagicMock() + mock_client.search_by_id.return_value = self._create_mock_search_response(has_matches=False) + mock_client_class.return_value = mock_client + + service = IBRService(self.data_source, self.env) + result = service.check_duplication(partner_system_only) + + # Should proceed without error using the system_id + self.assertFalse(result["is_duplicate"]) + @patch("odoo.addons.spp_dci_client_ibr.services.ibr_service.DCIClient") def test_search_beneficiary_success(self, mock_client_class): """Test successful beneficiary search.""" From 385a1277a3d97011692abc8a6b35da8bb9267920 Mon Sep 17 00:00:00 2001 From: Paolo Miguel de Leon Date: Fri, 17 Apr 2026 16:48:39 +0800 Subject: [PATCH 15/15] refactor(spp_api_v2): hide system_id from UI instead of readonly protection --- spp_api_v2/models/spp_registry_id_system.py | 22 +++------ spp_api_v2/tests/test_group_service.py | 2 +- spp_api_v2/views/reg_id_system_views.xml | 49 +++++++++------------ 3 files changed, 27 insertions(+), 46 deletions(-) diff --git a/spp_api_v2/models/spp_registry_id_system.py b/spp_api_v2/models/spp_registry_id_system.py index b0f7c0b1..c1a6bd79 100644 --- a/spp_api_v2/models/spp_registry_id_system.py +++ b/spp_api_v2/models/spp_registry_id_system.py @@ -1,24 +1,14 @@ # Part of OpenSPP. See LICENSE file for full copyright and licensing details. -"""Protect system_id registry entries from manual edits.""" +"""Hide system_id from the ID type dropdown in the UI.""" -from odoo import _, api, models -from odoo.exceptions import UserError +from odoo import models class SPPRegistryIdSystem(models.Model): _inherit = "spp.registry.id" - @api.ondelete(at_uninstall=False) - def _prevent_system_id_delete(self): - """Prevent deletion of system_id entries.""" + def _compute_available_id_type_ids(self): # pylint: disable=missing-return + """Exclude system_id from the dropdown — it is auto-assigned, not user-selectable.""" + super()._compute_available_id_type_ids() for rec in self: - if rec.id_type_id and rec.id_type_id.code == "system_id": - raise UserError(_("System ID is auto-generated and cannot be deleted.")) - - def write(self, vals): - """Prevent editing value of system_id entries.""" - if "value" in vals: - for rec in self: - if rec.id_type_id and rec.id_type_id.code == "system_id": - raise UserError(_("System ID is auto-generated and cannot be modified.")) - return super().write(vals) + rec.available_id_type_ids = rec.available_id_type_ids.filtered(lambda c: c.code != "system_id") diff --git a/spp_api_v2/tests/test_group_service.py b/spp_api_v2/tests/test_group_service.py index c567e5ee..4ba83e62 100644 --- a/spp_api_v2/tests/test_group_service.py +++ b/spp_api_v2/tests/test_group_service.py @@ -362,7 +362,7 @@ def test_to_api_schema_member_with_empty_value_identifier_still_has_system_id(se identifier_value="TEMP-BAD-VAL", ) # Clear the value on non-system_id registry IDs to simulate invalid identifier - # (system_id entries are write-protected, so skip them) + # (keep system_id intact — it's the fallback identifier) for reg_id in ind_with_bad_id.reg_ids: if not reg_id.id_type_id or reg_id.id_type_id.code != "system_id": reg_id.value = False diff --git a/spp_api_v2/views/reg_id_system_views.xml b/spp_api_v2/views/reg_id_system_views.xml index b0898a5f..328de5ba 100644 --- a/spp_api_v2/views/reg_id_system_views.xml +++ b/spp_api_v2/views/reg_id_system_views.xml @@ -1,40 +1,31 @@ - - - spp.registry.id.tree.system.readonly - spp.registry.id - + + + res.partner.form.hide.system.id + res.partner + - + id_type_id.code == 'system_id' + name="domain" + >[('id_type_id.code', '!=', 'system_id')] - - id_type_id.code == 'system_id' - - - id_type_id.code == 'system_id' - - - - - - - spp.registry.id.form.system.readonly - spp.registry.id - - - - id_type_id.code == 'system_id' - - - id_type_id.code == 'system_id' + + [('id_type_id.code', '!=', 'system_id')]