Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
95a8d95
fix(spp_api_v2): skip records without identifiers instead of crashing…
pmigueld Apr 15, 2026
b0130c6
fix(spp_api_v2): handle None from to_api_schema in all callers and fi…
pmigueld Apr 15, 2026
2f63663
test(spp_api_v2): add coverage for records without valid identifiers
pmigueld Apr 15, 2026
2a9dcab
test(spp_api_v2): fix test setup for no-identifier coverage tests
pmigueld Apr 15, 2026
cf54325
test(spp_api_v2): add coverage for no-identifier paths and pragma on …
pmigueld Apr 15, 2026
291a18e
fix(spp_api_v2): return records without identifiers using internal ID…
pmigueld Apr 16, 2026
efe2cd5
fix(spp_api_v2): use fallback identifier in membership references for…
pmigueld Apr 17, 2026
2c0845c
feat(spp_api_v2): auto-assign system_id to registrants for API addres…
pmigueld Apr 17, 2026
d761de0
test(spp_api_v2): update tests for system_id auto-assignment and memb…
pmigueld Apr 17, 2026
a7299be
fix(spp_api_v2): sort system_id last in identifier lists and prefer r…
pmigueld Apr 17, 2026
ca0c89b
test(spp_api_v2): relax membership reference assertions to accept any…
pmigueld Apr 17, 2026
910e853
fix(spp_api_v2): protect system_id from manual edit and deletion
pmigueld Apr 17, 2026
e4fc7bc
fix(spp_api_v2): make system_id rows readonly and muted in registry I…
pmigueld Apr 17, 2026
cc7ee10
fix(spp_api_v2): prefer non-system_id identifiers in references and u…
pmigueld Apr 17, 2026
385a127
refactor(spp_api_v2): hide system_id from UI instead of readonly prot…
pmigueld Apr 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions spp_api_v2/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
2 changes: 2 additions & 0 deletions spp_api_v2/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -39,6 +40,7 @@
"views/consent_views.xml",
"views/api_outgoing_log_views.xml",
"views/menu.xml",
"views/reg_id_system_views.xml",
],
"assets": {},
"demo": [],
Expand Down
22 changes: 22 additions & 0 deletions spp_api_v2/data/system_id_type.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8" ?>
<!--
Part of OpenSPP. See LICENSE file for full copyright and licensing details.
-->
<odoo noupdate="1">
<!-- System-generated identifier for API addressability.
Every registrant gets one automatically at creation time.
Unlike identity documents (national_id, passport), this is
not a real-world document — it's an internal stable UUID
that allows the API to address records that don't yet have
any identity documents assigned. -->
<record id="code_id_type_system_id" model="spp.vocabulary.code">
<field name="vocabulary_id" ref="spp_vocabulary.vocab_id_type" />
<field name="code">system_id</field>
<field name="display">System ID</field>
<field name="target_type">both</field>
<field
name="definition"
>Auto-generated unique identifier for API addressability. Not an identity document.</field>
<field name="sequence">0</field>
</record>
</odoo>
2 changes: 2 additions & 0 deletions spp_api_v2/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,5 @@
from . import fastapi_endpoint_registry
from . import ir_http_patch
from . import res_partner_mobile
from . import res_partner_system_id
from . import spp_registry_id_system
65 changes: 65 additions & 0 deletions spp_api_v2/models/res_partner_system_id.py
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions spp_api_v2/models/spp_registry_id_system.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
"""Hide system_id from the ID type dropdown in the UI."""

from odoo import models


class SPPRegistryIdSystem(models.Model):
_inherit = "spp.registry.id"

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:
rec.available_id_type_ids = rec.available_id_type_ids.filtered(lambda c: c.code != "system_id")
2 changes: 2 additions & 0 deletions spp_api_v2/routers/bulk.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: # pragma: no cover — record has no valid identifiers
continue

# Apply consent filtering
filtered_data = consent_service.filter_response(
Expand Down
2 changes: 2 additions & 0 deletions spp_api_v2/routers/filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: # pragma: no cover — record has no valid identifiers
continue

if consent_type:
partner_id = record.id if resource_config["model"] == "res.partner" else None
Expand Down
7 changes: 7 additions & 0 deletions spp_api_v2/routers/group.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: # pragma: no cover — safety net; identifier lookup above would 404 first
raise HTTPException(
status_code=404,
detail="Group not found",
)

# Apply consent filtering
consent_service = ConsentService(env)
Expand Down Expand Up @@ -203,6 +208,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 (
Expand Down
7 changes: 7 additions & 0 deletions spp_api_v2/routers/individual.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: # pragma: no cover — safety net; identifier lookup above would 404 first
raise HTTPException(
status_code=404,
detail="Individual not found",
)

# Apply consent filtering
consent_service = ConsentService(env)
Expand Down Expand Up @@ -218,6 +223,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 (
Expand Down
7 changes: 7 additions & 0 deletions spp_api_v2/routers/program_membership.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ async def read_program_membership(

# Convert to API schema
data = service.to_api_schema(membership)
if data is None: # pragma: no cover — safety net; identifier lookup above would 404 first
raise HTTPException(
status_code=404,
detail="ProgramMembership not found",
)

# Apply consent filtering for the beneficiary
consent_service = ConsentService(env)
Expand Down Expand Up @@ -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
)
Expand Down
28 changes: 21 additions & 7 deletions spp_api_v2/services/group_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -121,10 +122,19 @@ 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:
raise ValidationError(f"Group {group.name} has no valid external identifiers")
_logger.warning(
"Group (id=%s) has no identifiers — system_id may not have been assigned.",
group.id,
)
return None
Comment thread
pmigueld marked this conversation as resolved.

# Build Group resource
group_data = {
Expand Down Expand Up @@ -203,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,
Expand Down Expand Up @@ -1204,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,
Expand Down
28 changes: 20 additions & 8 deletions spp_api_v2/services/individual_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,21 +114,31 @@ 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:
# Must have at least one identifier per spec
raise ValidationError(f"Partner {partner.name} has no valid external identifiers")
_logger.warning(
"Individual (id=%s) has no identifiers — system_id may not have been assigned.",
partner.id,
)
return None
Comment thread
pmigueld marked this conversation as resolved.

# Build name (REQUIRED)
name = {
Expand Down Expand Up @@ -280,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
Expand Down Expand Up @@ -749,7 +760,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
Expand Down
Loading
Loading