Skip to content

Commit 4a98bdb

Browse files
Merge pull request #203 from OpenSPP/fix/spp-dci-dr-spec-envelope
fix(spp_dci_client_dr): parse DCI v1.0.0 spec envelope and record fields
2 parents b3510d8 + 55a219d commit 4a98bdb

8 files changed

Lines changed: 969 additions & 227 deletions

File tree

spp_dci_client_dr/routers/callback.py

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from datetime import UTC, datetime
77
from typing import Annotated
88

9+
from odoo import fields
910
from odoo.api import Environment
1011

1112
from odoo.addons.fastapi.dependencies import odoo_env
@@ -14,6 +15,7 @@
1415
from fastapi import APIRouter, Depends, HTTPException, Request, status
1516

1617
from ..middleware.signature import verify_dr_signature
18+
from ..services.dr_parsing import extract_disability_data, unwrap_search_data
1719

1820
_logger = logging.getLogger(__name__)
1921

@@ -149,8 +151,7 @@ def _process_dr_search_result(env: Environment, result: dict, source_registry: s
149151
)
150152
return
151153

152-
data = result.get("data", {})
153-
reg_records = data.get("reg_records", [])
154+
reg_records = unwrap_search_data(result.get("data"))
154155

155156
for record in reg_records:
156157
# Extract identifiers to find matching partner
@@ -245,11 +246,8 @@ def _update_disability_status(
245246
# Use sudo() for API access - authentication is handled by signature verification
246247
DisabilityStatus = env["spp.dci.disability.status"].sudo() # nosemgrep: odoo-sudo-without-context
247248

248-
# Extract disability data from record
249-
has_disability = record.get("has_disability", False) or record.get("is_pwd", False)
250-
disability_types = record.get("disability_types", [])
251-
functional_scores = record.get("functional_scores", {})
252-
assessment_date = record.get("assessment_date")
249+
# Extract disability data using spec-aware parsing
250+
extracted = extract_disability_data(record)
253251

254252
# Find existing status
255253
existing = DisabilityStatus.search(
@@ -259,16 +257,14 @@ def _update_disability_status(
259257

260258
vals = {
261259
"partner_id": partner.id,
262-
"has_disability": has_disability,
263-
"disability_types": json.dumps(disability_types) if isinstance(disability_types, list) else disability_types,
264-
"functional_scores": json.dumps(functional_scores)
265-
if isinstance(functional_scores, dict)
266-
else functional_scores,
267-
"assessment_date": assessment_date,
260+
"has_disability": extracted["has_disability"],
261+
"disability_types": json.dumps(extracted["disability_types"]),
262+
"functional_scores": json.dumps(extracted["functional_scores"]),
263+
"assessment_date": extracted["assessment_date"],
268264
"source_registry": source_registry,
269265
"raw_data": json.dumps(record),
270266
"state": "synced",
271-
"last_sync_date": datetime.now(UTC),
267+
"last_sync_date": fields.Datetime.now(),
272268
"synced_by": env.user.id,
273269
}
274270

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
from . import dr_parsing
12
from . import dr_service
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
2+
"""Pure parsing helpers for DCI v1.0.0 Disability Registry records.
3+
4+
All functions are stateless and require no Odoo env, making them
5+
independently testable and easy to reuse.
6+
"""
7+
8+
import logging
9+
from datetime import date, datetime
10+
11+
_logger = logging.getLogger(__name__)
12+
13+
# Status values that indicate the person is registered as disabled.
14+
# The SP DCI v1.0.0 spec does not declare an enum; these are the
15+
# spec-aligned workflow tokens. Empty / missing / other values trigger
16+
# the impairment-list fallback in extract_disability_data.
17+
_APPROVED_STATUSES = {"approved", "registered"}
18+
19+
# Status values that explicitly reject disability registration.
20+
_REJECTED_STATUSES = {"rejected", "denied"}
21+
22+
23+
def _coerce_date(value) -> date | None:
24+
"""Coerce a DCI date/datetime value into a ``date`` object.
25+
26+
Accepts ISO date strings (``YYYY-MM-DD``), ISO datetime strings with an
27+
optional trailing ``Z`` (``YYYY-MM-DDTHH:MM:SSZ``), naive/aware datetimes,
28+
and date objects. Returns ``None`` for empty input or unparseable values
29+
(with a WARNING logged).
30+
31+
For tz-aware inputs, the local wall-clock date is returned;
32+
no UTC normalization is applied.
33+
"""
34+
if not value:
35+
return None
36+
if isinstance(value, datetime):
37+
return value.date()
38+
if isinstance(value, date):
39+
return value
40+
try:
41+
return datetime.fromisoformat(str(value).removesuffix("Z")).date()
42+
except ValueError:
43+
_logger.warning("Could not parse date from value: %r", value)
44+
return None
45+
46+
47+
def unwrap_search_data(data) -> list:
48+
"""Extract the reg_records list from a DCI v1.0.0 search response data envelope.
49+
50+
Args:
51+
data: The value of ``search_response[*].data`` from the API response.
52+
Expected to be a dict with a ``reg_records`` key per the spec.
53+
54+
Returns:
55+
list: The contents of ``data["reg_records"]``, or an empty list when
56+
the envelope is absent, empty, or malformed.
57+
"""
58+
if data is None:
59+
return []
60+
61+
if not isinstance(data, dict):
62+
_logger.warning(
63+
"Unexpected type for search response data envelope: %s; expected dict",
64+
type(data).__name__,
65+
)
66+
return []
67+
68+
if not data:
69+
return []
70+
71+
records = data.get("reg_records")
72+
if records is None:
73+
return []
74+
if not isinstance(records, list):
75+
_logger.warning(
76+
"Unexpected type for reg_records: %s; expected list",
77+
type(records).__name__,
78+
)
79+
return []
80+
return records
81+
82+
83+
def extract_disability_data(record: dict) -> dict:
84+
"""Extract structured disability information from a DCI v1.0.0 record.
85+
86+
Args:
87+
record: A single record dict from ``reg_records``.
88+
89+
Returns:
90+
dict with keys:
91+
- ``has_disability`` (bool)
92+
- ``disability_types`` (list[str])
93+
- ``functional_scores`` (dict, always ``{}``: spec has no numeric scores)
94+
- ``assessment_date`` (``date`` | None)
95+
- ``source_registry`` (str | None)
96+
- ``raw_data`` (the input record, unchanged)
97+
"""
98+
# Extract impairment types from disability_details.
99+
# Use `or []` so an explicit null on the wire does not crash the loop.
100+
details = record.get("disability_details") or []
101+
disability_types = [d["impairment_type"] for d in details if isinstance(d, dict) and d.get("impairment_type")]
102+
103+
# Resolve has_disability from the disability_status string
104+
status_str = str(record.get("disability_status", "")).strip().lower()
105+
106+
if status_str in _APPROVED_STATUSES:
107+
has_disability = True
108+
elif status_str in _REJECTED_STATUSES:
109+
has_disability = False
110+
elif status_str == "":
111+
# No explicit status: fall back to impairment list presence
112+
has_disability = bool(disability_types)
113+
else:
114+
_logger.warning(
115+
"Unknown disability_status value: %s; falling back to impairment list",
116+
record.get("disability_status"),
117+
)
118+
has_disability = bool(disability_types)
119+
120+
# Assessment date: prefer last_updated, fall back to registration_date.
121+
# The spec uses ISO datetime strings; coerce to a date for the ORM.
122+
assessment_date = _coerce_date(record.get("last_updated") or record.get("registration_date"))
123+
124+
# Source registry: prefer source_registry, fall back to registry_name
125+
source_registry = record.get("source_registry") or record.get("registry_name")
126+
127+
return {
128+
"has_disability": has_disability,
129+
"disability_types": disability_types,
130+
"functional_scores": {},
131+
"assessment_date": assessment_date,
132+
"source_registry": source_registry,
133+
"raw_data": record,
134+
}
135+
136+
137+
def extract_functional_scores(record: dict) -> dict:
138+
"""Return functional assessment scores from a DCI v1.0.0 record.
139+
140+
The DCI v1.0.0 spec does not define numeric functional scores.
141+
``impairment_level`` is a free-text string, not a number.
142+
This function always returns ``{}`` and exists as a hook for future
143+
spec versions that may introduce numeric scoring.
144+
145+
Args:
146+
record: A single record dict from ``reg_records``.
147+
148+
Returns:
149+
dict: Always ``{}``.
150+
"""
151+
return {}

spp_dci_client_dr/services/dr_service.py

Lines changed: 21 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99

1010
from odoo.addons.spp_dci_client.services import DCIClient
1111

12+
from .dr_parsing import extract_disability_data, extract_functional_scores, unwrap_search_data
13+
1214
_logger = logging.getLogger(__name__)
1315

1416

@@ -122,14 +124,11 @@ def get_disability_status(self, partner) -> dict | None:
122124

123125
# Extract first result
124126
search_response = message["search_response"][0]
125-
if "data" not in search_response or not search_response["data"]:
127+
records = unwrap_search_data(search_response.get("data"))
128+
if not records:
126129
return None
127130

128-
record_data = (
129-
search_response["data"][0] if isinstance(search_response["data"], list) else search_response["data"]
130-
)
131-
132-
# Extract disability information
131+
record_data = records[0]
133132
disability_data = self._extract_disability_data(record_data)
134133

135134
_logger.info(
@@ -207,14 +206,11 @@ def get_functional_assessment(self, identifier_type: str, identifier_value: str)
207206

208207
# Extract first result
209208
search_response = message["search_response"][0]
210-
if "data" not in search_response or not search_response["data"]:
209+
records = unwrap_search_data(search_response.get("data"))
210+
if not records:
211211
return None
212212

213-
record_data = (
214-
search_response["data"][0] if isinstance(search_response["data"], list) else search_response["data"]
215-
)
216-
217-
# Extract functional scores
213+
record_data = records[0]
218214
scores = self._extract_functional_scores(record_data)
219215

220216
_logger.info(
@@ -389,103 +385,29 @@ def _get_partner_identifier(self, partner):
389385
return None
390386

391387
def _extract_disability_data(self, record_data: dict) -> dict:
392-
"""Extract disability information from DCI record data.
388+
"""Extract disability information from a DCI v1.0.0 record.
389+
390+
Delegates to the stateless module-level helper in dr_parsing.
393391
394392
Args:
395-
record_data: DCI record data from search response
393+
record_data: A single record dict from reg_records
396394
397395
Returns:
398396
dict: Extracted disability data
399397
"""
400-
disability_data = {
401-
"has_disability": False,
402-
"disability_types": [],
403-
"functional_scores": {},
404-
"raw_data": record_data,
405-
}
406-
407-
# Check for disability flag
408-
if "has_disability" in record_data:
409-
disability_data["has_disability"] = bool(record_data["has_disability"])
410-
elif "is_pwd" in record_data:
411-
disability_data["has_disability"] = bool(record_data["is_pwd"])
412-
413-
# Extract disability types
414-
if "disability_types" in record_data:
415-
types_data = record_data["disability_types"]
416-
if isinstance(types_data, list):
417-
disability_data["disability_types"] = types_data
418-
elif isinstance(types_data, str):
419-
# Handle comma-separated string
420-
disability_data["disability_types"] = [t.strip() for t in types_data.split(",") if t.strip()]
421-
422-
# Extract functional scores
423-
disability_data["functional_scores"] = self._extract_functional_scores(record_data)
424-
425-
# Extract assessment date
426-
if "assessment_date" in record_data:
427-
disability_data["assessment_date"] = record_data["assessment_date"]
428-
elif "disability_assessment_date" in record_data:
429-
disability_data["assessment_date"] = record_data["disability_assessment_date"]
430-
431-
# Extract source registry
432-
if "source_registry" in record_data:
433-
disability_data["source_registry"] = record_data["source_registry"]
434-
elif "registry_name" in record_data:
435-
disability_data["source_registry"] = record_data["registry_name"]
436-
437-
return disability_data
398+
return extract_disability_data(record_data)
438399

439400
def _extract_functional_scores(self, record_data: dict) -> dict:
440-
"""Extract functional assessment scores from record data.
401+
"""Return functional assessment scores from a DCI v1.0.0 record.
402+
403+
Delegates to the stateless module-level helper in dr_parsing.
404+
The DCI v1.0.0 spec has no numeric functional scores, so this always
405+
returns ``{}``.
441406
442407
Args:
443-
record_data: DCI record data
408+
record_data: A single record dict from reg_records
444409
445410
Returns:
446-
dict: Functional scores by domain
447-
Example: {'Vision': 3, 'Hearing': 1, 'Mobility': 4, ...}
411+
dict: Always ``{}``
448412
"""
449-
scores = {}
450-
451-
# Try to extract from functional_scores field
452-
if "functional_scores" in record_data:
453-
scores_data = record_data["functional_scores"]
454-
if isinstance(scores_data, dict):
455-
scores = scores_data
456-
elif isinstance(scores_data, str):
457-
# Try to parse JSON string
458-
try:
459-
scores = json.loads(scores_data)
460-
except json.JSONDecodeError:
461-
_logger.warning("Failed to parse functional_scores JSON")
462-
463-
# Try to extract individual domain scores
464-
functional_domains = [
465-
"Vision",
466-
"Hearing",
467-
"Mobility",
468-
"Cognition",
469-
"SelfCare",
470-
"Communication",
471-
]
472-
473-
for domain in functional_domains:
474-
# Try various field name formats
475-
for field_name in [
476-
f"functional_{domain.lower()}",
477-
f"{domain.lower()}_score",
478-
domain.lower(),
479-
domain,
480-
]:
481-
if field_name in record_data and record_data[field_name]:
482-
try:
483-
scores[domain] = int(record_data[field_name])
484-
except (ValueError, TypeError):
485-
_logger.warning(
486-
"Invalid functional score for %s: %s",
487-
domain,
488-
record_data[field_name],
489-
)
490-
491-
return scores
413+
return extract_functional_scores(record_data)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
22

3+
from . import test_callback
34
from . import test_disability_status
5+
from . import test_dr_parsing
46
from . import test_dr_service

0 commit comments

Comments
 (0)