diff --git a/docker-compose.dr.yml b/docker-compose.dr.yml new file mode 100644 index 00000000..69cb374f --- /dev/null +++ b/docker-compose.dr.yml @@ -0,0 +1,113 @@ +# OpenSPP-DR standalone docker-compose +# +# Self-contained file for the second OpenSPP container that plays the +# Disability Registry role in the federated demo topology (ADR-024). +# The SP side keeps its own lifecycle through `./spp` against +# docker-compose.yml — this file touches none of those services. +# +# Networking: the DR container joins the SP project's existing Docker +# network (`openspp2_openspp` by default) as an EXTERNAL network so the +# two containers can resolve each other by service name. Make sure the +# SP is up (`./spp start`) before launching the DR — the external +# network must exist. +# +# Database: shares the SP's `db` container by joining the same network +# and using DB_HOST=db, but uses a distinct database name (`openspp_dr`) +# so neither side sees the other's records. +# +# Usage: +# +# # Prereq: SP is up (network + db exist) +# ./spp start +# +# # Launch DR +# docker compose -f docker-compose.dr.yml up -d +# +# # Access: +# # SP UI (existing): determined by `./spp url` (ui=8069, dev=dynamic) +# # DR UI (new): http://localhost:8070 (admin/admin) +# # SP -> DR in-network: http://openspp-dr:8069 +# +# # Logs / shell / stop +# docker compose -f docker-compose.dr.yml logs -f openspp-dr +# docker compose -f docker-compose.dr.yml exec openspp-dr odoo shell -d openspp_dr +# docker compose -f docker-compose.dr.yml down +# +# # Wipe DR data (drops openspp_dr filestore volume; the SP's openspp +# # database in the shared db container is untouched, but you'll want +# # to manually DROP DATABASE openspp_dr in psql for a clean re-init). +# docker compose -f docker-compose.dr.yml down -v +# +# Module wiring: +# - SP container: install `spp_dci_openspp_dr` + `spp_dci_openg2p` +# via the SP's ODOO_INIT_MODULES (set before `./spp start`). +# - DR container: defaults to `spp_dci_server_disability` (overridable +# via ODOO_DR_INIT_MODULES). +# +# Network-name override: +# If your SP project name isn't `openspp2` (e.g. you cloned into a +# differently-named directory), set OPENSPP_NETWORK before launching: +# OPENSPP_NETWORK=_openspp docker compose -f docker-compose.dr.yml up -d + +name: openspp-dr + +services: + openspp-dr: + image: openspp-dev + # No `build:` here — relies on `./spp start` (or `./spp build`) + # having produced the openspp-dev image first. This keeps the DR + # standalone but avoids duplicating build state. + container_name: openspp-dr + hostname: openspp-dr + environment: + # Connect to the SP project's db container by service-name DNS + # resolution on the shared network. + DB_HOST: db + DB_PORT: "5432" + DB_USER: odoo + DB_PASSWORD: odoo + DB_NAME: ${ODOO_DR_DB_NAME:-openspp_dr} + DB_FILTER: "^${ODOO_DR_DB_NAME:-openspp_dr}$$" + LIST_DB: "False" + + ODOO_ADMIN_PASSWD: admin + + ODOO_WORKERS: "0" + ODOO_CRON_THREADS: "0" + + # The DR-side server module. Override to install richer demo + # registrant data alongside the server endpoint. + ODOO_INIT_MODULES: "${ODOO_DR_INIT_MODULES:-spp_dci_server_disability}" + ODOO_UPDATE_MODULES: "${ODOO_DR_UPDATE_MODULES:-}" + + LOG_LEVEL: info + PROXY_MODE: "False" + ports: + # Host 8070 → container 8069. SP-side OpenSPPDRService talks to + # http://openspp-dr:8069 over the shared Docker network; the + # host port is only for the operator's browser. + - "8070:8069" + volumes: + - .:/mnt/extra-addons/openspp:ro,z + - openspp_dr_data:/var/lib/odoo + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8069/web/health"] + interval: 10s + timeout: 10s + start_period: 90s + retries: 10 + networks: + - sp-shared + +volumes: + openspp_dr_data: + +networks: + # Join the SP project's existing network so we can reach the SP's + # `db` container and the SP's openspp container can reach us by name. + # The default name `openspp2_openspp` reflects the SP project's + # default name (derived from the OpenSPP2 directory) — override via + # OPENSPP_NETWORK if your SP project is named differently. + sp-shared: + name: ${OPENSPP_NETWORK:-openspp2_openspp} + external: true diff --git a/scripts/demo/SPDCI_CEL_TO_DCI_FLOW.md b/scripts/demo/SPDCI_CEL_TO_DCI_FLOW.md new file mode 100644 index 00000000..1f456113 --- /dev/null +++ b/scripts/demo/SPDCI_CEL_TO_DCI_FLOW.md @@ -0,0 +1,361 @@ +# How a CEL expression turns into two DCI calls + +This is the technical walkthrough behind one click of **Enroll Eligible** on the Disability Assistance program, when the program's CEL rule is: + +``` +has_disability == true && is_poor == "low" +``` + +The journey: one operator click → two HTTP DCI requests against two independent registries → one ANDed eligibility decision per partner. + +## The big picture + +``` +┌──────────────────────────────────────────────────────────────────────────┐ +│ OPERATOR clicks "Enroll Eligible" on the Disability Assistance program │ +└────────────────────────────────────┬─────────────────────────────────────┘ + │ + ▼ + ┌──────────────────────────────────────────────┐ + │ spp.program.membership.manager.default │ + │ ._prepare_eligible_domain() runs │ (1) Eligibility entry point + └────────────────────────┬─────────────────────┘ + │ + ▼ + ┌──────────────────────────────────────────────┐ + │ PRE-WARM: bridge eagerly fetches every │ (2) Pre-warm hook + │ active DCI-backed CEL variable for cohort │ in spp_cel_dci_bridge + └────────────────────────┬─────────────────────┘ + │ + ┌────────────────────┴────────────────────┐ + ▼ ▼ + ┌───────────────────────┐ ┌────────────────────────┐ + │ has_disability │ │ is_poor │ + │ registry_type=DR │ │ registry_type=SR │ + │ vendor=openspp │ │ vendor=openg2p │ + └──────────┬────────────┘ └───────────┬────────────┘ + │ │ + ▼ DCI search-sync ▼ DCI search-sync + ┌───────────────────────┐ ┌────────────────────────┐ + │ OpenSPP-DR │ │ OpenG2P SR │ + │ /dci_api/v1/ │ │ /dci/registry/ │ + │ disability/registry │ │ sync/search │ + │ /sync/search │ │ │ + │ │ │ partner-nsr.play. │ + │ Returns reg_record │ │ openg2p.org │ + │ {has_disability:bool}│ │ │ + └──────────┬────────────┘ │ Returns reg_record │ + │ │ {income_level:string} │ + │ └───────────┬────────────┘ + │ │ + ▼ ▼ + ┌────────────────────────────────────────────────────────────────┐ + │ spp.data.value cache rows written per partner per variable │ (3) Cache write + │ {"value": true} / {"value": "low"} / {"value": null} ... │ + └────────────────────────────────┬───────────────────────────────┘ + │ + ▼ + ┌──────────────────────────────────────────────┐ + │ CEL parser + translator │ (4) Plan build + │ has_disability == true && is_poor == "low" │ + │ → AND[MetricCompare, MetricCompare] │ + └────────────────────────┬─────────────────────┘ + │ + ▼ + ┌──────────────────────────────────────────────┐ + │ CEL executor │ (5) SQL fast path + │ Each MetricCompare → SQL subquery against │ + │ spp_data_value table; ANDed together │ + └────────────────────────┬─────────────────────┘ + │ + ▼ + ┌──────────────────────────────────────────────┐ + │ PostgreSQL evaluates the final domain in │ (6) Final eligibility query + │ one query — returns matching partner IDs │ + └────────────────────────┬─────────────────────┘ + │ + ▼ + ┌──────────────────────────────────────────────┐ + │ spp.program.membership rows flip: │ (7) Result + │ matching → enrolled, others → not_eligible │ + └──────────────────────────────────────────────┘ +``` + +The audit log (`spp.dci.fetch.audit`) gets one row per partner per fetch — 30 rows total for the 15 demo personas. That's the compliance trail. + +## Step-by-step + +### Step 1 — Operator click reaches the eligibility manager + +The Enroll-Eligible button invokes `spp.program.enroll_eligible_registrants()` on the program. That iterates each membership manager configured on the program (the default one for our demo) and asks each: "give me a domain that selects the eligible partners." + +`spp_cel_dci_bridge` overrides the default manager's `_prepare_eligible_domain()` so that **before** the CEL filter compiles, the cache for every DCI-backed variable gets pre-warmed. + +→ Code: `spp_cel_dci_bridge/models/eligibility_manager.py` + +### Step 2 — Pre-warm pulls every active DCI variable + +The bridge calls: + +```python +cache_mgr.precompute_cached_variables(subject_ids, period_key="current", program_id=program.id) +``` + +`cache_mgr` is `spp.data.cache.manager`. Internally it: + +1. Searches every `spp.cel.variable` where `active=True` and `cache_strategy ∈ {ttl, manual}` — that finds **both** `has_disability` and `is_poor` (and the inactive `has_dependent_under_school_age` is skipped). +2. For each variable, calls `_compute_variable_values(variable, subject_ids, ...)`. + +The bridge overrides `_compute_variable_values`: when the variable has `source_type='external'` AND a DCI-backed provider, it delegates to `_compute_dci_values`. + +→ Code: `spp_cel_dci_bridge/models/data_cache_manager.py` + +**Important design point**: the pre-warm fetches *all* active variables, not just the ones the rule references. We trade extra registry round-trips for executor simplicity. For 15 partners × 2 variables = 30 DCI calls. + +### Step 3 — The dispatcher picks the right handler + +`_compute_dci_values` calls: + +```python +dispatcher.fetch_values_for_variable(variable, subject_ids, period_key) +``` + +The dispatcher (`spp.cel.dci.dispatcher`) looks at the variable's data-source `registry_type` and chooses a handler: + +| `registry_type` | Handler in the bridge | Vendor override called if `vendor=...` is set | +|---|---|---| +| `DR` | `_handler_dr` | `OpenSPPDRService` (`vendor=openspp`) | +| `SR` | `_handler_sr` | `OpenG2PSocialService` (`vendor=openg2p`) | +| `CRVS`, `IBR`, `FR` | (registry-specific handlers; not used in this demo) | — | + +Vendor adapters absorb per-vendor request/response quirks. OpenG2P needs a specific expression-query envelope shape with consent/authorize blocks; OpenSPP-DR speaks vanilla SPDCI. + +→ Code: `spp_cel_dci_bridge/models/dci_dispatcher.py`, + `spp_dci_openspp_dr/models/dci_dispatcher.py`, + `spp_dci_openg2p/models/dci_dispatcher.py` + +### Step 4 — Each handler builds and sends a DCI envelope + +**For `has_disability` (DR side)**, `OpenSPPDRService.get_partner_record(partner)`: + +1. Reads the partner's UIN from `spp.registry.id` (priority: UIN > DRN > NATIONAL_ID > NID). +2. Calls `DCIClient.search_by_id(identifier_type='UIN', identifier_value='IND-NSR-0001', ...)`. +3. Underneath, the DCI client POSTs a signed DCI envelope to the configured base URL + search endpoint. + +The on-the-wire request is: + +``` +POST http://openspp-dr:8069/dci_api/v1/disability/registry/sync/search +Content-Type: application/json + +{ + "signature": "", + "header": { + "version": "1.0.0", + "message_id": "", + "message_ts": "2026-05-15T...", + "action": "search", + "sender_id": "openspp-sp.demo", + "receiver_id": "openspp-dr.demo", + "total_count": 1, + "is_msg_encrypted": false + }, + "message": { + "transaction_id": "", + "search_request": [{ + "reference_id": "", + "timestamp": "2026-05-15T...", + "search_criteria": { + "reg_type": "ns:org:RegistryType:DR", + "query_type": "idtype-value", + "query": {"type": "UIN", "value": "IND-NSR-0001"} + } + }] + } +} +``` + +**For `is_poor` (SR side)**, `OpenG2PSocialService.get_partner_record(partner)` does the same shape but with OpenG2P's expression-query format: + +``` +POST https://partner-nsr.play.openg2p.org/dci/registry/sync/search + +{ + "signature": "", + "header": { ...openspp-sp.demo → openg2p.demo... }, + "message": { + "search_request": [{ + "search_criteria": { + "reg_type": "Individual", + "reg_record_type": "Individual", + "query_type": "expression", + "query": { + "type": "ns:org:QueryType:expression", + "value": {"expression": {"query": {"search_text": {"$eq": "IND-NSR-0001"}}}} + }, + "consent": {"@context": "...", "@type": "Consent", ...}, + "authorize": {"@context": "...", "@type": "Authorize", ...} + } + }] + } +} +``` + +The differences (consent block, expression-query shape, "Individual" literal reg_type) are exactly why `spp_dci_openg2p` has a vendor adapter and `spp_dci_openspp_dr` does not. + +→ Code: `spp_dci_openspp_dr/services/openspp_dr_service.py`, + `spp_dci_openg2p/services/openg2p_social_service.py`, + `spp_dci_openg2p/services/openg2p_dci_client.py` (the envelope shaper) + +### Step 5 — The remote registries answer + +**OpenSPP-DR** (our own server module, `spp_dci_server_disability`): + +1. FastAPI router at `/dci_api/v1/disability/registry/sync/search` receives the envelope. +2. Signature + bearer middleware validates the sender (dev-mode bypasses for the demo). +3. `DisabilitySearchService.execute_search()` extracts the UIN, looks up `spp.registry.id.value`, finds the matching partner. +4. Reads `partner.has_disability` (Boolean, computed by `spp_disability_registry` from the latest approved `spp.disability.assessment`). +5. Builds a response envelope with `data.reg_records[0] = {has_disability: true, ...}` and returns 200. + +**OpenG2P SR** (their hosted service): receives the envelope, runs its expression query against its data store, returns `data.reg_records[0]` with `income_level`, `marital_status`, `occupation`, etc. + +→ Code: `spp_dci_server_disability/routers/disability_router.py`, + `spp_dci_server_disability/services/disability_search_service.py` + +### Step 6 — Service unwraps response, dispatcher extracts the value + +Back on the SP side, each service unwraps `message.search_response[0].data.reg_records[0]` and returns the raw record dict. The dispatcher then applies `variable.dci_attribute_path`: + +- For `has_disability`, path = `has_disability` → extracts the Boolean True. +- For `is_poor`, path = `income_level` → extracts the string `"low"`. + +The dispatcher writes a `spp.dci.fetch.audit` row capturing the result (`ok` / `not_found` / `error`), elapsed time, sender, variable, subject. Audit closure = compliance. + +→ Code: `spp_cel_dci_bridge/models/dci_dispatcher.py` + +### Step 7 — Cache write + +The parent `data_evaluator` upserts a `spp.data.value` row for each (variable, subject_id): + +``` +variable_name | subject_id | value_json | expires_at +---------------+------------+----------------------+----------- +has_disability | 18 | {"value": true} | now+300s +is_poor | 18 | {"value": "low"} | now+300s +``` + +15 partners × 2 variables = 30 rows after pre-warm. With `cache_strategy='ttl'` and `cache_ttl_seconds=300`, the rows are good for 5 minutes — subsequent eligibility checks in that window skip the DCI calls. + +→ Code: `spp_cel_domain/models/data_evaluator.py:precompute_cached_variables` + +### Step 8 — CEL parsing → plan + +The CEL string is parsed by `spp.cel.parser`: + +``` +"has_disability == true && is_poor == "low"" + │ + ▼ tokenize + parse +Compare(Compare(Call(metric('has_disability', me)), ==, Literal(True)), + AND, + Compare(Call(metric('is_poor', me)), ==, Literal('low'))) + │ + ▼ translator +AND[ MetricCompare(metric='has_disability', op='==', rhs=True), + MetricCompare(metric='is_poor', op='==', rhs='low') ] +``` + +The translator picked `MetricCompare` nodes because each side calls `metric('', me)` — a registry-backed variable lookup. + +→ Code: `spp_cel_domain/services/cel_parser.py`, + `spp_cel_domain/models/cel_translator.py`, + `spp_cel_domain/models/cel_queryplan.py` + +### Step 9 — Executor builds SQL subqueries + +The executor (`spp.cel.executor`) walks the plan. For each `MetricCompare`, when the cache is fresh AND the comparison is supported, it builds an SQL fast-path subquery: + +```sql +-- For has_disability == true: +SELECT DISTINCT fv.subject_id +FROM spp_data_value fv +WHERE fv.variable_name = 'has_disability' + AND fv.subject_model = 'res.partner' + AND fv.period_key = 'current' + AND fv.error_code IS NULL + AND (fv.expires_at IS NULL OR fv.expires_at > NOW()) + AND (CASE WHEN jsonb_typeof(fv.value_json) = 'object' + THEN (fv.value_json -> 'value')::boolean + END) = true +``` + +And similarly for `is_poor == 'low'` (string cast via `value_json ->> 'value'`). + +Each subquery becomes an Odoo domain clause `('id', 'in', )` and gets ANDed onto the final domain by the executor's top-level composer (this is the AND-of-overrides fix from commit `503e7fd7`). + +→ Code: `spp_cel_domain/models/cel_executor.py` — see `_metric_inselect_sql` for the SQL build and the top-level `compile_and_preview` for the AND composition. + +### Step 10 — One Odoo query, one PostgreSQL roundtrip + +The final domain handed to `res.partner.search()` looks like: + +```python +[ + ('is_registrant', '=', True), + ('is_group', '=', False), + ('id', 'in', [18, 19, 20, ..., 32]), # cohort restriction + ('disabled', '=', False), + ('id', 'in', ), + ('id', 'in', ), +] +``` + +Odoo turns this into one big SQL with two subselects ANDed in the WHERE. PostgreSQL evaluates it once. Result: 4 matching partner IDs — Alex, Morgan, Taylor, Sam. + +### Step 11 — Memberships flip + +The eligibility manager writes each partner's `spp.program.membership.state`: +- 4 matching partners → `enrolled` +- 11 non-matching → `not_eligible` + +In the UI, the Programs Membership list reflects the new states. + +## Why this matters for the SPDCI story + +A few things to highlight in the presentation: + +1. **No registry holds all the data.** The eligibility decision needs disability data (DR) AND poverty data (SR). Each registry owns what it's authoritative for — neither leaks data into the other. This is the federated principle. + +2. **The CEL surface is vendor-neutral.** The rule reads `has_disability == true && is_poor == "low"` — no mention of OpenG2P, OpenSPP-DR, OAuth, or HTTP. Swap OpenG2P out for a national SR; the rule doesn't change. Configuration adjustments only. + +3. **One operator click triggers two DCI calls per partner.** 15 partners × 2 variables = 30 round-trips, all in parallel cohort batches. Pre-warmed once, cached for 5 minutes, the same cohort can be re-evaluated repeatedly without re-querying. + +4. **The audit trail captures every fetch.** `spp.dci.fetch.audit` records 30 rows per click — variable, sender, receiver, subject, outcome, elapsed time. This is the compliance evidence that "we asked the DR for X and got Y at time T." + +5. **PostgreSQL composes the final decision.** Once the cache is warm, eligibility is a single SQL query over local data — no extra round-trips, scales to millions of partners. The SPDCI step is the fetch; the composition is local. + +## Try it during the demo + +```bash +# Reset state (drafts + cache flush) +docker compose exec openspp-dev odoo shell -d openspp --no-http \ + < scripts/demo/reset_spdci_demo.py + +# Click Enroll Eligible in the UI. +# Watch the live log: +docker compose logs -f openspp-dev | grep -E "CEL|DCI|Pre-comput|Pre-warm" + +# In the UI: Programs → Disability Assistance → Memberships +# See 4 Enrolled, 11 Not Eligible. +# In the UI: DCI → Fetch Audit +# See 30 rows (15 partners × 2 variables), each with provider, subject, result, elapsed_ms. + +# Verify the cached values directly: +docker compose exec db psql -U odoo -d openspp -c \ + "SELECT subject_id, variable_name, value_json + FROM spp_data_value + WHERE variable_name IN ('has_disability', 'is_poor') + ORDER BY subject_id, variable_name;" +``` + +That's the complete journey from CEL syntax to two federated DCI calls to one eligibility decision. diff --git a/scripts/demo/SPDCI_DEMO_BRIEFING.md b/scripts/demo/SPDCI_DEMO_BRIEFING.md new file mode 100644 index 00000000..b96cda28 --- /dev/null +++ b/scripts/demo/SPDCI_DEMO_BRIEFING.md @@ -0,0 +1,196 @@ +# SPDCI Federated Demo — Briefing Sheet + +A one-page reference for the live demo of OpenSPP V2's SPDCI federated-eligibility flow. + +## The narrative + +> A government runs a social-protection program — **Disability Assistance** — that +> targets registrants who are **both** living with a disability **and** classified as +> poor. The two facts live in two independent registries owned by two independent +> agencies. OpenSPP composes the eligibility decision by querying both over the DCI +> standard, in real time, with a single click. + +## Demo eligibility rule + +``` +has_disability == true && is_poor == "low" +``` + +| Clause | Source registry | Resolved via | +| ---------------- | ------------------------------------------------------------------------- | ----------------------------------------------------- | +| `has_disability` | **OpenSPP-DR** (Disability Registry, a sibling OpenSPP instance) | DCI `/dci_api/v1/disability/registry/sync/search` | +| `is_poor` | **OpenG2P SR** (National Social Registry, `partner-nsr.play.openg2p.org`) | DCI `/dci/registry/sync/search`, reads `income_level` | + +Both fetches happen inside Enroll Eligible's pre-warm phase. The CEL executor ANDs the +two SQL subqueries against the SP's cohort. + +## The 15 demo registrants + +Each persona on the SP carries a UIN reg_id matching an OpenG2P SR seed identifier +(`IND-NSR-0001`..`IND-NSR-0015`). Names mirror OpenG2P's actual seed records so the +federation story stays honest — operators can curl OpenG2P and verify the same name +comes back. + +| # | UIN | Registrant | OpenG2P `income_level` | OpenSPP-DR `has_disability` | Verdict | +| --- | -------------- | ------------- | ---------------------- | --------------------------- | ----------------------------- | +| 1 | `IND-NSR-0001` | Alex Rivera | **low** | **true** | ✅ **ENROLLED** | +| 2 | `IND-NSR-0002` | Priya Rivera | low | false | not eligible (no DR record) | +| 3 | `IND-NSR-0003` | Noah Rivera | (empty) | false | not eligible (both fail) | +| 4 | `IND-NSR-0004` | Morgan Cole | **low** | **true** | ✅ **ENROLLED** | +| 5 | `IND-NSR-0005` | Leah Cole | low | false | not eligible (no DR record) | +| 6 | `IND-NSR-0006` | Nia Cole | (empty) | true | not eligible (income not low) | +| 7 | `IND-NSR-0007` | Kim Lee | medium | true | not eligible (income not low) | +| 8 | `IND-NSR-0008` | Jun Lee | medium | false | not eligible (both fail) | +| 9 | `IND-NSR-0009` | Rin Lee | (empty) | true | not eligible (income not low) | +| 10 | `IND-NSR-0010` | Taylor Brooks | **low** | **true** | ✅ **ENROLLED** | +| 11 | `IND-NSR-0011` | Iris Brooks | (empty) | false | not eligible (both fail) | +| 12 | `IND-NSR-0012` | Reyn Brooks | (empty) | false | not eligible (both fail) | +| 13 | `IND-NSR-0013` | Sam Hayes | **low** | **true** | ✅ **ENROLLED** | +| 14 | `IND-NSR-0014` | Dev Hayes | low | false | not eligible (no DR record) | +| 15 | `IND-NSR-0015` | Asha Hayes | (empty) | true | not eligible (income not low) | + +**Expected outcome**: 4 / 15 enrolled. The other 11 illustrate each failure mode of the +AND'd rule. + +## Topology + +``` + ┌────────────────────────┐ + │ OpenG2P SR (cloud) │ + │ partner-nsr │ + │ Returns income_level │ + └───────────▲────────────┘ + │ DCI search-sync + │ (HTTPS, expression query) +┌──────────────────────┐ ┌────────────┴────────────┐ ┌──────────────────────┐ +│ Operator clicks │──▶│ OpenSPP SP instance │──▶│ OpenSPP-DR instance │ +│ "Enroll Eligible" │ │ (./spp container) │ │ (sibling container)│ +│ │ │ │ │ │ +│ Program rule: │ │ • spp_cel_dci_bridge │ │ • spp_disability_ │ +│ has_disability == │ │ • spp_dci_openg2p (SR) │ │ registry │ +│ true && │ │ • spp_dci_openspp_dr │ │ • spp_dci_server │ +│ is_poor == "low" │ │ (DR client) │ │ • spp_dci_server_ │ +│ │ │ │ │ disability │ +└──────────────────────┘ └─────────────────────────┘ └──────────────────────┘ + │ DCI search-sync (HTTP, in-container network) + ▼ + http://openspp-dr:8069 +``` + +The bridge fans the eligibility check out to two independent registries, caches the +results in `spp.data.value`, audits every fetch in `spp.dci.fetch.audit`, and lets the +CEL executor compose the final eligibility decision in one SQL query. + +## Glossary + +### Standards & protocols + +**SPDCI** — Social Protection Digital Convergence Initiative. A community-driven effort +under the broader DCI banner to standardise how social-protection MIS systems +interoperate with identification, civil-registration, and other government registries. + +**DCI** — Digital Convergence Initiative. The umbrella body publishing open standards +for cross-registry data exchange, hosted at [spdci.org](https://spdci.org). The DCI +specs define wire-level message envelopes, header conventions, signature/consent blocks, +and per-registry search semantics. + +**Search-Sync** — DCI's synchronous search protocol. A POST request carrying a DCI +envelope (`signature`, `header`, `message.search_request`) returns matching registry +records in the same HTTP response. Used for "tell me what you know about this person" +lookups. Contrasted with search-async (a callback-based variant for long-running +queries). + +**OIDC / OAuth2** — The OpenID Connect / OAuth 2.0 family. Used for authentication and +authorisation, especially with MOSIP eSignet. Different protocol from DCI search-sync: +OIDC mediates **user authentication via browser redirect**; DCI does **server-to-server +data lookup**. + +### Registries + +**SR — Social Registry**. Holds household-composition and socio-economic data used for +eligibility targeting (e.g., `income_level`, `marital_status`, `employment_status`). In +this demo, the SR is OpenG2P's playground at `partner-nsr.play.openg2p.org`. SPDCI +registry-type code: `SR`. + +**DR — Disability Registry**. Holds disability assessments and related data (e.g., +`has_disability`, severity, review cadence). In this demo, the DR is a second OpenSPP +instance running `spp_disability_registry` + `spp_dci_server_disability`. SPDCI +registry-type code: `DR`. + +**CRVS — Civil Registration and Vital Statistics**. Holds birth/death/marriage records. +Not used in this demo. Code: `CRVS`. + +**IBR — Integrated Beneficiary Registry**. Cross-program beneficiary index, often used +to detect duplicate enrollment. Not used in this demo. Code: `IBR`. + +**FR — Functional/Farmer Registry**. Domain-specific registries (e.g., farmer +registries). Code: `FR`. + +### CEL + +**CEL — Common Expression Language**. Google's open-source domain-specific language for +evaluating boolean and numeric expressions. OpenSPP uses CEL for program eligibility +rules. Example: + +``` +has_disability == true && age_years(r.birthdate) >= 18 +``` + +**CEL accessor** — The identifier inside a CEL rule that references a registrant +attribute (e.g., `has_disability`, `is_poor`, `age_years`). Accessors are +**vendor-neutral** by design — rewriting a rule to read from a different vendor's +registry doesn't change the CEL surface, only the data-source configuration backing the +accessor. + +**`spp.cel.variable`** — The Odoo model that backs a CEL accessor. Carries the value +type, source (`field` / `external` / `computed` / `aggregate`), provider link, cache +strategy, and other metadata. + +**`metric()` call** — How the CEL planner translates a registry-backed variable when +evaluating. A rule like `has_disability == true` compiles to +`metric('has_disability', me) == true` and the executor's SQL fast path turns that into +an `('id', 'in', )` clause on `spp.data.value`. + +### OpenSPP-side terminology + +**`spp.dci.data.source`** — A configured DCI endpoint (host, path, auth, sender/receiver +ids, vendor adapter). One per external registry. + +**`spp.data.provider`** — The CEL framework's reference to a backing source. A +DCI-backed provider has `dci_data_source_id` set. + +**`spp.cel.dci.dispatcher`** — Bridge code that, for a CEL variable backed by a DCI +source, routes the fetch to the right per-registry-type handler (`_handler_sr`, +`_handler_dr`, etc.) which then delegates to a vendor service adapter. + +**Vendor adapter** — Optional Python service class that absorbs vendor-specific +request/response quirks. Examples in this demo: `OpenG2PSocialService` (handles +OpenG2P's expression-query / consent-block shape), `OpenSPPDRService` (unwraps +`data.reg_records[0]` correctly). + +**`spp.data.value`** — Persistent cache of resolved variable values. Each row: +`(subject_model, subject_id, variable_name, period_key, value_json, expires_at, ...)`. + +**`spp.dci.fetch.audit`** — Compliance log of every DCI fetch. One row per subject per +fetch, regardless of outcome (ok / not_found / error). Surfaces who queried what, when, +with what response. + +### Other systems (referenced but out of scope) + +**MOSIP** — Modular Open Source Identity Platform. National identity system used by +several governments. Future-work integration point for SPDCI; not in this demo. + +**eSignet** — MOSIP's OIDC-compliant authentication service. Mediates user identity +verification via browser redirect + KYC token. Different protocol family from DCI +search-sync. Phase 4 roadmap item in ADR-024. + +**OpenG2P** — Open-source social-protection platform. Provides the SR +(`partner-nsr.play.openg2p.org`) used in this demo's federated eligibility flow. + +## See Also + +- ADR-023 — CEL ↔ DCI External Fetch Bridge +- ADR-024 — Federated DCI Demo Topology for SPDCI +- `docs/plans/SPP_DCI_FEDERATED_DEMO_PLAN.md` — implementation plan +- `scripts/demo/setup_spdci_demo.py` — seed script for the 15 demo personas +- `scripts/demo/reset_spdci_demo.py` — per-iteration reset (membership + cache) diff --git a/scripts/demo/SPDCI_DEMO_MODULES.md b/scripts/demo/SPDCI_DEMO_MODULES.md new file mode 100644 index 00000000..5eb94394 --- /dev/null +++ b/scripts/demo/SPDCI_DEMO_MODULES.md @@ -0,0 +1,222 @@ +# SPDCI Demo — Modules & SP Reset Procedure + +Reference sheet for resetting the **SP** instance only (DR stays up) and reinstalling +the modules required for the federated CEL ↔ DCI eligibility demo (ADR-024). + +The SP container plays the Social Protection platform that runs CEL eligibility rules; +the DR container plays the standalone Disability Registry that answers `has_disability` +lookups over DCI. They share the same `db` Postgres container but use different +databases (`openspp` vs. `openspp_dr`). + +This doc covers the **SP-only reset** flow: the DR's `openspp_dr` database, its 8 seeded +disability assessments, and its DCI-server config are preserved across the reset, so +only the SP needs to be re-installed and re-pointed at the still-running DR. For +first-time setup of both sides (or a full both-instances rebuild), follow the expanded +recipe in `docker-compose.dr.yml`'s header comment and run the seed script on both +sides. + +--- + +## Top-level modules to install + +You only need to install the **leaf modules** below — Odoo's dependency solver pulls in +everything else (`spp_cel_domain`, `spp_dci_client`, `spp_dci_server`, `spp_registry`, +`spp_vocabulary`, `spp_programs`, `spp_studio`, etc.). + +### SP container (`openspp` database) + +| Module | What it provides | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `spp_dci_openspp_dr` | Preset that wires the `has_disability` CEL variable to the OpenSPP-DR over DCI. Brings in `spp_cel_dci_bridge` and `spp_dci_client_dr`. | +| `spp_dci_openg2p` | Preset that wires the `is_poor` CEL variable to a DCI-compliant Social Registry (income_level → is_poor). Also hosts the **SR-import wizard** under Registry. | + +### DR container (`openspp_dr` database) + +| Module | What it provides | +| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `spp_dci_server_disability` | DCI-server endpoint that answers `/dci_api/v1/disability/registry/sync/search` and exposes the disability assessment data. Brings in `spp_dci_server`, `spp_registry`, `spp_vocabulary`. | + +### Dependency tree (informational) + +``` +SP container: + spp_dci_openspp_dr + └── spp_cel_dci_bridge + │ ├── spp_cel_domain + │ ├── spp_dci_client + │ ├── spp_dci_client_dr + │ ├── spp_dci_client_crvs + │ ├── spp_dci_client_ibr + │ ├── spp_programs + │ └── spp_studio + ├── spp_dci_client_dr + └── spp_vocabulary + + spp_dci_openg2p + ├── spp_cel_dci_bridge (already pulled in above) + ├── spp_vocabulary + └── spp_registry + +DR container: + spp_dci_server_disability + ├── spp_dci_server + │ └── spp_dci + ├── spp_registry + └── spp_vocabulary +``` + +--- + +## SP-only reset procedure + +Resets the SP database (`openspp`) from scratch. **The DR stays up untouched** — its +`openspp_dr` database and the 8 seeded disability assessments are preserved, so the SP's +`has_disability` lookups will keep working against the still-live DR once the SP +re-installs and re-points at it. + +### 1. Stop the SP (keep DR running) + +```bash +./spp stop +docker compose down -v # removes SP filestore volume +``` + +Verify the DR is still up — it shares the network but has its own container and DB: + +```bash +docker compose -f docker-compose.dr.yml ps # openspp-dr should be Up (healthy) +``` + +If you wiped the SP network (rare), the DR will have lost its external-network link and +you'll need to restart it: + +```bash +docker compose -f docker-compose.dr.yml up -d +``` + +### 2. Re-init the SP + +```bash +# Set the SP's init modules and start. The two presets pull every +# dependency listed in the tree above. +export ODOO_INIT_MODULES="spp_dci_openspp_dr,spp_dci_openg2p" +./spp start +``` + +Watch the boot log; it will exit cleanly when install finishes: + +```bash +docker compose logs -f openspp-dev | grep -E "Modules loaded|ERROR|init " +``` + +--- + +## Post-install wiring (SP side only) + +After the SP is back up, it needs a couple of records the data XML does not seed +automatically (because the SP doesn't know your DR's URL): + +### 2a. Point the SP's DR data source at the running DR + +The `spp_dci_openspp_dr` preset creates an `spp.dci.data.source` record with a +placeholder URL. Set it to the in-network DR hostname: + +```bash +docker compose exec openspp-dev odoo shell -d openspp --no-http <<'PY' +src = env.ref("spp_dci_openspp_dr.openspp_dr_source") +src.write({ + "base_url": "http://openspp-dr:8069", + "active": True, +}) +env.cr.commit() +print(f"DR source -> {src.base_url}") +PY +``` + +### 2b. Seed SP-side registrants + +Two options — pick one. + +**Option A: seed script (matches prior demo runs)** + +```bash +# Enrolls 15 IND-NSR-XXXX partners into program id=1 as draft. +docker compose exec openspp-dev odoo shell -d openspp --no-http \ + < scripts/demo/setup_spdci_demo.py +``` + +The script is idempotent (re-runs update existing partners by UIN). It also detects when +run on the DR and seeds the disability assessments instead — but **don't run it on the +DR this time**; the DR already has its 8 assessments from the previous run. + +**Option B: SR-import wizard (operator-driven, recommended for the demo presentation)** + +After the SP is up, an operator can populate registrants via the wizard under **Registry +→ Import from External Registry**: + +- Source Registry: select **Social Registry** (the only option). +- Discovery: Range sweep `IND-NSR-` `0001..0015` (pad=4). +- Auto-enroll into program: pick the demo program if you want memberships created in one + step. +- Preview → Import Selected. + +This produces the same SP-side state as the seed script. + +### 2c. DR config — NO ACTION NEEDED + +The DR's previous setup is preserved: + +- `dci.allow_unsigned_requests=true` system parameter (set in a prior run) +- `dci.bypass_bearer_auth=true` system parameter (set in a prior run) +- 8 approved disability assessments seeded against `IND-NSR-0001`/`0003`/`0005`/… + +Skip the optional bypass and DR seeding steps from earlier docs — they remain in effect +across SP wipes because the DR database is untouched. + +--- + +## Reset between demo runs (no reinstall) + +If you want to re-run the demo without wiping the database: + +```bash +# Resets the 15 memberships to draft and wipes the DCI cache, so the +# next eligibility evaluation goes through to DR + SR again. +docker compose exec openspp-dev odoo shell -d openspp --no-http \ + < scripts/demo/reset_spdci_demo.py +``` + +--- + +## Sanity checks before the demo + +| Check | Command | +| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| SP installed modules look right | `docker compose exec openspp-dev odoo shell -d openspp --no-http -c "print([m.name for m in env['ir.module.module'].search([('name','in',['spp_dci_openspp_dr','spp_dci_openg2p']), ('state','=','installed')])])"` | +| DR installed modules look right | `docker compose -f docker-compose.dr.yml exec openspp-dr odoo shell -d openspp_dr --no-http -c "print([m.name for m in env['ir.module.module'].search([('name','=','spp_dci_server_disability'), ('state','=','installed')])])"` | +| SP can resolve `openspp-dr` | `docker compose exec openspp-dev getent hosts openspp-dr` | +| DR endpoint is up | `curl -sS http://localhost:8070/web/health` | +| SR-import wizard finds the source | Open Registry → Import from External Registry. Source Registry should pre-fill **Social Registry**. | +| 15 demo personas seeded | SP: `SELECT count(*) FROM res_partner WHERE is_registrant = true;` (expect 15) | +| 8 DR assessments seeded | DR: `SELECT count(*) FROM spp_disability_assessment WHERE state='approved';` (expect 8) | + +--- + +## Quick reference: container/database names + +| Container | Service | Database | DB host | Network alias | +| ---------------------- | ------------- | ------------ | ------------------ | ------------- | +| openspp2-openspp-dev-1 | `openspp-dev` | `openspp` | `db:5432` (shared) | `openspp-dev` | +| openspp-dr | `openspp-dr` | `openspp_dr` | `db:5432` (shared) | `openspp-dr` | +| openspp2-db-1 | `db` | both | n/a | `db` | +| openspp2-jobworker-1 | `jobworker` | `openspp` | `db:5432` | — | + +UI ports: + +- SP: dynamic (`./spp url`) — usually `http://localhost:` +- DR: `http://localhost:8070` (admin/admin) + +In-container DNS: + +- SP → DR: `http://openspp-dr:8069` +- DR → SP: not used in this demo topology. diff --git a/scripts/demo/openg2p_demo_data.csv b/scripts/demo/openg2p_demo_data.csv new file mode 100644 index 00000000..b990b4be --- /dev/null +++ b/scripts/demo/openg2p_demo_data.csv @@ -0,0 +1,16 @@ +uin,given_name,surname,sex,birth_date,marital_status,employment_status,occupation +IND-NSR-0001,Alex,Rivera,male,1984-04-10,married,self employed,wage labourer +IND-NSR-0002,Priya,Rivera,female,1986-06-22,married,self employed,petty trader +IND-NSR-0003,Noah,Rivera,male,2017-01-15,single,student,student +IND-NSR-0004,Morgan,Cole,female,1968-09-02,widowed,unemployed,small-plot farmer +IND-NSR-0005,Leah,Cole,female,1998-02-10,single,employed,shop assistant +IND-NSR-0006,Nia,Cole,female,2019-11-01,single,student,student +IND-NSR-0007,Kim,Lee,male,1981-07-18,married,employed,clerk +IND-NSR-0008,Jun,Lee,female,1982-12-04,married,self employed,tailor +IND-NSR-0009,Rin,Lee,female,1953-05-30,widowed,retired,retired +IND-NSR-0010,Taylor,Brooks,others,1990-03-25,single,unemployed,none +IND-NSR-0011,Iris,Brooks,female,1957-11-12,widowed,retired,retired +IND-NSR-0012,Reyn,Brooks,male,2015-08-19,single,student,student +IND-NSR-0013,Sam,Hayes,female,1987-01-05,married,self employed,livestock keeper +IND-NSR-0014,Dev,Hayes,male,1984-07-20,married,self employed,livestock keeper +IND-NSR-0015,Asha,Hayes,female,2011-06-14,single,student,student diff --git a/scripts/demo/reset_spdci_demo.py b/scripts/demo/reset_spdci_demo.py new file mode 100644 index 00000000..e15b6768 --- /dev/null +++ b/scripts/demo/reset_spdci_demo.py @@ -0,0 +1,89 @@ +# ============================================================================ +# SPDCI DEMO — MEMBERSHIP + CACHE RESET. DO NOT SHIP IN PRODUCTION. +# ============================================================================ +# +# Resets the 15 demo registrants seeded by `setup_spdci_demo.py` back to a +# clean pre-evaluation state so the operator can demo Enroll Eligible +# multiple times during a presentation: +# +# 1. All memberships on program id=DEMO_PROGRAM_ID for the demo +# partners get flipped from {enrolled, not_eligible, paused, exited} +# back to state='draft'. The next Enroll Eligible click will +# re-evaluate the CEL rule for each. +# +# 2. The DCI value cache for the demo partners is wiped. This forces +# the next eligibility check to re-fetch live from OpenG2P SR and +# OpenSPP-DR (instead of serving the 5-minute TTL'd cached values). +# Useful when the demo audience should see the DCI round-trip in +# the SP log. +# +# RUN ON SP ONLY: +# docker compose exec openspp-dev odoo shell -d openspp --no-http \ +# < scripts/demo/reset_spdci_demo.py +# +# (DR side has no memberships and no DCI cache — nothing to reset there.) +# +# Linter directives: +# - `env` is injected by Odoo shell — ruff can't resolve it statically. +# - `print()` is the right channel for an interactive shell script. +# ruff: noqa: F821 +# pylint: disable=print-used +# ============================================================================ + +import logging + +_logger = logging.getLogger("reset_spdci_demo") + +DEMO_PROGRAM_ID = 1 +DEMO_UINS = [f"IND-NSR-{n:04d}" for n in range(1, 16)] +WIPE_DCI_CACHE = True # Set False if you want to keep the cache warm + +print("\n=== Resetting SPDCI demo memberships ===\n") + +# Resolve the demo partner ids via their UIN reg_ids +RegId = env["spp.registry.id"] +reg_ids = RegId.search([("value", "in", DEMO_UINS)]) +partner_ids = sorted(set(reg_ids.mapped("partner_id.id"))) +if not partner_ids: + raise RuntimeError("No demo partners found. Run scripts/demo/setup_spdci_demo.py first.") + +print(f" Demo partners: {len(partner_ids)} (ids={partner_ids})") + +# ---- 1. Reset memberships on the demo program ------------------------------ +Membership = env["spp.program.membership"] +program = env["spp.program"].browse(DEMO_PROGRAM_ID).exists() +if not program: + raise RuntimeError(f"spp.program id={DEMO_PROGRAM_ID} not found") + +mems = Membership.search([("program_id", "=", program.id), ("partner_id", "in", partner_ids)]) +before_states = {m.id: m.state for m in mems} +mems.write({"state": "draft", "exit_date": False}) + +print(f"\n Program: {program.name!r} (id={program.id})") +print(f" Memberships reset: {len(mems)}") +for m in mems.sorted("partner_id"): + print(f" partner.id={m.partner_id.id:<4} {m.partner_id.name:<32} {before_states[m.id]!r} -> 'draft'") + +# ---- 2. Wipe the DCI value cache for the demo partners --------------------- +if WIPE_DCI_CACHE: + DataValue = env["spp.data.value"] + cache_rows = DataValue.search( + [ + ("subject_model", "=", "res.partner"), + ("subject_id", "in", partner_ids), + ("variable_name", "in", ["has_disability", "is_poor", "has_dependent_under_school_age"]), + ] + ) + n_cache = len(cache_rows) + cache_rows.unlink() + print(f"\n DCI cache rows wiped: {n_cache}") + print(" Next Enroll Eligible will fire live DCI queries against OpenG2P + OpenSPP-DR.") +else: + print("\n DCI cache untouched (set WIPE_DCI_CACHE=True at the top to also wipe).") + +env.cr.commit() + +print("\n=== Done. Click Enroll Eligible on the program to re-evaluate. ===") +print('Expected outcome with rule `has_disability == true && is_poor == "low"`:') +print(" 4 ENROLLED : Alex Rivera (0001), Morgan Cole (0004), Taylor Brooks (0010), Sam Hayes (0013)") +print(" 11 not eligible") diff --git a/scripts/demo/setup_spdci_demo.py b/scripts/demo/setup_spdci_demo.py new file mode 100644 index 00000000..480da5e6 --- /dev/null +++ b/scripts/demo/setup_spdci_demo.py @@ -0,0 +1,281 @@ +# ============================================================================ +# SPDCI FEDERATED DEMO — ONE-SHOT SEED SCRIPT. DO NOT SHIP IN PRODUCTION. +# ============================================================================ +# +# Creates 4 demo registrants for the SPDCI dry-run on both sides of the +# federated topology (SP and OpenSPP-DR). Each partner's UIN reg_id matches +# a real OpenG2P SR seed identifier so the SP-side `is_poor` lookup returns +# a real `income_level` from OpenG2P during Enroll-Eligible. +# +# RUN ON SP: +# docker compose exec openspp-dev odoo shell -d openspp --no-http \ +# < scripts/demo/setup_spdci_demo.py +# +# RUN ON DR: +# docker compose -f docker-compose.dr.yml exec openspp-dr \ +# odoo shell -d openspp_dr --no-http \ +# < scripts/demo/setup_spdci_demo.py +# +# WHAT THIS IS NOT: +# - Not a module. Production installs of any spp_* module create zero +# registrants. This is an out-of-band seed script run ONLY before a +# demo. Delete the partners after the demo. +# - Not idempotent in a "fix any prior wrong state" sense. It's +# idempotent in the "skip if UIN already exists" sense. To re-run +# cleanly, first delete the partners (see CLEANUP at the bottom). +# +# WHAT IT CREATES: +# - SP side: 15 res.partner records with UIN reg_ids matching every +# OpenG2P SR seed in the IND-NSR-0001..IND-NSR-0015 range. Names +# mirror OpenG2P's actual seed names so the federation story stays +# honest (an SP-side audit row tagged "Alex Rivera" matches what +# OpenG2P would return on probe). If a partner with a given UIN +# already exists, it is RENAMED to match the persona rather than +# skipped — this keeps the script reusable when prior partners are +# already attached to programs and can't be deleted. +# - SP side, additionally: every demo partner is added as a draft +# membership of program record id=DEMO_PROGRAM_ID (default 1) so +# Enroll Eligible can be demonstrated directly. Override the constant +# at the top of the script before running if your program record's +# id differs. +# - DR side: same 15 partners PLUS approved disability assessments +# for 8 of them, distributed so the eligibility matrix exercises +# all four poor×disabled quadrants. +# +# DEMO MATRIX (with CEL rule `has_disability == true && is_poor == "low"`): +# +# | UIN | Persona | OpenG2P income | DR assessment | Verdict | +# |--------------|---------------|----------------|---------------|----------------| +# | IND-NSR-0001 | Alex Rivera | low | approved | ENROLLED | +# | IND-NSR-0002 | Priya Rivera | low | none | not eligible* | +# | IND-NSR-0003 | Noah Rivera | (empty) | none | not eligible | +# | IND-NSR-0004 | Morgan Cole | low | approved | ENROLLED | +# | IND-NSR-0005 | Leah Cole | low | none | not eligible* | +# | IND-NSR-0006 | Nia Cole | (empty) | approved | not eligible** | +# | IND-NSR-0007 | Kim Lee | medium | approved | not eligible** | +# | IND-NSR-0008 | Jun Lee | medium | none | not eligible | +# | IND-NSR-0009 | Rin Lee | (empty) | approved | not eligible** | +# | IND-NSR-0010 | Taylor Brooks | low | approved | ENROLLED | +# | IND-NSR-0011 | Iris Brooks | (empty) | none | not eligible | +# | IND-NSR-0012 | Reyn Brooks | (empty) | none | not eligible | +# | IND-NSR-0013 | Sam Hayes | low | approved | ENROLLED | +# | IND-NSR-0014 | Dev Hayes | low | none | not eligible* | +# | IND-NSR-0015 | Asha Hayes | (empty) | approved | not eligible** | +# +# * = poor but not disabled (DR says no) — exercises has_disability filter +# ** = disabled but not poor (SR says no/medium) — exercises is_poor filter +# +# Enrolled count: 4 / 15. Every quadrant of the (poor × disabled) matrix +# is represented, so the demo can visibly show that BOTH registries must +# agree before a registrant qualifies. +# ============================================================================ +# Linter directives: +# - `env` is injected at runtime by Odoo shell — ruff cannot resolve it +# statically. F821 is suppressed at file scope. +# - `print()` is the right output channel for an interactive shell +# script: the operator running it sees the table on stdout. pylint's +# W8116 print-used rule is module-targeted and over-reaches here. +# - B007 unused-loop-var: the summary loop intentionally unpacks the +# full DEMO_PERSONAS row even though only `uin` is used inside the +# loop body — keeps the unpack in sync with the data structure. +# ruff: noqa: F821, B007 +# pylint: disable=print-used +# ============================================================================ + +import logging + +from odoo import fields + +_logger = logging.getLogger("setup_spdci_demo") + +# On the SP side, the script also adds every demo partner as a draft +# membership of this program so Edwin can demo Enroll Eligible directly +# without walking through the change-request flow. Override before +# running if your program record's id differs. +DEMO_PROGRAM_ID = 1 + +# Each tuple: (UIN, given_name, surname, has_dr_assessment) +# Names match OpenG2P SR seed records (probed 2026-05-15 against +# partner-nsr.play.openg2p.org). has_dr_assessment toggles whether we +# create an approved disability assessment on the DR side — this is +# what makes res.partner.has_disability compute to True. +DEMO_PERSONAS = [ + ("IND-NSR-0001", "Alex", "Rivera", True), # poor + disabled -> ENROLLED + ("IND-NSR-0002", "Priya", "Rivera", False), # poor only + ("IND-NSR-0003", "Noah", "Rivera", False), # neither + ("IND-NSR-0004", "Morgan", "Cole", True), # poor + disabled -> ENROLLED + ("IND-NSR-0005", "Leah", "Cole", False), # poor only + ("IND-NSR-0006", "Nia", "Cole", True), # disabled only (no income) + ("IND-NSR-0007", "Kim", "Lee", True), # disabled only (medium income) + ("IND-NSR-0008", "Jun", "Lee", False), # neither (medium income, no disability) + ("IND-NSR-0009", "Rin", "Lee", True), # disabled only (no income) + ("IND-NSR-0010", "Taylor", "Brooks", True), # poor + disabled -> ENROLLED + ("IND-NSR-0011", "Iris", "Brooks", False), # neither + ("IND-NSR-0012", "Reyn", "Brooks", False), # neither + ("IND-NSR-0013", "Sam", "Hayes", True), # poor + disabled -> ENROLLED + ("IND-NSR-0014", "Dev", "Hayes", False), # poor only + ("IND-NSR-0015", "Asha", "Hayes", True), # disabled only (no income) +] + +# Detect side: DR-side has spp.disability.assessment installed +on_dr_side = "spp.disability.assessment" in env +side_label = "DR" if on_dr_side else "SP" +_logger.warning( + "=== DEMO SEED: setting up %d federated-demo partners on the %s side. DO NOT use this in production. ===", + len(DEMO_PERSONAS), + side_label, +) +print(f"\n=== Setting up demo partners on the {side_label} side ===\n") + +# Find UIN vocabulary code; use get_or_create_local to bypass system-vocab protection +vocab_id_type = env.ref("spp_vocabulary.vocab_id_type", raise_if_not_found=False) +if not vocab_id_type: + raise RuntimeError("spp_vocabulary.vocab_id_type not found — install spp_vocabulary first") + +Code = env["spp.vocabulary.code"] +uin_code = Code.with_context(active_test=False).search( + [("vocabulary_id", "=", vocab_id_type.id), ("code", "=", "UIN")], + limit=1, +) +if not uin_code: + uin_code = Code.get_or_create_local( + namespace_uri="urn:openspp:vocab:id-type", + code="UIN", + display="UIN (Universal Identification Number)", + ) + print(f" Seeded UIN vocab code (id={uin_code.id})") + +Partner = env["res.partner"] +RegId = env["spp.registry.id"] + +demo_partners = env["res.partner"].browse() + +for uin, given, surname, has_dr_assessment in DEMO_PERSONAS: + # If a partner already has this UIN, RENAME to match the persona + # rather than skip. This makes the script reusable when a DB already + # has IND-NSR-XXXX partners enrolled in programs (we can't delete + # them without orphaning memberships, but we can rebrand them). + existing = RegId.search([("value", "=", uin), ("id_type_id", "=", uin_code.id)], limit=1) + persona_values = { + "name": f"{given} {surname}", + "given_name": given, + "family_name": surname, + "is_registrant": True, + "is_group": False, + "birthdate": "1990-01-01", + } + if existing: + partner = existing.partner_id + before = partner.name + partner.write(persona_values) + if before != partner.name: + print(f" ↻ {uin} partner.id={partner.id} renamed: {before!r} -> {partner.name!r}") + else: + print(f" ↻ {uin} partner.id={partner.id} already named {partner.name!r}") + else: + partner = Partner.create(persona_values) + RegId.create( + { + "partner_id": partner.id, + "id_type_id": uin_code.id, + "value": uin, + } + ) + print(f" ✓ Created {given} {surname} (UIN={uin}, partner.id={partner.id})") + demo_partners |= partner + + # DR-side only: ensure an approved disability assessment exists + # for the personas flagged has_dr_assessment. + if on_dr_side and has_dr_assessment: + Assessment = env["spp.disability.assessment"] + existing_asmt = Assessment.search( + [("registrant_id", "=", partner.id), ("approval_state", "=", "approved")], + limit=1, + ) + if existing_asmt: + print(f" - approved assessment already exists (id={existing_asmt.id})") + else: + asmt = Assessment.create( + { + "registrant_id": partner.id, + "assessment_date": fields.Date.today(), + # Force has_disability=True by setting one WG domain to severe. + # _compute_disability_indicator sets has_disability when any + # WG_* field is 'a_lot' or 'cannot'. + "wg_walking": "a_lot", + "review_category": "mip", # 3-year review cadence + } + ) + # Bypass the approval workflow — direct write for demo seed only. + asmt.write({"approval_state": "approved"}) + # Touch the related partner so has_disability propagates immediately. + partner.invalidate_recordset(["current_disability_assessment_id", "has_disability"]) + print( + f" ✓ Created approved assessment (id={asmt.id}, " + f"partner.has_disability now {partner.has_disability})" + ) + +# SP-side only: add every demo partner as a draft membership of the +# program with record ID = 1, so Edwin can demo Enroll Eligible directly +# without first walking through the change-request flow to add members +# (his colleague demos that part on a separate instance). +# Memberships start in state='draft'; eligibility evaluation flips them +# to 'enrolled' or 'not_eligible' based on the CEL rule. +if not on_dr_side: + program = env["spp.program"].browse(DEMO_PROGRAM_ID).exists() + if not program: + print("\n ⚠ spp.program id=1 not found on SP — skipping bulk-enroll step.") + print(" Create the program first (or change DEMO_PROGRAM_ID at the top of the script).") + else: + Membership = env["spp.program.membership"] + added = 0 + already = 0 + for partner in demo_partners: + existing_mem = Membership.search( + [("partner_id", "=", partner.id), ("program_id", "=", program.id)], + limit=1, + ) + if existing_mem: + already += 1 + continue + Membership.create( + { + "partner_id": partner.id, + "program_id": program.id, + "state": "draft", + } + ) + added += 1 + print( + f"\n ✓ Program '{program.name}' (id={program.id}): " + f"{added} new memberships added, {already} already members " + f"({len(demo_partners)} demo partners total)." + ) + +env.cr.commit() + +# Summary +print("\n=== Summary ===") +for uin, given, surname, has_dr_assessment in DEMO_PERSONAS: + reg = RegId.search([("value", "=", uin), ("id_type_id", "=", uin_code.id)], limit=1) + p = reg.partner_id + if on_dr_side: + hd = "has_disability=True" if p.has_disability else "has_disability=False" + else: + hd = "(DR side controls disability)" + print(f" {uin} partner.id={p.id:<5} {p.name:<22} {hd}") + +print("\n=== Done. ===") +print( + "Next: run this same script against the OTHER side." + if on_dr_side + else "Next: run this same script against the DR (openspp_dr database)." +) +print("\nCLEANUP after the demo:") +print(" Delete the 4 partners via UI, or:") +print(" >>> uin_code = env.ref('spp_vocabulary.vocab_id_type')") +print(" >>> RegId = env['spp.registry.id']") +print(" >>> uins = [f'IND-NSR-{n:04d}' for n in range(1, 16)]") +print(" >>> partners = RegId.search([('value', 'in', uins)]).mapped('partner_id')") +print(" >>> partners.unlink()") +print(" >>> env.cr.commit()") diff --git a/scripts/lint/check_naming.py b/scripts/lint/check_naming.py index c6197b0d..d494ed18 100755 --- a/scripts/lint/check_naming.py +++ b/scripts/lint/check_naming.py @@ -243,16 +243,34 @@ def check_python_file(self, file_path: str, fix: bool = False) -> list[Violation return violations def _check_imports(self, file_path: str, tree: ast.AST) -> list[Violation]: - """Check for deprecated g2p imports.""" + """Check for deprecated g2p imports. + + The deprecated prefix is `g2p_` / `g2p.` at a token boundary — + i.e., a top-level `g2p_*` package, or a `.g2p_*` segment inside + a dotted module path. The plain substring check (`"g2p_" in + module_name`) produces false positives against unrelated + namespaces that happen to embed the letters — `openg2p_*` is + the OpenG2P platform's distinct namespace and should NOT flag. + Anchor the match to a path-segment boundary so only true + g2p_/g2p. tokens are caught. + """ violations = [] severity = self.config.get_severity("naming.g2p_import", Severity.ERROR) + def is_deprecated_g2p(module_name: str) -> bool: + return ( + module_name.startswith("g2p_") + or module_name.startswith("g2p.") + or ".g2p_" in module_name + or ".g2p." in module_name + ) + for node in ast.walk(tree): if isinstance(node, (ast.Import, ast.ImportFrom)): line = node.lineno if isinstance(node, ast.ImportFrom) and node.module: - if node.module.startswith("g2p") or "g2p_" in node.module: + if is_deprecated_g2p(node.module): new_module = node.module.replace("g2p_", "spp_").replace("g2p.", "spp.") violations.append( Violation( @@ -268,7 +286,7 @@ def _check_imports(self, file_path: str, tree: ast.AST) -> list[Violation]: elif isinstance(node, ast.Import): for alias in node.names: - if alias.name.startswith("g2p") or "g2p_" in alias.name: + if is_deprecated_g2p(alias.name): new_name = alias.name.replace("g2p_", "spp_").replace("g2p.", "spp.") violations.append( Violation( diff --git a/spp_cel_dci_bridge/README.rst b/spp_cel_dci_bridge/README.rst new file mode 100644 index 00000000..da0f96f1 --- /dev/null +++ b/spp_cel_dci_bridge/README.rst @@ -0,0 +1,300 @@ +========================== +OpenSPP CEL <-> DCI Bridge +========================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:55a0a619c513154a0395240a39e1b92a09fc61db48aff0882e448e15ad111d4b + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OpenSPP%2FOpenSPP2-lightgray.png?logo=github + :target: https://github.com/OpenSPP/OpenSPP2/tree/19.0/spp_cel_dci_bridge + :alt: OpenSPP/OpenSPP2 + +|badge1| |badge2| |badge3| + +Bridges OpenSPP's CEL expression engine to external DCI registries. CEL +eligibility rules of the form ``has_disability == true`` automatically +fetch values from a configured DCI registry (Disability Registry, CRVS, +IBR), cache them in ``spp.data.value``, and resolve as standard SQL +filters during program enrollment. No CEL grammar changes; the +integration sits behind one cache-manager override. + +Key Capabilities +~~~~~~~~~~~~~~~~ + +- Override ``spp.data.cache.manager._compute_variable_values`` to route + ``source_type='external'`` CEL variables linked to a DCI data source + through the DCI client family instead of returning empty +- Dispatch by ``registry_type`` to the appropriate DCI service + (``DRService``, ``CRVSService``, ``IBRService``) with runtime + ImportError guards so the bridge installs cleanly when some clients + are absent +- Normalize the three inconsistent registry_type conventions used by + existing DCI clients (``"DR"``, ``"ns:org:RegistryType:Civil"``, + ``"ibr"``) to a single canonical key for routing +- Apply per-variable ``external_failure_policy``: ``null`` (default; + cache as null), ``last_known`` (surface most recent non-null cached + value), ``fail`` (propagate as UserError) +- Fill missing subjects with explicit None so the cache stays complete + across the cohort — letting the CEL executor use the metric SQL fast + path instead of falling back to Python evaluation +- Record one ``spp.dci.fetch.audit`` row per subject per fetch + (provider, source, registry, variable, outcome, elapsed_ms, + error_message) for compliance + +Key Models +~~~~~~~~~~ + ++----------------------------+-----------------------------------------+ +| Model | Description | ++============================+=========================================+ +| ``spp.cel.dci.dispatcher`` | AbstractModel routing fetch requests to | +| | per-registry-type handlers | ++----------------------------+-----------------------------------------+ +| ``spp.dci.fetch.audit`` | One row per subject per DCI fetch | +| | attempt for compliance audit | ++----------------------------+-----------------------------------------+ + +Schema Extensions +~~~~~~~~~~~~~~~~~ + ++-------------------------+-----------------------------+----------------------------------+ +| Model | Field | Purpose | ++=========================+=============================+==================================+ +| ``spp.data.provider`` | ``dci_data_source_id`` | Links the CEL provider to a DCI | +| | | data source | ++-------------------------+-----------------------------+----------------------------------+ +| ``spp.data.provider`` | ``is_dci_backed`` | True when the provider routes | +| | (computed) | through DCI | ++-------------------------+-----------------------------+----------------------------------+ +| ``spp.cel.variable`` | ``dci_attribute_path`` | Dotted path into the DCI | +| | | response (e.g., | +| | | ``has_disability``, | +| | | ``functional_scores.cognition``) | ++-------------------------+-----------------------------+----------------------------------+ +| ``spp.cel.variable`` | ``external_failure_policy`` | Behaviour on fetch failure: null | +| | | / last_known / fail | ++-------------------------+-----------------------------+----------------------------------+ +| ``spp.dci.data.source`` | ``vendor`` (Selection) | Vendor-adapter discriminator. | +| | | The selection starts empty; | +| | | vendor presets | +| | | (``spp_dci_openg2p``, | +| | | ``spp_dci_openspp_dr``) extend | +| | | it via ``selection_add``. The | +| | | dispatcher consults this field | +| | | to route registry-type handlers | +| | | to vendor-specific services. | ++-------------------------+-----------------------------+----------------------------------+ + +Views +~~~~~ + ++-------------------------------------+----------------------------------+ +| File | Purpose | ++=====================================+==================================+ +| ``views/dci_data_source_views.xml`` | Surfaces the ``vendor`` field on | +| | the upstream | +| | ``spp.dci.data.source`` form | +| | (after Authentication Type) and | +| | list (after Registry Type) so | +| | operators can set the adapter | +| | without developer mode. | ++-------------------------------------+----------------------------------+ +| ``views/data_provider_views.xml`` | Provider list/form additions for | +| | the DCI link. | ++-------------------------------------+----------------------------------+ +| ``views/cel_variable_views.xml`` | CEL-variable additions for | +| | ``dci_attribute_path`` and | +| | ``external_failure_policy``. | ++-------------------------------------+----------------------------------+ +| ``views/dci_fetch_audit_views.xml`` | List/form for | +| | ``spp.dci.fetch.audit`` | +| | (read-only, ACL-gated). | ++-------------------------------------+----------------------------------+ + +Architecture +~~~~~~~~~~~~ + +:: + + CEL: has_disability == true + | + v (resolver) + metric('has_disability', me) == true + | + v (translator -> executor SQL fast path) + id IN (SELECT subject_id FROM spp_data_value WHERE ...) + | + v (populated by precompute, before eligibility runs) + cache_mgr.precompute_cached_variables(...) + | + v (overridden in this module) + _compute_variable_values(var, subjects) + | + v (when var is DCI-backed) + spp.cel.dci.dispatcher.fetch_values_for_variable(var, subjects) + | + v (registry_type='DR') + DRService.get_disability_status(partner) + | + v (writes back) + spp.data.value rows + spp.dci.fetch.audit rows + +The cycle pre-fetch hook +(``cycle_manager_base._precompute_cycle_cached_variables``) is already +wired in ``spp_programs`` — installing the bridge plus a vendor preset +(e.g., ``spp_dci_openg2p``) wires the whole flow without further code. + +See Also +~~~~~~~~ + +- ``spp_dci_openg2p`` — permanent OpenG2P vendor preset that ships + pre-configured data source, provider, and CEL variable wiring +- ADR-023 — decision rationale, alternatives considered, failure modes, + future async work + +.. IMPORTANT:: + This is an alpha version, the data model and design can change at any time without warning. + Only for development or testing purpose, do not use in production. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Writing CEL rules against DCI-backed variables +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +CEL accessors are **vendor-neutral**. The eligibility rule reads the +semantic concept; the vendor identity lives in configuration records. + +:: + + has_disability == true && age_years(r.birthdate) >= 18 + +The bridge does not change CEL syntax. To switch from one DCI registry +to another (OpenG2P → national DR, mock → production), change the data +source configuration; CEL rules are not edited. + +Configuring a DCI-backed variable manually +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +1. Create a ``spp.dci.data.source`` record with ``auth_type``, + ``base_url``, ``registry_type``, and OAuth2 credentials. +2. Optional: set the **Vendor Adapter** field (defined here as a + Selection field with an empty selection; vendor presets extend it via + ``selection_add``). Set when a vendor preset registered its adapter — + e.g., ``openg2p``, ``openspp`` — so the bridge dispatcher routes + through the vendor-specific service. Leave blank for sources that + speak vanilla SPDCI. +3. Create a ``spp.data.provider`` and set ``dci_data_source_id`` to the + source above. +4. Create or repurpose a ``spp.cel.variable``: + + - ``source_type = 'external'`` + - ``external_provider_id`` = the provider + - ``dci_attribute_path`` = the dotted path into the DCI response + payload (e.g., ``has_disability``, ``severity.code``, + ``functional_scores.cognition``) + - ``cache_strategy = 'ttl'`` (or ``'manual'``) + - ``cache_ttl_seconds`` = TTL in seconds (300 for demo, 86400 for + production) + - ``external_failure_policy`` = null / last_known / fail + +For typical OpenG2P deployments install ``spp_dci_openg2p``; for an +OpenSPP-DR instance install ``spp_dci_openspp_dr`` — each ships a +permanent preset. + +Pre-warm behaviour +~~~~~~~~~~~~~~~~~~ + +When ``Enroll Eligible`` / ``Import Eligible`` runs at the program +level, the bridge eagerly pre-warms **every active DCI-backed CEL +variable** for the cohort, regardless of which variables the program's +specific CEL rule references. This is by design — the executor's SQL +fast path needs a fresh cache for any ``metric()`` accessor the rule +could reference, and parsing the rule up front to extract referenced +names was traded off for simplicity. Side effect: a program that only +checks ``has_disability`` still produces audit rows for ``is_poor`` and +any other active SR variables in the cohort. + +To exclude a variable from the pre-warm, set ``state='inactive'`` and +``active=False`` on the ``spp.cel.variable`` record. The pre-warm filter +applies ``("active", "=", True)``, so inactive variables are skipped — +useful for deferred-feature placeholders. Such variables are also +unavailable to CEL rules (compound rules referencing them evaluate the +comparison against null, which fails the filter). + +Failure policies +~~~~~~~~~~~~~~~~ + ++----------------+-----------------------------------------------------+ +| Policy | Behaviour | ++================+=====================================================+ +| ``null`` | Default. Errored subjects cache as null; CEL | +| | evaluates against null. | ++----------------+-----------------------------------------------------+ +| ``last_known`` | Surface most recent non-null cached value, | +| | regardless of expiry. | ++----------------+-----------------------------------------------------+ +| ``fail`` | Propagate the exception as UserError. Eligibility | +| | check aborts. | ++----------------+-----------------------------------------------------+ + +Audit +~~~~~ + +Every DCI fetch records one row in ``spp.dci.fetch.audit``: + +- Navigate to the menu surfaced via ``view_dci_fetch_audit_list`` +- Filter by variable, provider, result (ok / not_found / error) +- Read access for all internal users; write access for spp admin only + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* OpenSPP.org + +Maintainers +----------- + +.. |maintainer-jeremi| image:: https://github.com/jeremi.png?size=40px + :target: https://github.com/jeremi + :alt: jeremi +.. |maintainer-gonzalesedwin1123| image:: https://github.com/gonzalesedwin1123.png?size=40px + :target: https://github.com/gonzalesedwin1123 + :alt: gonzalesedwin1123 + +Current maintainers: + +|maintainer-jeremi| |maintainer-gonzalesedwin1123| + +This module is part of the `OpenSPP/OpenSPP2 `_ project on GitHub. + +You are welcome to contribute. \ No newline at end of file diff --git a/spp_cel_dci_bridge/__init__.py b/spp_cel_dci_bridge/__init__.py new file mode 100644 index 00000000..4cc91a92 --- /dev/null +++ b/spp_cel_dci_bridge/__init__.py @@ -0,0 +1,2 @@ +from . import exceptions +from . import models diff --git a/spp_cel_dci_bridge/__manifest__.py b/spp_cel_dci_bridge/__manifest__.py new file mode 100644 index 00000000..dc84e713 --- /dev/null +++ b/spp_cel_dci_bridge/__manifest__.py @@ -0,0 +1,32 @@ +# pylint: disable=pointless-statement +{ + "name": "OpenSPP CEL <-> DCI Bridge", + "summary": "Fetch CEL variable values from external DCI registries", + "version": "19.0.1.0.0", + "category": "OpenSPP/Integration", + "author": "OpenSPP.org", + "website": "https://github.com/OpenSPP/OpenSPP2", + "license": "LGPL-3", + "development_status": "Alpha", + "maintainers": ["jeremi", "gonzalesedwin1123"], + "depends": [ + "spp_cel_domain", + "spp_dci_client", + "spp_dci_client_dr", + "spp_dci_client_crvs", + "spp_dci_client_ibr", + "spp_programs", + "spp_studio", + ], + "external_dependencies": {"python": []}, + "data": [ + "security/ir.model.access.csv", + "views/data_provider_views.xml", + "views/cel_variable_views.xml", + "views/dci_fetch_audit_views.xml", + "views/dci_data_source_views.xml", + ], + "installable": True, + "application": False, + "auto_install": False, +} diff --git a/spp_cel_dci_bridge/exceptions.py b/spp_cel_dci_bridge/exceptions.py new file mode 100644 index 00000000..6d1a409c --- /dev/null +++ b/spp_cel_dci_bridge/exceptions.py @@ -0,0 +1,26 @@ +"""Bridge-specific exceptions. + +Distinguish *configuration* errors (broken setup, unsupported handler) +from *runtime* errors (transient registry failures). Configuration errors +must surface immediately to operators; runtime errors are subject to the +variable's external_failure_policy (null / last_known / fail). +""" + +from odoo.exceptions import UserError + + +class DCIConfigurationError(UserError): + """Setup-time problem with the DCI integration. + + Examples: + - The DCI client module required by a variable's registry_type is + not installed (handler hits the ImportError branch). + - A variable's registry_type has no concrete handler (e.g., SR/FR + in v1). + - Required configuration on a data source or provider is missing + and cannot be silently substituted. + + Always propagates through _compute_dci_values regardless of policy. + Operators must see broken integration immediately — silently treating + these as "no one is eligible" is a compliance hazard. + """ diff --git a/spp_cel_dci_bridge/models/__init__.py b/spp_cel_dci_bridge/models/__init__.py new file mode 100644 index 00000000..0775f747 --- /dev/null +++ b/spp_cel_dci_bridge/models/__init__.py @@ -0,0 +1,7 @@ +from . import data_provider +from . import cel_variable +from . import dci_data_source +from . import dci_fetch_audit +from . import dci_dispatcher +from . import data_cache_manager +from . import eligibility_manager diff --git a/spp_cel_dci_bridge/models/cel_variable.py b/spp_cel_dci_bridge/models/cel_variable.py new file mode 100644 index 00000000..9628832f --- /dev/null +++ b/spp_cel_dci_bridge/models/cel_variable.py @@ -0,0 +1,53 @@ +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class CELVariable(models.Model): + _inherit = "spp.cel.variable" + + # Related field so views can gate visibility/required on the provider's + # is_dci_backed flag without writing a chained dotted-path expression + # (which Odoo's view validator rejects). + external_provider_is_dci_backed = fields.Boolean( + related="external_provider_id.is_dci_backed", + store=False, + readonly=True, + ) + + dci_attribute_path = fields.Char( + string="DCI Attribute Path", + help=( + "Dotted path into the DCI response payload " + "(e.g., 'has_disability', 'severity.code', " + "'functional_scores.cognition'). Required when the variable's " + "external provider is DCI-backed." + ), + ) + + external_failure_policy = fields.Selection( + selection=[ + ("null", "Return null (default)"), + ("last_known", "Return last known value"), + ("fail", "Propagate exception"), + ], + default="null", + help=( + "Behaviour when the external DCI fetch fails for a subject:\n" + "- null: cache value as null; CEL evaluates against null.\n" + "- last_known: return the most recent non-null cached value, " + "regardless of expiry. Log a warning.\n" + "- fail: propagate the exception. Use for compliance-critical " + "rules." + ), + ) + + @api.constrains("source_type", "external_provider_id", "dci_attribute_path") + def _check_dci_attribute_path(self): + for rec in self: + if ( + rec.source_type == "external" + and rec.external_provider_id + and rec.external_provider_id.is_dci_backed + and not rec.dci_attribute_path + ): + raise ValidationError(_("DCI-backed external variables must define a DCI Attribute Path.")) diff --git a/spp_cel_dci_bridge/models/data_cache_manager.py b/spp_cel_dci_bridge/models/data_cache_manager.py new file mode 100644 index 00000000..9a2623a6 --- /dev/null +++ b/spp_cel_dci_bridge/models/data_cache_manager.py @@ -0,0 +1,146 @@ +import logging + +from odoo import _, models +from odoo.exceptions import UserError + +from ..exceptions import DCIConfigurationError + +_logger = logging.getLogger(__name__) + + +class DataCacheManager(models.AbstractModel): + """Route DCI-backed external CEL variables through the DCI dispatcher. + + The parent implementation (spp_cel_domain) treats source_type='external' + as a push-only path: it returns {} and logs that values must be pushed + via API. This override fills that gap by calling the dispatcher for + variables whose external_provider_id is linked to a DCI data source. + + Non-DCI external variables continue to fall through to the parent + implementation unchanged. + """ + + _inherit = "spp.data.cache.manager" + + def _compute_variable_values(self, variable, subject_ids, period_key, program_id): + if ( + variable.source_type == "external" + and variable.external_provider_id + and variable.external_provider_id.is_dci_backed + ): + return self._compute_dci_values(variable, subject_ids, period_key, program_id) + return super()._compute_variable_values(variable, subject_ids, period_key, program_id) + + def _compute_dci_values(self, variable, subject_ids, period_key, program_id): + """Fetch DCI-backed values, then apply the variable's failure policy. + + Every queried subject ends up in the returned dict — either with the + fetched value, the last-known cached value (last_known policy), or + explicit None (null policy). This ensures the resulting cache covers + the entire cohort, so the CEL executor's metric SQL fast path sees a + 'fresh' cache state and uses SQL instead of falling back to Python + evaluation (which requires spp.indicator). + """ + dispatcher = self.env["spp.cel.dci.dispatcher"] + policy = variable.external_failure_policy or "null" + + try: + values = dispatcher.fetch_values_for_variable(variable, subject_ids, period_key) + except DCIConfigurationError: + # Configuration errors (missing client module, unimplemented + # handler) always propagate, regardless of policy. Silently + # treating these as "no one is eligible" would be a compliance + # hazard — operators must see the broken setup immediately. + raise + except Exception as e: + _logger.error( + "DCI fetch failed for variable %s (policy=%s): %s", + variable.name, + policy, + e, + exc_info=True, + ) + if policy == "fail": + raise UserError( + _( + "DCI fetch failed for variable '%(var)s': %(err)s", + var=variable.name, + err=e, + ) + ) from e + values = {} + + if policy == "last_known": + missing = set(subject_ids) - set(values.keys()) + if missing: + values = self._augment_with_last_known(variable, values, missing) + + # Fill any still-missing subjects with explicit None. The cache writer + # records {"value": null}; CEL boolean comparisons against null + # evaluate to null (postgres) which fails WHERE clauses — i.e., the + # subject does not match `has_disability == true`, which is the right + # semantic for "we asked the registry and got nothing back." + for sid in subject_ids: + values.setdefault(sid, None) + + return values + + def _augment_with_last_known(self, variable, values, missing_subject_ids): + """Fill missing subjects from the most recent cached non-null value. + + Ignores expiry — the whole point of 'last_known' policy is to surface + stale-but-known answers when the live source is unavailable. Logs a + warning per subject so operators can see what's degraded. + + Uses DISTINCT ON to pick the latest non-null row per subject in a + single query. The ORM-search-then-filter approach would fetch every + historical row for the missing subjects and Python-filter to the + latest — fine at demo scale, but O(history × cohort) and degrades + sharply for deployments with daily TTL refresh over months. + + Filters out JSON null (`{"value": null}`) at the SQL layer so we + don't surface "we previously fetched nothing" as a last-known value. + """ + if not missing_subject_ids: + return values + + missing_list = list(missing_subject_ids) + self.env.cr.execute( + """ + SELECT DISTINCT ON (subject_id) + subject_id, + value_json, + recorded_at + FROM spp_data_value + WHERE variable_name = %s + AND subject_id = ANY(%s) + AND subject_model = %s + AND company_id = %s + AND (value_json -> 'value') IS NOT NULL + AND (value_json -> 'value') != 'null'::jsonb + ORDER BY subject_id, recorded_at DESC, id DESC + """, + ( + variable.name, + missing_list, + "res.partner", + self.env.company.id, + ), + ) + + filled = dict(values) + for subject_id, value_json, recorded_at in self.env.cr.fetchall(): + payload = value_json + if not isinstance(payload, dict): + continue + inner = payload.get("value") + if inner is None: + continue # belt-and-suspenders; SQL filter should have excluded these + filled[subject_id] = inner + _logger.warning( + "Variable %s: using last-known value for subject %d (recorded_at=%s) due to fetch failure", + variable.name, + subject_id, + recorded_at, + ) + return filled diff --git a/spp_cel_dci_bridge/models/data_provider.py b/spp_cel_dci_bridge/models/data_provider.py new file mode 100644 index 00000000..1e5992b2 --- /dev/null +++ b/spp_cel_dci_bridge/models/data_provider.py @@ -0,0 +1,27 @@ +from odoo import api, fields, models + + +class DataProvider(models.Model): + _inherit = "spp.data.provider" + + dci_data_source_id = fields.Many2one( + "spp.dci.data.source", + string="DCI Data Source", + ondelete="restrict", + help=( + "When set, this provider fetches values via the DCI protocol. " + "The registry_type on the DCI source determines which DCI " + "service handles the call." + ), + ) + + is_dci_backed = fields.Boolean( + string="DCI-Backed", + compute="_compute_is_dci_backed", + store=True, + ) + + @api.depends("dci_data_source_id") + def _compute_is_dci_backed(self): + for rec in self: + rec.is_dci_backed = bool(rec.dci_data_source_id) diff --git a/spp_cel_dci_bridge/models/dci_data_source.py b/spp_cel_dci_bridge/models/dci_data_source.py new file mode 100644 index 00000000..8fbd5197 --- /dev/null +++ b/spp_cel_dci_bridge/models/dci_data_source.py @@ -0,0 +1,33 @@ +from odoo import fields, models + + +class DCIDataSource(models.Model): + """Add a ``vendor`` discriminator the bridge dispatcher uses to route + requests to vendor-specific DCI client adapters. + + The DCI spec leaves several request/response shapes ambiguous (query + types, response wrappers, consent block placement). Different + deployments and vendors have picked different interpretations. Rather + than fork the upstream DCIClient, sources are marked with a + ``vendor`` value and the dispatcher's per-registry-type handlers + (``_handler_dr``, ``_handler_sr``, etc.) consult it before delegating + to the right adapter. + + The selection starts empty — each vendor preset module + (``spp_dci_openg2p``, ``spp_dci_openspp_dr``, ...) extends it via + ``selection_add`` when registering its own adapter. + """ + + _inherit = "spp.dci.data.source" + + vendor = fields.Selection( + selection=[], + string="Vendor Adapter", + help=( + "Optional vendor identifier. When set, the bridge dispatcher " + "routes to a vendor-specific DCI client adapter instead of " + "the generic registry-type service. Use only when a registry " + "has known protocol-shape quirks that the standard client " + "cannot absorb via configuration alone." + ), + ) diff --git a/spp_cel_dci_bridge/models/dci_dispatcher.py b/spp_cel_dci_bridge/models/dci_dispatcher.py new file mode 100644 index 00000000..bc70667a --- /dev/null +++ b/spp_cel_dci_bridge/models/dci_dispatcher.py @@ -0,0 +1,465 @@ +import logging +import time + +from odoo import _, api, models + +from ..exceptions import DCIConfigurationError + +_logger = logging.getLogger(__name__) + + +class DCIDispatcher(models.AbstractModel): + """Route CEL variable fetches to the appropriate DCI registry-type handler. + + The dispatcher is the single seam between the CEL bridge and the DCI client + family. It looks at the DCI data source attached to a CEL variable's + provider, picks the handler keyed by `registry_type`, and asks it to + resolve `{subject_id: value}` for the given subjects. + + Handlers tolerate missing DCI client modules: if `spp_dci_client_dr` is + not installed, the DR handler returns `{}` and logs a warning rather than + raising. This keeps the bridge installable in deployments that only need + some registry types. + """ + + _name = "spp.cel.dci.dispatcher" + _description = "CEL <-> DCI Dispatcher" + + _HANDLERS = { + "DR": "_handler_dr", + "CRVS": "_handler_crvs", + "IBR": "_handler_ibr", + "SR": "_handler_sr", + "FR": "_handler_fr", + } + + # The existing DCI client modules use inconsistent registry_type strings: + # - spp_dci_client_dr checks for "DR" + # - spp_dci_client_crvs checks for "ns:org:RegistryType:Civil" + # - spp_dci_client_ibr checks for "ibr" + # The bridge accepts every known form and maps it to a canonical key + # before dispatching. Upstream cleanup of the registry_type field is + # tracked separately; the bridge cannot wait on that. + _REGISTRY_TYPE_ALIASES = { + "DR": "DR", + "dr": "DR", + "ns:org:RegistryType:DR": "DR", + "CRVS": "CRVS", + "crvs": "CRVS", + "ns:org:RegistryType:Civil": "CRVS", + "IBR": "IBR", + "ibr": "IBR", + "ns:org:RegistryType:IBR": "IBR", + "SR": "SR", + "SOCIAL_REGISTRY": "SR", + "ns:org:RegistryType:Social": "SR", + "FR": "FR", + "FUNCTIONAL_REGISTRY": "FR", + "ns:org:RegistryType:FR": "FR", + } + + @api.model + def fetch_values_for_variable(self, variable, subject_ids, period_key): + """Resolve values for a CEL variable backed by a DCI registry. + + Args: + variable: spp.cel.variable record with source_type='external' + and a DCI-backed external_provider_id. + subject_ids: list of res.partner IDs to fetch values for. + period_key: period key (e.g., 'current', '2026-Q2'). + + Returns: + dict mapping subject_id to the extracted attribute value. + Subjects with no resolvable value are omitted from the dict; + the cache manager records them as null. + """ + if not subject_ids: + return {} + + provider = variable.external_provider_id + if not provider or not provider.is_dci_backed: + return {} + + source = provider.dci_data_source_id + if not source or not source.active: + _logger.warning( + "Variable %s: DCI source %s is missing or inactive", + variable.name, + source and source.code, + ) + return {} + + canonical = self._REGISTRY_TYPE_ALIASES.get(source.registry_type) + handler_name = self._HANDLERS.get(canonical) if canonical else None + if not handler_name: + raise DCIConfigurationError( + _( + "No DCI handler for registry_type=%(reg)s on variable %(var)s", + reg=source.registry_type, + var=variable.name, + ) + ) + + handler = getattr(self, handler_name) + return handler(variable, source, subject_ids, period_key) + + # ------------------------------------------------------------------ + # Registry-type handlers + # + # Each handler: + # - Checks the corresponding DCI client module is installed. + # - Iterates subject_ids, calling the underlying DCI service per subject. + # - Extracts the attribute named by variable.dci_attribute_path. + # - Returns {subject_id: value}; subjects with no value are omitted. + # ------------------------------------------------------------------ + + def _handler_dr(self, variable, source, subject_ids, period_key): + """Call the Disability Registry DCI service for each subject. + + Returns {subject_id: value} where value is the attribute named by + `variable.dci_attribute_path` extracted from the DR response payload. + Subjects with no DR record, no matching identifier, or no value at + the configured path are omitted from the returned dict. + + Records one spp.dci.fetch.audit row per subject regardless of outcome. + """ + try: + from odoo.addons.spp_dci_client_dr.services.dr_service import ( + DRService, + ) + except ImportError as e: + raise DCIConfigurationError( + _( + "spp_dci_client_dr is not installed; cannot fetch " + "variable %(var)s. Install spp_dci_client_dr or " + "reconfigure the variable to use a different registry.", + var=variable.name, + ) + ) from e + + service = DRService(self.env, data_source_code=source.code) + Partner = self.env["res.partner"] + partners = Partner.browse(subject_ids).exists() + path = variable.dci_attribute_path + + result = {} + for partner in partners: + started = time.monotonic() + try: + payload = service.get_disability_status(partner) + except Exception as e: + self._record_audit( + variable, + source, + partner.id, + "error", + started, + error_message=str(e), + ) + _logger.warning( + "DR fetch failed for partner %d (var=%s): %s", + partner.id, + variable.name, + e, + ) + continue + + if payload is None: + self._record_audit( + variable, + source, + partner.id, + "not_found", + started, + ) + continue + + value = self._extract_by_path(payload, path) + if value is None: + self._record_audit( + variable, + source, + partner.id, + "not_found", + started, + ) + continue + + result[partner.id] = value + self._record_audit( + variable, + source, + partner.id, + "ok", + started, + ) + + return result + + # ------------------------------------------------------------------ + # Audit logging + # ------------------------------------------------------------------ + + def _record_audit(self, variable, source, subject_id, result, started_at, error_message=None): + """Write one spp.dci.fetch.audit row. + + Captures the acting user id BEFORE escalating to sudo so the audit + preserves operator attribution. Without this, the user_id field's + `default=lambda self: self.env.user` resolves against the sudoed env + and every row records as user_root — defeating the compliance + purpose. Audit writes go through sudo because background workers + (precompute job, cycle pre-fetch) may not hold spp_admin rights, + but every fetch must produce a row. Reading the audit is still + ACL-gated. + """ + try: + elapsed_ms = int((time.monotonic() - started_at) * 1000) + acting_user_id = self.env.uid + # sudo() is intentional: background workers may not have write + # rights on the audit model, but every fetch must produce a row. + # acting_user_id captured above preserves operator attribution. + self.env["spp.dci.fetch.audit"].sudo().create( # nosemgrep: odoo-sudo-without-context + { + "user_id": acting_user_id, + "provider_code": variable.external_provider_id.code, + "data_source_code": source.code, + "registry_type": source.registry_type, + "variable_name": variable.name, + "subject_model": "res.partner", + "subject_id": subject_id, + "result": result, + "error_message": error_message, + "elapsed_ms": elapsed_ms, + } + ) + except Exception as e: # never let audit failures break the fetch + _logger.error("Failed to write DCI fetch audit row: %s", e) + + def _handler_crvs(self, variable, source, subject_ids, period_key): + """Call the CRVS DCI service for each subject. + + CRVS's verify_birth takes (identifier_type, identifier_value) rather + than a partner, so the handler resolves the partner's first identifier + before calling the service. Subjects without any identifier are + recorded as not_found and omitted from the result. + """ + try: + from odoo.addons.spp_dci_client_crvs.services.crvs_service import ( + CRVSService, + ) + except ImportError as e: + raise DCIConfigurationError( + _( + "spp_dci_client_crvs is not installed; cannot fetch " + "variable %(var)s. Install spp_dci_client_crvs or " + "reconfigure the variable to use a different registry.", + var=variable.name, + ) + ) from e + + service = CRVSService(self.env, data_source_code=source.code) + Partner = self.env["res.partner"] + partners = Partner.browse(subject_ids).exists() + path = variable.dci_attribute_path + + result = {} + for partner in partners: + started = time.monotonic() + identifier = self._first_identifier(partner) + if identifier is None: + self._record_audit( + variable, + source, + partner.id, + "not_found", + started, + error_message="no identifier", + ) + continue + + id_type, id_value = identifier + try: + payload = service.verify_birth(id_type, id_value) + except Exception as e: + self._record_audit( + variable, + source, + partner.id, + "error", + started, + error_message=str(e), + ) + _logger.warning( + "CRVS fetch failed for partner %d (var=%s): %s", + partner.id, + variable.name, + e, + ) + continue + + if payload is None: + self._record_audit( + variable, + source, + partner.id, + "not_found", + started, + ) + continue + + value = self._extract_by_path(payload, path) + if value is None: + self._record_audit( + variable, + source, + partner.id, + "not_found", + started, + ) + continue + + result[partner.id] = value + self._record_audit( + variable, + source, + partner.id, + "ok", + started, + ) + + return result + + def _handler_ibr(self, variable, source, subject_ids, period_key): + """Call the IBR DCI service for each subject. + + IBR's check_duplication takes a partner directly and returns a dict + with keys is_duplicate, matched_programs, raw_response. The variable's + dci_attribute_path picks the field of interest. + """ + try: + from odoo.addons.spp_dci_client_ibr.services.ibr_service import ( + IBRService, + ) + except ImportError as e: + raise DCIConfigurationError( + _( + "spp_dci_client_ibr is not installed; cannot fetch " + "variable %(var)s. Install spp_dci_client_ibr or " + "reconfigure the variable to use a different registry.", + var=variable.name, + ) + ) from e + + # IBRService takes (data_source, env) — different from DR/CRVS + service = IBRService(source, self.env) + Partner = self.env["res.partner"] + partners = Partner.browse(subject_ids).exists() + path = variable.dci_attribute_path + + result = {} + for partner in partners: + started = time.monotonic() + try: + payload = service.check_duplication(partner) + except Exception as e: + self._record_audit( + variable, + source, + partner.id, + "error", + started, + error_message=str(e), + ) + _logger.warning( + "IBR fetch failed for partner %d (var=%s): %s", + partner.id, + variable.name, + e, + ) + continue + + if payload is None: + self._record_audit( + variable, + source, + partner.id, + "not_found", + started, + ) + continue + + value = self._extract_by_path(payload, path) + if value is None: + self._record_audit( + variable, + source, + partner.id, + "not_found", + started, + ) + continue + + result[partner.id] = value + self._record_audit( + variable, + source, + partner.id, + "ok", + started, + ) + + return result + + @staticmethod + def _first_identifier(partner): + """Return (id_type_code, id_value) for the partner's first reg id, or None.""" + reg = partner.reg_ids[:1] + if not reg or not reg.id_type_id: + return None + code = reg.id_type_id.code or reg.id_type_id.name + if not code or not reg.value: + return None + return (code, reg.value) + + def _handler_sr(self, variable, source, subject_ids, period_key): + """Social Registry handler; not implemented in v1.""" + raise DCIConfigurationError( + _( + "Social Registry handler is not implemented in v1. Variable " + "%(var)s cannot be evaluated. Track this in ADR-023 v2 work.", + var=variable.name, + ) + ) + + def _handler_fr(self, variable, source, subject_ids, period_key): + """Functional Registry handler; not implemented in v1.""" + raise DCIConfigurationError( + _( + "Functional Registry handler is not implemented in v1. " + "Variable %(var)s cannot be evaluated. Track this in " + "ADR-023 v2 work.", + var=variable.name, + ) + ) + + # ------------------------------------------------------------------ + # Helpers shared by handlers + # ------------------------------------------------------------------ + + @staticmethod + def _extract_by_path(payload, dotted_path): + """Resolve a dotted path against a nested dict. + + Returns None if any segment is missing. Used to map a DCI response + payload to the single scalar value the CEL variable represents. + """ + if not payload or not dotted_path: + return None + cursor = payload + for segment in dotted_path.split("."): + if not isinstance(cursor, dict): + return None + if segment not in cursor: + return None + cursor = cursor[segment] + return cursor diff --git a/spp_cel_dci_bridge/models/dci_fetch_audit.py b/spp_cel_dci_bridge/models/dci_fetch_audit.py new file mode 100644 index 00000000..5ef5bdff --- /dev/null +++ b/spp_cel_dci_bridge/models/dci_fetch_audit.py @@ -0,0 +1,75 @@ +from odoo import api, fields, models + + +class DCIFetchAudit(models.Model): + """One row per DCI external fetch attempt. + + Captures provenance for compliance: which provider was queried, for which + subject, on whose behalf, with what outcome. Reusing spp.audit.log was + rejected (ADR-023 §6.4) because that model is CRUD-shaped and would + require synthetic rules to record non-CRUD events. + + A scheduled action prunes rows older than the value of the system + parameter spp_cel_dci_bridge.audit_retention_days (default 90). + """ + + _name = "spp.dci.fetch.audit" + _description = "DCI External Fetch Audit" + _order = "create_date desc" + + create_date = fields.Datetime(readonly=True) + user_id = fields.Many2one( + "res.users", + string="User", + default=lambda self: self.env.user, + readonly=True, + ) + + provider_code = fields.Char(required=True, index=True) + data_source_code = fields.Char(required=True, index=True) + registry_type = fields.Char(required=True) + variable_name = fields.Char(required=True, index=True) + + subject_model = fields.Char( + default="res.partner", + help="Odoo model name the audit row is for (typically res.partner).", + ) + subject_id = fields.Integer( + index=True, + help="Database ID of the subject record at the time of the fetch.", + ) + + # Reference field reconstructed from (subject_model, subject_id) so the + # list view can render a click-through link to the current partner. Not + # stored — if the partner is later deleted or renamed, the snapshot + # subject_id remains as the historical truth in the audit log. + subject_ref = fields.Reference( + selection=[("res.partner", "Registrant")], + string="Subject", + compute="_compute_subject_ref", + help=( + "Click-through to the currently registered partner. Empty if " + "the partner has been deleted since the fetch — the immutable " + "subject_id below preserves the historical reference." + ), + ) + + @api.depends("subject_model", "subject_id") + def _compute_subject_ref(self): + for rec in self: + if not rec.subject_model or not rec.subject_id: + rec.subject_ref = False + continue + target = self.env[rec.subject_model].browse(rec.subject_id).exists() + rec.subject_ref = f"{rec.subject_model},{rec.subject_id}" if target else False + + result = fields.Selection( + selection=[ + ("ok", "OK"), + ("not_found", "Not Found"), + ("error", "Error"), + ], + required=True, + ) + error_message = fields.Text() + elapsed_ms = fields.Integer(help="Round-trip duration in milliseconds") diff --git a/spp_cel_dci_bridge/models/eligibility_manager.py b/spp_cel_dci_bridge/models/eligibility_manager.py new file mode 100644 index 00000000..a8e8f26a --- /dev/null +++ b/spp_cel_dci_bridge/models/eligibility_manager.py @@ -0,0 +1,133 @@ +"""Pre-warm the cache before CEL eligibility compilation. + +The Import Eligible / Enroll Eligible flow on a Program (program-level, +not cycle-level) calls the eligibility manager's `_prepare_eligible_domain` +which compiles `has_disability == true` to `metric('has_disability', me) +== true`. When the executor checks the cache freshness and finds it +incomplete (no rows yet, or stale), it falls back to a legacy Python +evaluation path that calls `spp.indicator.evaluate` — a method that no +longer exists in this Odoo 19 installation, producing: + + AttributeError: 'spp.indicator' object has no attribute 'evaluate' + +The cycle-based flow doesn't hit this because `cycle_manager_base.py` +already calls `_precompute_cycle_cached_variables` (and thus +`cache_mgr.precompute_cached_variables`) before each eligibility check. +The program-level flow has no equivalent pre-fetch — the SQL fast path +is never available, the Python fallback is broken. + +This module patches `spp.program.membership.manager.default` so its +`_prepare_eligible_domain` warms the cache for the candidate cohort +before the CEL compile runs. After pre-fetch, the cache is "fresh" and +the executor takes the SQL fast path, never touching the broken legacy +Python path. + +The pre-fetch is a no-op when: + - The eligibility manager has no CEL expression (parent flow runs) + - The CEL expression has no cached-strategy variables (no metric() calls) + - `spp.data.cache.manager` is not in the environment (spp_cel_domain + missing — defensive guard) +""" + +import logging + +from odoo import models + +_logger = logging.getLogger(__name__) + + +class DefaultEligibilityManagerCacheWarmer(models.Model): + _inherit = "spp.program.membership.manager.default" + + def _prepare_eligible_domain(self, membership=None): + # No CEL expression -> nothing to pre-warm + if not self.cel_expression: + return super()._prepare_eligible_domain(membership) + + self._precompute_cached_variables_for_cohort(membership) + return super()._prepare_eligible_domain(membership) + + def _precompute_cached_variables_for_cohort(self, membership): + """Warm `spp.data.value` for the cohort that the CEL filter will + evaluate over. + + Cohort definition mirrors the base domain that + `spp_programs/models/cel/eligibility_cel.py:_prepare_eligible_domain` + will apply BEFORE the CEL filter: + + - is_registrant = True + - is_group respects target_type + - disabled = False + - id IN membership.partner_ids (if membership provided — only + then we have a bounded cohort; otherwise we pre-warm for ALL + registrants matching the base domain) + + Note on scale: for a deployment with 100k registrants and no + membership filter, this fetches DCI data for every registrant. + That's expensive but it's the only way the SQL fast path can run + on demand. For cycle-based enrollment this isn't a concern + because the cycle manager limits the cohort to existing + memberships. For Import Eligible (program-level), the cohort IS + the full registrant base by design — the operator is looking + for new eligibles among everyone. + """ + if "spp.data.cache.manager" not in self.env: + return + + target_type = self.program_id.target_type + base_domain = [ + ("is_registrant", "=", True), + ("disabled", "=", False), + ] + if target_type == "group": + base_domain.append(("is_group", "=", True)) + elif target_type == "individual": + base_domain.append(("is_group", "=", False)) + + if membership is not None: + partner_ids = membership.mapped("partner_id.id") + if not partner_ids: + return + base_domain.append(("id", "in", partner_ids)) + + # Resolve the cohort before fetching cached variable values. + # No sudo: respect the operator's record rules, matching the + # behaviour of cycle_manager_base._precompute_cycle_cached_variables. + # Partners the operator can't see are excluded from the cohort and + # cannot be enrolled — that's the correct outcome. + subject_ids = self.env["res.partner"].search(base_domain).ids + if not subject_ids: + return + + cache_mgr = self.env["spp.data.cache.manager"] + try: + result = cache_mgr.precompute_cached_variables( + subject_ids, + period_key="current", + program_id=self.program_id.id, + ) + except Exception as e: + # Don't let pre-warm failure block the eligibility check. + # The CEL evaluator will report its own error if the cache + # is still incomplete. + _logger.warning( + "Cache pre-warm failed for program %s (manager %s): %s", + self.program_id.name, + self.name, + e, + ) + return + + if result.get("success"): + _logger.info( + "Pre-warmed %d cached variable(s) for %d subject(s) before CEL eligibility on program %s", + result.get("variables_processed", 0), + len(subject_ids), + self.program_id.name, + ) + else: + _logger.warning( + "Cache pre-warm returned no success for program %s: %s", + self.program_id.name, + result.get("error_message"), + ) diff --git a/spp_cel_dci_bridge/pyproject.toml b/spp_cel_dci_bridge/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/spp_cel_dci_bridge/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/spp_cel_dci_bridge/readme/DESCRIPTION.md b/spp_cel_dci_bridge/readme/DESCRIPTION.md new file mode 100644 index 00000000..2d2a442c --- /dev/null +++ b/spp_cel_dci_bridge/readme/DESCRIPTION.md @@ -0,0 +1,70 @@ +Bridges OpenSPP's CEL expression engine to external DCI registries. CEL eligibility rules of the form `has_disability == true` automatically fetch values from a configured DCI registry (Disability Registry, CRVS, IBR), cache them in `spp.data.value`, and resolve as standard SQL filters during program enrollment. No CEL grammar changes; the integration sits behind one cache-manager override. + +### Key Capabilities + +- Override `spp.data.cache.manager._compute_variable_values` to route `source_type='external'` CEL variables linked to a DCI data source through the DCI client family instead of returning empty +- Dispatch by `registry_type` to the appropriate DCI service (`DRService`, `CRVSService`, `IBRService`) with runtime ImportError guards so the bridge installs cleanly when some clients are absent +- Normalize the three inconsistent registry_type conventions used by existing DCI clients (`"DR"`, `"ns:org:RegistryType:Civil"`, `"ibr"`) to a single canonical key for routing +- Apply per-variable `external_failure_policy`: `null` (default; cache as null), `last_known` (surface most recent non-null cached value), `fail` (propagate as UserError) +- Fill missing subjects with explicit None so the cache stays complete across the cohort — letting the CEL executor use the metric SQL fast path instead of falling back to Python evaluation +- Record one `spp.dci.fetch.audit` row per subject per fetch (provider, source, registry, variable, outcome, elapsed_ms, error_message) for compliance + +### Key Models + +| Model | Description | +| ------------------------ | ---------------------------------------------------------------------- | +| `spp.cel.dci.dispatcher` | AbstractModel routing fetch requests to per-registry-type handlers | +| `spp.dci.fetch.audit` | One row per subject per DCI fetch attempt for compliance audit | + +### Schema Extensions + +| Model | Field | Purpose | +| -------------------- | --------------------------- | -------------------------------------------------------- | +| `spp.data.provider` | `dci_data_source_id` | Links the CEL provider to a DCI data source | +| `spp.data.provider` | `is_dci_backed` (computed) | True when the provider routes through DCI | +| `spp.cel.variable` | `dci_attribute_path` | Dotted path into the DCI response (e.g., `has_disability`, `functional_scores.cognition`) | +| `spp.cel.variable` | `external_failure_policy` | Behaviour on fetch failure: null / last_known / fail | +| `spp.dci.data.source`| `vendor` (Selection) | Vendor-adapter discriminator. The selection starts empty; vendor presets (`spp_dci_openg2p`, `spp_dci_openspp_dr`) extend it via `selection_add`. The dispatcher consults this field to route registry-type handlers to vendor-specific services. | + +### Views + +| File | Purpose | +| ----------------------------------- | --------------------------------------------------------------------------------------------- | +| `views/dci_data_source_views.xml` | Surfaces the `vendor` field on the upstream `spp.dci.data.source` form (after Authentication Type) and list (after Registry Type) so operators can set the adapter without developer mode. | +| `views/data_provider_views.xml` | Provider list/form additions for the DCI link. | +| `views/cel_variable_views.xml` | CEL-variable additions for `dci_attribute_path` and `external_failure_policy`. | +| `views/dci_fetch_audit_views.xml` | List/form for `spp.dci.fetch.audit` (read-only, ACL-gated). | + +### Architecture + +``` +CEL: has_disability == true + | + v (resolver) + metric('has_disability', me) == true + | + v (translator -> executor SQL fast path) + id IN (SELECT subject_id FROM spp_data_value WHERE ...) + | + v (populated by precompute, before eligibility runs) + cache_mgr.precompute_cached_variables(...) + | + v (overridden in this module) + _compute_variable_values(var, subjects) + | + v (when var is DCI-backed) + spp.cel.dci.dispatcher.fetch_values_for_variable(var, subjects) + | + v (registry_type='DR') + DRService.get_disability_status(partner) + | + v (writes back) + spp.data.value rows + spp.dci.fetch.audit rows +``` + +The cycle pre-fetch hook (`cycle_manager_base._precompute_cycle_cached_variables`) is already wired in `spp_programs` — installing the bridge plus a vendor preset (e.g., `spp_dci_openg2p`) wires the whole flow without further code. + +### See Also + +- `spp_dci_openg2p` — permanent OpenG2P vendor preset that ships pre-configured data source, provider, and CEL variable wiring +- ADR-023 — decision rationale, alternatives considered, failure modes, future async work diff --git a/spp_cel_dci_bridge/readme/USAGE.md b/spp_cel_dci_bridge/readme/USAGE.md new file mode 100644 index 00000000..1e18dc38 --- /dev/null +++ b/spp_cel_dci_bridge/readme/USAGE.md @@ -0,0 +1,46 @@ +### Writing CEL rules against DCI-backed variables + +CEL accessors are **vendor-neutral**. The eligibility rule reads the semantic concept; the vendor identity lives in configuration records. + +``` +has_disability == true && age_years(r.birthdate) >= 18 +``` + +The bridge does not change CEL syntax. To switch from one DCI registry to another (OpenG2P → national DR, mock → production), change the data source configuration; CEL rules are not edited. + +### Configuring a DCI-backed variable manually + +1. Create a `spp.dci.data.source` record with `auth_type`, `base_url`, `registry_type`, and OAuth2 credentials. +2. Optional: set the **Vendor Adapter** field (defined here as a Selection field with an empty selection; vendor presets extend it via `selection_add`). Set when a vendor preset registered its adapter — e.g., `openg2p`, `openspp` — so the bridge dispatcher routes through the vendor-specific service. Leave blank for sources that speak vanilla SPDCI. +3. Create a `spp.data.provider` and set `dci_data_source_id` to the source above. +4. Create or repurpose a `spp.cel.variable`: + - `source_type = 'external'` + - `external_provider_id` = the provider + - `dci_attribute_path` = the dotted path into the DCI response payload (e.g., `has_disability`, `severity.code`, `functional_scores.cognition`) + - `cache_strategy = 'ttl'` (or `'manual'`) + - `cache_ttl_seconds` = TTL in seconds (300 for demo, 86400 for production) + - `external_failure_policy` = null / last_known / fail + +For typical OpenG2P deployments install `spp_dci_openg2p`; for an OpenSPP-DR instance install `spp_dci_openspp_dr` — each ships a permanent preset. + +### Pre-warm behaviour + +When `Enroll Eligible` / `Import Eligible` runs at the program level, the bridge eagerly pre-warms **every active DCI-backed CEL variable** for the cohort, regardless of which variables the program's specific CEL rule references. This is by design — the executor's SQL fast path needs a fresh cache for any `metric()` accessor the rule could reference, and parsing the rule up front to extract referenced names was traded off for simplicity. Side effect: a program that only checks `has_disability` still produces audit rows for `is_poor` and any other active SR variables in the cohort. + +To exclude a variable from the pre-warm, set `state='inactive'` and `active=False` on the `spp.cel.variable` record. The pre-warm filter applies `("active", "=", True)`, so inactive variables are skipped — useful for deferred-feature placeholders. Such variables are also unavailable to CEL rules (compound rules referencing them evaluate the comparison against null, which fails the filter). + +### Failure policies + +| Policy | Behaviour | +| ------------ | -------------------------------------------------------------------- | +| `null` | Default. Errored subjects cache as null; CEL evaluates against null. | +| `last_known` | Surface most recent non-null cached value, regardless of expiry. | +| `fail` | Propagate the exception as UserError. Eligibility check aborts. | + +### Audit + +Every DCI fetch records one row in `spp.dci.fetch.audit`: + +- Navigate to the menu surfaced via `view_dci_fetch_audit_list` +- Filter by variable, provider, result (ok / not_found / error) +- Read access for all internal users; write access for spp admin only diff --git a/spp_cel_dci_bridge/security/ir.model.access.csv b/spp_cel_dci_bridge/security/ir.model.access.csv new file mode 100644 index 00000000..43a39314 --- /dev/null +++ b/spp_cel_dci_bridge/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_spp_dci_fetch_audit_admin,access.spp.dci.fetch.audit.admin,model_spp_dci_fetch_audit,spp_security.group_spp_admin,1,1,1,1 +access_spp_dci_fetch_audit_user,access.spp.dci.fetch.audit.user,model_spp_dci_fetch_audit,base.group_user,1,0,0,0 diff --git a/spp_cel_dci_bridge/static/description/index.html b/spp_cel_dci_bridge/static/description/index.html new file mode 100644 index 00000000..4bd2b344 --- /dev/null +++ b/spp_cel_dci_bridge/static/description/index.html @@ -0,0 +1,695 @@ + + + + + +OpenSPP CEL <-> DCI Bridge + + + +
+

OpenSPP CEL <-> DCI Bridge

+ + +

Alpha License: LGPL-3 OpenSPP/OpenSPP2

+

Bridges OpenSPP’s CEL expression engine to external DCI registries. CEL +eligibility rules of the form has_disability == true automatically +fetch values from a configured DCI registry (Disability Registry, CRVS, +IBR), cache them in spp.data.value, and resolve as standard SQL +filters during program enrollment. No CEL grammar changes; the +integration sits behind one cache-manager override.

+
+

Key Capabilities

+
    +
  • Override spp.data.cache.manager._compute_variable_values to route +source_type='external' CEL variables linked to a DCI data source +through the DCI client family instead of returning empty
  • +
  • Dispatch by registry_type to the appropriate DCI service +(DRService, CRVSService, IBRService) with runtime +ImportError guards so the bridge installs cleanly when some clients +are absent
  • +
  • Normalize the three inconsistent registry_type conventions used by +existing DCI clients ("DR", "ns:org:RegistryType:Civil", +"ibr") to a single canonical key for routing
  • +
  • Apply per-variable external_failure_policy: null (default; +cache as null), last_known (surface most recent non-null cached +value), fail (propagate as UserError)
  • +
  • Fill missing subjects with explicit None so the cache stays complete +across the cohort — letting the CEL executor use the metric SQL fast +path instead of falling back to Python evaluation
  • +
  • Record one spp.dci.fetch.audit row per subject per fetch +(provider, source, registry, variable, outcome, elapsed_ms, +error_message) for compliance
  • +
+
+
+

Key Models

+ ++++ + + + + + + + + + + + + + +
ModelDescription
spp.cel.dci.dispatcherAbstractModel routing fetch requests to +per-registry-type handlers
spp.dci.fetch.auditOne row per subject per DCI fetch +attempt for compliance audit
+
+
+

Schema Extensions

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ModelFieldPurpose
spp.data.providerdci_data_source_idLinks the CEL provider to a DCI +data source
spp.data.provideris_dci_backed +(computed)True when the provider routes +through DCI
spp.cel.variabledci_attribute_pathDotted path into the DCI +response (e.g., +has_disability, +functional_scores.cognition)
spp.cel.variableexternal_failure_policyBehaviour on fetch failure: null +/ last_known / fail
spp.dci.data.sourcevendor (Selection)Vendor-adapter discriminator. +The selection starts empty; +vendor presets +(spp_dci_openg2p, +spp_dci_openspp_dr) extend +it via selection_add. The +dispatcher consults this field +to route registry-type handlers +to vendor-specific services.
+
+
+

Views

+ ++++ + + + + + + + + + + + + + + + + + + + +
FilePurpose
views/dci_data_source_views.xmlSurfaces the vendor field on +the upstream +spp.dci.data.source form +(after Authentication Type) and +list (after Registry Type) so +operators can set the adapter +without developer mode.
views/data_provider_views.xmlProvider list/form additions for +the DCI link.
views/cel_variable_views.xmlCEL-variable additions for +dci_attribute_path and +external_failure_policy.
views/dci_fetch_audit_views.xmlList/form for +spp.dci.fetch.audit +(read-only, ACL-gated).
+
+
+

Architecture

+
+CEL: has_disability == true
+        |
+        v (resolver)
+    metric('has_disability', me) == true
+        |
+        v (translator -> executor SQL fast path)
+    id IN (SELECT subject_id FROM spp_data_value WHERE ...)
+        |
+        v (populated by precompute, before eligibility runs)
+    cache_mgr.precompute_cached_variables(...)
+        |
+        v (overridden in this module)
+    _compute_variable_values(var, subjects)
+        |
+        v (when var is DCI-backed)
+    spp.cel.dci.dispatcher.fetch_values_for_variable(var, subjects)
+        |
+        v (registry_type='DR')
+    DRService.get_disability_status(partner)
+        |
+        v (writes back)
+    spp.data.value rows + spp.dci.fetch.audit rows
+
+

The cycle pre-fetch hook +(cycle_manager_base._precompute_cycle_cached_variables) is already +wired in spp_programs — installing the bridge plus a vendor preset +(e.g., spp_dci_openg2p) wires the whole flow without further code.

+
+
+

See Also

+
    +
  • spp_dci_openg2p — permanent OpenG2P vendor preset that ships +pre-configured data source, provider, and CEL variable wiring
  • +
  • ADR-023 — decision rationale, alternatives considered, failure modes, +future async work
  • +
+
+

Important

+

This is an alpha version, the data model and design can change at any time without warning. +Only for development or testing purpose, do not use in production.

+
+

Table of contents

+
+ +
+
+

Usage

+
+
+
+

Writing CEL rules against DCI-backed variables

+

CEL accessors are vendor-neutral. The eligibility rule reads the +semantic concept; the vendor identity lives in configuration records.

+
+has_disability == true && age_years(r.birthdate) >= 18
+
+

The bridge does not change CEL syntax. To switch from one DCI registry +to another (OpenG2P → national DR, mock → production), change the data +source configuration; CEL rules are not edited.

+
+
+

Configuring a DCI-backed variable manually

+
    +
  1. Create a spp.dci.data.source record with auth_type, +base_url, registry_type, and OAuth2 credentials.
  2. +
  3. Optional: set the Vendor Adapter field (defined here as a +Selection field with an empty selection; vendor presets extend it via +selection_add). Set when a vendor preset registered its adapter — +e.g., openg2p, openspp — so the bridge dispatcher routes +through the vendor-specific service. Leave blank for sources that +speak vanilla SPDCI.
  4. +
  5. Create a spp.data.provider and set dci_data_source_id to the +source above.
  6. +
  7. Create or repurpose a spp.cel.variable:
      +
    • source_type = 'external'
    • +
    • external_provider_id = the provider
    • +
    • dci_attribute_path = the dotted path into the DCI response +payload (e.g., has_disability, severity.code, +functional_scores.cognition)
    • +
    • cache_strategy = 'ttl' (or 'manual')
    • +
    • cache_ttl_seconds = TTL in seconds (300 for demo, 86400 for +production)
    • +
    • external_failure_policy = null / last_known / fail
    • +
    +
  8. +
+

For typical OpenG2P deployments install spp_dci_openg2p; for an +OpenSPP-DR instance install spp_dci_openspp_dr — each ships a +permanent preset.

+
+
+

Pre-warm behaviour

+

When Enroll Eligible / Import Eligible runs at the program +level, the bridge eagerly pre-warms every active DCI-backed CEL +variable for the cohort, regardless of which variables the program’s +specific CEL rule references. This is by design — the executor’s SQL +fast path needs a fresh cache for any metric() accessor the rule +could reference, and parsing the rule up front to extract referenced +names was traded off for simplicity. Side effect: a program that only +checks has_disability still produces audit rows for is_poor and +any other active SR variables in the cohort.

+

To exclude a variable from the pre-warm, set state='inactive' and +active=False on the spp.cel.variable record. The pre-warm filter +applies ("active", "=", True), so inactive variables are skipped — +useful for deferred-feature placeholders. Such variables are also +unavailable to CEL rules (compound rules referencing them evaluate the +comparison against null, which fails the filter).

+
+
+

Failure policies

+ ++++ + + + + + + + + + + + + + + + + +
PolicyBehaviour
nullDefault. Errored subjects cache as null; CEL +evaluates against null.
last_knownSurface most recent non-null cached value, +regardless of expiry.
failPropagate the exception as UserError. Eligibility +check aborts.
+
+
+

Audit

+

Every DCI fetch records one row in spp.dci.fetch.audit:

+
    +
  • Navigate to the menu surfaced via view_dci_fetch_audit_list
  • +
  • Filter by variable, provider, result (ok / not_found / error)
  • +
  • Read access for all internal users; write access for spp admin only
  • +
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • OpenSPP.org
  • +
+
+
+

Maintainers

+

Current maintainers:

+

jeremi gonzalesedwin1123

+

This module is part of the OpenSPP/OpenSPP2 project on GitHub.

+

You are welcome to contribute.

+
+
+
+
+ + diff --git a/spp_cel_dci_bridge/tests/__init__.py b/spp_cel_dci_bridge/tests/__init__.py new file mode 100644 index 00000000..df84186b --- /dev/null +++ b/spp_cel_dci_bridge/tests/__init__.py @@ -0,0 +1,9 @@ +from . import test_schema_extensions +from . import test_dispatcher +from . import test_dr_handler +from . import test_cache_manager_override +from . import test_failure_policy +from . import test_audit_logging +from . import test_end_to_end +from . import test_crvs_ibr_handlers +from . import test_eligibility_prewarm diff --git a/spp_cel_dci_bridge/tests/common.py b/spp_cel_dci_bridge/tests/common.py new file mode 100644 index 00000000..85780818 --- /dev/null +++ b/spp_cel_dci_bridge/tests/common.py @@ -0,0 +1,155 @@ +"""Shared test fixtures for spp_cel_dci_bridge. + +Follows the precedent in spp_dci_client_dr/tests/test_dr_service.py: +patch DCIClient with a MagicMock returning canned DCI search responses. +A full httpx-level mock server is overkill for the bridge's contract, +which is "DRService returns a dict, the bridge maps a field out of it." +""" + +from odoo.tests.common import TransactionCase + + +def make_dr_search_response(has_disability=True, functional_scores=None, source_registry="Test DR"): + """Build a canned DCI DR search-response envelope.""" + if functional_scores is None: + functional_scores = {"Vision": 1, "Mobility": 1, "Cognition": 1} + return { + "message": { + "search_response": [ + { + "reference_id": "ref-bridge-test", + "status": "succ", + "data": [ + { + "has_disability": has_disability, + "is_pwd": has_disability, + "disability_types": ["Vision", "Mobility"], + "functional_scores": functional_scores, + "assessment_date": "2026-01-15", + "source_registry": source_registry, + } + ], + } + ] + } + } + + +def make_dr_empty_response(): + """Canned 'subject not found in DR' envelope.""" + return {"message": {"search_response": []}} + + +class BridgeTestBase(TransactionCase): + """Shared scaffolding for bridge tests that exercise the DR handler. + + Builds the minimum graph needed to drive the dispatcher: + - Vocabulary code for an identifier type + - res.partner with one spp.registry.id linking the partner to the code + - spp.dci.data.source of registry_type='DR' + - spp.data.provider linked to the DCI source + - spp.cel.variable with source_type='external' and a DCI attribute path + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.Partner = cls.env["res.partner"] + cls.Variable = cls.env["spp.cel.variable"] + cls.Provider = cls.env["spp.data.provider"] + cls.DCISource = cls.env["spp.dci.data.source"] + cls.IdRecord = cls.env["spp.registry.id"] + cls.VocabularyCode = cls.env["spp.vocabulary.code"] + + vocab_model = cls.env["spp.vocabulary"] + id_type_vocab = vocab_model.search([("namespace_uri", "=", "urn:openspp:vocab:id-type")], limit=1) + if not id_type_vocab: + id_type_vocab = vocab_model.create( + { + "name": "ID Type (bridge tests)", + "namespace_uri": "urn:openspp:vocab:id-type", + } + ) + + cls.id_type_uin = cls.VocabularyCode.create( + { + "vocabulary_id": id_type_vocab.id, + "code": "UIN_BRIDGE_TEST", + "display": "UIN (bridge tests)", + "target_type": "individual", + "is_local": True, + } + ) + + cls.partner_a = cls.Partner.create( + { + "name": "Bridge Partner A", + "is_registrant": True, + "is_group": False, + } + ) + cls.IdRecord.create( + { + "partner_id": cls.partner_a.id, + "id_type_id": cls.id_type_uin.id, + "value": "UIN-BRIDGE-A", + } + ) + + cls.partner_b = cls.Partner.create( + { + "name": "Bridge Partner B", + "is_registrant": True, + "is_group": False, + } + ) + cls.IdRecord.create( + { + "partner_id": cls.partner_b.id, + "id_type_id": cls.id_type_uin.id, + "value": "UIN-BRIDGE-B", + } + ) + + # Partner with no identifier — used to test "not found" paths + cls.partner_no_id = cls.Partner.create( + { + "name": "Bridge Partner (no ID)", + "is_registrant": True, + "is_group": False, + } + ) + + cls.dci_source = cls.DCISource.create( + { + "name": "Bridge DR Source", + "code": "bridge_dr_source", + "registry_type": "DR", + "base_url": "https://dr.test.invalid/api/v1", + "auth_type": "none", + "our_sender_id": "bridge.test.openspp.example.org", + } + ) + + cls.provider = cls.Provider.create( + { + "name": "Bridge DR Provider", + "code": "bridge_dr_provider", + "dci_data_source_id": cls.dci_source.id, + "default_ttl_seconds": 300, + } + ) + + cls.variable = cls.Variable.create( + { + "name": "has_disability_test", + "cel_accessor": "has_disability_test", + "source_type": "external", + "value_type": "boolean", + "external_provider_id": cls.provider.id, + "dci_attribute_path": "has_disability", + "cache_strategy": "ttl", + "cache_ttl_seconds": 300, + } + ) diff --git a/spp_cel_dci_bridge/tests/test_audit_logging.py b/spp_cel_dci_bridge/tests/test_audit_logging.py new file mode 100644 index 00000000..7e6b4392 --- /dev/null +++ b/spp_cel_dci_bridge/tests/test_audit_logging.py @@ -0,0 +1,174 @@ +from unittest.mock import MagicMock, patch + +from odoo.tests.common import tagged + +from .common import ( + BridgeTestBase, + make_dr_empty_response, + make_dr_search_response, +) + + +@tagged("post_install", "-at_install") +class TestAuditLogging(BridgeTestBase): + """Verify one spp.dci.fetch.audit row is recorded per subject per fetch.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.Audit = cls.env["spp.dci.fetch.audit"] + + def _audits_for_variable(self): + return self.Audit.search([("variable_name", "=", self.variable.name)]) + + @patch("odoo.addons.spp_dci_client_dr.services.dr_service.DCIClient") + def test_audit_row_on_success(self, mock_client_class): + mock_client = MagicMock() + mock_client.search_by_id.return_value = make_dr_search_response(True) + mock_client_class.return_value = mock_client + + self.env["spp.cel.dci.dispatcher"].fetch_values_for_variable(self.variable, [self.partner_a.id], "current") + + rows = self._audits_for_variable() + self.assertEqual(len(rows), 1) + row = rows + self.assertEqual(row.subject_id, self.partner_a.id) + self.assertEqual(row.result, "ok") + self.assertEqual(row.provider_code, self.provider.code) + self.assertEqual(row.data_source_code, self.dci_source.code) + self.assertEqual(row.registry_type, "DR") + self.assertEqual(row.subject_model, "res.partner") + self.assertGreaterEqual(row.elapsed_ms, 0) + self.assertFalse(row.error_message) + + @patch("odoo.addons.spp_dci_client_dr.services.dr_service.DCIClient") + def test_audit_row_on_not_found(self, mock_client_class): + mock_client = MagicMock() + mock_client.search_by_id.return_value = make_dr_empty_response() + mock_client_class.return_value = mock_client + + self.env["spp.cel.dci.dispatcher"].fetch_values_for_variable(self.variable, [self.partner_a.id], "current") + + rows = self._audits_for_variable() + self.assertEqual(len(rows), 1) + self.assertEqual(rows.result, "not_found") + + @patch("odoo.addons.spp_dci_client_dr.services.dr_service.DCIClient") + def test_audit_row_on_error(self, mock_client_class): + mock_client = MagicMock() + mock_client.search_by_id.side_effect = RuntimeError("simulated") + mock_client_class.return_value = mock_client + + self.env["spp.cel.dci.dispatcher"].fetch_values_for_variable(self.variable, [self.partner_a.id], "current") + + rows = self._audits_for_variable() + self.assertEqual(len(rows), 1) + self.assertEqual(rows.result, "error") + self.assertIn("simulated", rows.error_message) + + @patch("odoo.addons.spp_dci_client_dr.services.dr_service.DCIClient") + def test_one_audit_row_per_subject(self, mock_client_class): + mock_client = MagicMock() + mock_client.search_by_id.return_value = make_dr_search_response(True) + mock_client_class.return_value = mock_client + + self.env["spp.cel.dci.dispatcher"].fetch_values_for_variable( + self.variable, + [self.partner_a.id, self.partner_b.id], + "current", + ) + + rows = self._audits_for_variable() + self.assertEqual(len(rows), 2) + self.assertEqual({r.subject_id for r in rows}, {self.partner_a.id, self.partner_b.id}) + self.assertTrue(all(r.result == "ok" for r in rows)) + + @patch("odoo.addons.spp_dci_client_dr.services.dr_service.DCIClient") + def test_subject_ref_resolves_to_current_partner(self, mock_client_class): + """Reference field gives auditors click-through to the partner.""" + mock_client = MagicMock() + mock_client.search_by_id.return_value = make_dr_search_response(True) + mock_client_class.return_value = mock_client + + self.env["spp.cel.dci.dispatcher"].fetch_values_for_variable(self.variable, [self.partner_a.id], "current") + + row = self._audits_for_variable() + self.assertEqual(len(row), 1) + self.assertEqual(row.subject_ref, self.partner_a) + + def test_subject_ref_falsy_when_partner_missing(self): + """Reference is False when subject_id points to a deleted partner; + the immutable subject_id snapshot is preserved in the audit log.""" + missing_id = 99999999 + # Make sure the id really doesn't exist + self.assertFalse(self.env["res.partner"].browse(missing_id).exists()) + + row = self.Audit.create( + { + "provider_code": "bridge_dr_provider", + "data_source_code": "bridge_dr_source", + "registry_type": "DR", + "variable_name": self.variable.name, + "subject_model": "res.partner", + "subject_id": missing_id, + "result": "ok", + } + ) + self.assertFalse(row.subject_ref) + # Snapshot subject_id survives even when the partner no longer resolves + self.assertEqual(row.subject_id, missing_id) + + @patch("odoo.addons.spp_dci_client_dr.services.dr_service.DCIClient") + def test_audit_write_failure_does_not_break_fetch(self, mock_client_class): + """If the audit write itself raises (model gone, db full, etc.), + the fetch must still complete and return values. The compliance + cost of losing a row is real but lower than the operational cost + of having every fetch fail because audit infrastructure is broken. + """ + mock_client = MagicMock() + mock_client.search_by_id.return_value = make_dr_search_response(True) + mock_client_class.return_value = mock_client + + dispatcher = self.env["spp.cel.dci.dispatcher"] + + # Patch the audit create to raise — fetch should still succeed. + with patch.object( + type(self.Audit), + "create", + side_effect=RuntimeError("audit write broken"), + ): + result = dispatcher.fetch_values_for_variable(self.variable, [self.partner_a.id], "current") + + # Fetch returned the value despite audit failure + self.assertEqual(result, {self.partner_a.id: True}) + + @patch("odoo.addons.spp_dci_client_dr.services.dr_service.DCIClient") + def test_audit_records_acting_user_not_root(self, mock_client_class): + """Regression: audit must record the operator who triggered the + fetch, not user_root. The user_id field default resolves to + self.env.user, which gets overridden by sudo() to user_root unless + we capture acting_user_id before escalating privileges. + """ + mock_client = MagicMock() + mock_client.search_by_id.return_value = make_dr_search_response(True) + mock_client_class.return_value = mock_client + + # Create a non-admin internal user to act as the operator + officer = self.env["res.users"].create( + { + "name": "DCI Officer", + "login": "dci_officer_test", + "group_ids": [(6, 0, [self.env.ref("base.group_user").id])], + } + ) + + # Drive the dispatcher as that user + self.env["spp.cel.dci.dispatcher"].with_user(officer).fetch_values_for_variable( + self.variable, [self.partner_a.id], "current" + ) + + rows = self._audits_for_variable() + self.assertEqual(len(rows), 1) + # The audit row must record the officer, not user_root + self.assertEqual(rows.user_id, officer) + self.assertNotEqual(rows.user_id, self.env.ref("base.user_root")) diff --git a/spp_cel_dci_bridge/tests/test_cache_manager_override.py b/spp_cel_dci_bridge/tests/test_cache_manager_override.py new file mode 100644 index 00000000..6f092d71 --- /dev/null +++ b/spp_cel_dci_bridge/tests/test_cache_manager_override.py @@ -0,0 +1,116 @@ +from unittest.mock import MagicMock, patch + +from odoo.tests.common import tagged + +from .common import BridgeTestBase, make_dr_search_response + + +@tagged("post_install", "-at_install") +class TestCacheManagerOverride(BridgeTestBase): + """Verify _compute_variable_values routes DCI-backed externals through dispatcher.""" + + @patch("odoo.addons.spp_dci_client_dr.services.dr_service.DCIClient") + def test_dci_backed_external_routes_to_dispatcher(self, mock_client_class): + mock_client = MagicMock() + mock_client.search_by_id.return_value = make_dr_search_response(has_disability=True) + mock_client_class.return_value = mock_client + + cache_mgr = self.env["spp.data.cache.manager"] + result = cache_mgr._compute_variable_values( + self.variable, + [self.partner_a.id], + "current", + program_id=None, + ) + + self.assertEqual(result, {self.partner_a.id: True}) + + def test_non_dci_external_falls_back_to_super(self): + """A bare 'external' variable without a DCI provider goes through + the parent implementation, which returns {} and logs a warning.""" + plain_provider = self.Provider.create({"name": "Plain", "code": "plain_super"}) + var = self.Variable.create( + { + "name": "var_no_dci", + "cel_accessor": "var_no_dci", + "source_type": "external", + "value_type": "number", + "external_provider_id": plain_provider.id, + "cache_strategy": "ttl", + "cache_ttl_seconds": 300, + } + ) + + cache_mgr = self.env["spp.data.cache.manager"] + result = cache_mgr._compute_variable_values(var, [self.partner_a.id], "current", program_id=None) + + # Parent returns {} for external source_type without our override + self.assertEqual(result, {}) + + def test_field_source_type_unaffected(self): + """source_type='field' must still route through the parent.""" + field_var = self.Variable.create( + { + "name": "var_field", + "cel_accessor": "var_field", + "source_type": "field", + "value_type": "string", + "source_model": "res.partner", + "source_field": "name", + "cache_strategy": "ttl", + "cache_ttl_seconds": 300, + } + ) + + cache_mgr = self.env["spp.data.cache.manager"] + result = cache_mgr._compute_variable_values(field_var, [self.partner_a.id], "current", program_id=None) + + # Parent _compute_field_values reads the name field + self.assertEqual(result, {self.partner_a.id: self.partner_a.name}) + + @patch("odoo.addons.spp_dci_client_dr.services.dr_service.DCIClient") + def test_precompute_writes_to_spp_data_value(self, mock_client_class): + """End-to-end through precompute_variable: cache row appears.""" + mock_client = MagicMock() + mock_client.search_by_id.return_value = make_dr_search_response(has_disability=True) + mock_client_class.return_value = mock_client + + cache_mgr = self.env["spp.data.cache.manager"] + result = cache_mgr.precompute_variable( + self.variable.name, + [self.partner_a.id], + period_key="current", + ) + + self.assertTrue(result["success"], result.get("error_message")) + self.assertEqual(result["computed"], 1) + self.assertEqual(result["cached"], 1) + + DataValue = self.env["spp.data.value"] + rows = DataValue.search( + [ + ("variable_name", "=", self.variable.name), + ("subject_id", "=", self.partner_a.id), + ("period_key", "=", "current"), + ] + ) + self.assertEqual(len(rows), 1) + self.assertEqual(rows.value_json, {"value": True}) + + @patch("odoo.addons.spp_dci_client_dr.services.dr_service.DCIClient") + def test_dispatcher_exception_yields_null_not_raise(self, mock_client_class): + """An unhandled exception inside the dispatcher must not crash the + cache manager. Under the default null policy, the queried subject + appears in the result with an explicit None value so the cache is + complete and CEL evaluation can fall through to false.""" + mock_client = MagicMock() + mock_client.search_by_id.side_effect = RuntimeError("boom") + mock_client_class.return_value = mock_client + + cache_mgr = self.env["spp.data.cache.manager"] + # Should not raise + result = cache_mgr._compute_variable_values(self.variable, [self.partner_a.id], "current", program_id=None) + + # Per-subject error is swallowed by the handler; cache manager fills + # the missing subject with explicit None. + self.assertEqual(result, {self.partner_a.id: None}) diff --git a/spp_cel_dci_bridge/tests/test_crvs_ibr_handlers.py b/spp_cel_dci_bridge/tests/test_crvs_ibr_handlers.py new file mode 100644 index 00000000..de04e268 --- /dev/null +++ b/spp_cel_dci_bridge/tests/test_crvs_ibr_handlers.py @@ -0,0 +1,201 @@ +"""Smoke tests for CRVS and IBR handlers. + +These confirm the dispatcher routes correctly and the handlers wire to the +real service surfaces; they do NOT exhaustively exercise CRVS/IBR semantics +(which are the responsibility of those modules' own test suites). +""" + +from unittest.mock import MagicMock, patch + +from odoo.tests.common import tagged + +from .common import BridgeTestBase + + +def make_crvs_birth_response(birth_date="2000-01-15"): + return { + "message": { + "search_response": [ + { + "reference_id": "crvs-ref", + "status": "succ", + "data": [ + { + "identifier_type": "UIN", + "birth_date": birth_date, + "person_name": "Test Person", + } + ], + } + ] + } + } + + +def make_ibr_search_response(): + return { + "message": { + "search_response": [ + { + "reference_id": "ibr-ref", + "status": "succ", + "data": [ + { + "programs": ["program-a", "program-b"], + "first_name": "Test", + "last_name": "Person", + } + ], + } + ] + } + } + + +@tagged("post_install", "-at_install") +class TestCRVSHandler(BridgeTestBase): + """Verify CRVS dispatcher routing via mocked DCIClient. + + CRVS service requires registry_type = the SPDCI URI; the bridge + dispatcher normalizes both URI and short forms to the canonical key. + """ + + def setUp(self): + super().setUp() + # CRVS service validates against the SPDCI URI specifically + self.dci_source.registry_type = "ns:org:RegistryType:Civil" + self.variable.dci_attribute_path = "birth_date" + + @patch("odoo.addons.spp_dci_client_crvs.services.crvs_service.DCIClient") + def test_crvs_handler_extracts_attribute(self, mock_client_class): + mock_client = MagicMock() + mock_client.search_by_id.return_value = make_crvs_birth_response("2005-05-12") + mock_client_class.return_value = mock_client + + result = self.env["spp.cel.dci.dispatcher"].fetch_values_for_variable( + self.variable, [self.partner_a.id], "current" + ) + + self.assertEqual(result, {self.partner_a.id: "2005-05-12"}) + + @patch("odoo.addons.spp_dci_client_crvs.services.crvs_service.DCIClient") + def test_crvs_handler_omits_subject_without_identifier(self, mock_client_class): + mock_client = MagicMock() + mock_client.search_by_id.return_value = make_crvs_birth_response() + mock_client_class.return_value = mock_client + + result = self.env["spp.cel.dci.dispatcher"].fetch_values_for_variable( + self.variable, [self.partner_no_id.id], "current" + ) + + self.assertEqual(result, {}) + + @patch("odoo.addons.spp_dci_client_crvs.services.crvs_service.DCIClient") + def test_crvs_handler_swallows_per_subject_error(self, mock_client_class): + """Per-subject service exception must not fail the batch — logs + + records an audit row with result='error' and continues.""" + mock_client = MagicMock() + mock_client.search_by_id.side_effect = RuntimeError("crvs boom") + mock_client_class.return_value = mock_client + + result = self.env["spp.cel.dci.dispatcher"].fetch_values_for_variable( + self.variable, [self.partner_a.id], "current" + ) + + self.assertEqual(result, {}) + audits = self.env["spp.dci.fetch.audit"].search([("variable_name", "=", self.variable.name)]) + self.assertEqual(len(audits), 1) + self.assertEqual(audits.result, "error") + self.assertIn("crvs boom", audits.error_message) + + @patch("odoo.addons.spp_dci_client_crvs.services.crvs_service.DCIClient") + def test_crvs_handler_records_not_found_on_empty_response(self, mock_client_class): + mock_client = MagicMock() + mock_client.search_by_id.return_value = {"message": {"search_response": []}} + mock_client_class.return_value = mock_client + + result = self.env["spp.cel.dci.dispatcher"].fetch_values_for_variable( + self.variable, [self.partner_a.id], "current" + ) + + self.assertEqual(result, {}) + audits = self.env["spp.dci.fetch.audit"].search([("variable_name", "=", self.variable.name)]) + self.assertEqual(audits.result, "not_found") + + @patch("odoo.addons.spp_dci_client_crvs.services.crvs_service.DCIClient") + def test_crvs_handler_records_not_found_when_attribute_path_missing(self, mock_client_class): + """Successful response but the configured dci_attribute_path doesn't + resolve to anything — record not_found, not error.""" + self.variable.dci_attribute_path = "nonexistent.path" + mock_client = MagicMock() + mock_client.search_by_id.return_value = make_crvs_birth_response() + mock_client_class.return_value = mock_client + + result = self.env["spp.cel.dci.dispatcher"].fetch_values_for_variable( + self.variable, [self.partner_a.id], "current" + ) + + self.assertEqual(result, {}) + audits = self.env["spp.dci.fetch.audit"].search([("variable_name", "=", self.variable.name)]) + self.assertEqual(audits.result, "not_found") + + +@tagged("post_install", "-at_install") +class TestIBRHandler(BridgeTestBase): + """Verify IBR dispatcher routing via mocked DCIClient. + + IBR service validates registry_type == "ibr" (lowercase). The bridge + dispatcher normalizes this to the canonical "IBR" key. + """ + + def setUp(self): + super().setUp() + self.dci_source.registry_type = "ibr" + self.variable.dci_attribute_path = "is_duplicate" + + @patch("odoo.addons.spp_dci_client_ibr.services.ibr_service.DCIClient") + def test_ibr_handler_extracts_attribute(self, mock_client_class): + mock_client = MagicMock() + mock_client.search_by_id.return_value = make_ibr_search_response() + mock_client_class.return_value = mock_client + + result = self.env["spp.cel.dci.dispatcher"].fetch_values_for_variable( + self.variable, [self.partner_a.id], "current" + ) + + # check_duplication finds 2 matched programs → is_duplicate=True + self.assertEqual(result, {self.partner_a.id: True}) + + def test_ibr_handler_swallows_per_subject_error(self): + """If check_duplication itself raises (rare — the service swallows + per-identifier failures internally), the dispatcher must record + an error audit row and continue. + """ + with patch( + "odoo.addons.spp_dci_client_ibr.services.ibr_service.IBRService.check_duplication", + side_effect=RuntimeError("ibr boom"), + ): + result = self.env["spp.cel.dci.dispatcher"].fetch_values_for_variable( + self.variable, [self.partner_a.id], "current" + ) + + self.assertEqual(result, {}) + audits = self.env["spp.dci.fetch.audit"].search([("variable_name", "=", self.variable.name)]) + self.assertEqual(len(audits), 1) + self.assertEqual(audits.result, "error") + self.assertIn("ibr boom", audits.error_message) + + @patch("odoo.addons.spp_dci_client_ibr.services.ibr_service.DCIClient") + def test_ibr_handler_records_not_found_when_attribute_path_missing(self, mock_client_class): + self.variable.dci_attribute_path = "nonexistent_key" + mock_client = MagicMock() + mock_client.search_by_id.return_value = make_ibr_search_response() + mock_client_class.return_value = mock_client + + result = self.env["spp.cel.dci.dispatcher"].fetch_values_for_variable( + self.variable, [self.partner_a.id], "current" + ) + + self.assertEqual(result, {}) + audits = self.env["spp.dci.fetch.audit"].search([("variable_name", "=", self.variable.name)]) + self.assertEqual(audits.result, "not_found") diff --git a/spp_cel_dci_bridge/tests/test_dispatcher.py b/spp_cel_dci_bridge/tests/test_dispatcher.py new file mode 100644 index 00000000..8dc60b1a --- /dev/null +++ b/spp_cel_dci_bridge/tests/test_dispatcher.py @@ -0,0 +1,125 @@ +from odoo.exceptions import UserError +from odoo.tests.common import TransactionCase, tagged + +from odoo.addons.spp_cel_dci_bridge.exceptions import DCIConfigurationError + + +@tagged("post_install", "-at_install") +class TestDispatcherRouting(TransactionCase): + """Verify the dispatcher routes by registry_type and tolerates missing setup.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.Provider = cls.env["spp.data.provider"] + cls.Variable = cls.env["spp.cel.variable"] + cls.DCISource = cls.env["spp.dci.data.source"] + cls.dispatcher = cls.env["spp.cel.dci.dispatcher"] + + def _make_variable(self, registry_type, code_suffix): + source = self.DCISource.create( + { + "name": f"Source {code_suffix}", + "code": f"src_{code_suffix}", + "registry_type": registry_type, + "base_url": "https://example.invalid/api", + "auth_type": "none", + "our_sender_id": "test.openspp.example.org", + } + ) + provider = self.Provider.create( + { + "name": f"Provider {code_suffix}", + "code": f"prov_{code_suffix}", + "dci_data_source_id": source.id, + } + ) + var = self.Variable.create( + { + "name": f"var_{code_suffix}", + "cel_accessor": f"var_{code_suffix}", + "source_type": "external", + "value_type": "boolean", + "external_provider_id": provider.id, + "dci_attribute_path": "x", + } + ) + return var, source, provider + + def test_empty_subjects_returns_empty_dict(self): + var, _, _ = self._make_variable("DR", "empty") + self.assertEqual(self.dispatcher.fetch_values_for_variable(var, [], "current"), {}) + + def test_non_dci_provider_returns_empty(self): + provider = self.Provider.create({"name": "Plain", "code": "plain_p"}) + var = self.Variable.create( + { + "name": "var_plain", + "cel_accessor": "var_plain", + "source_type": "external", + "value_type": "number", + "external_provider_id": provider.id, + } + ) + self.assertEqual(self.dispatcher.fetch_values_for_variable(var, [1], "current"), {}) + + def test_inactive_source_returns_empty(self): + var, source, _ = self._make_variable("DR", "inactive") + source.active = False + self.assertEqual(self.dispatcher.fetch_values_for_variable(var, [1], "current"), {}) + + def test_unknown_registry_type_raises_configuration_error(self): + var, source, _ = self._make_variable("DR", "unknown") + # Bypass the registry_type constraint by writing raw. Selection is + # validated at write time, not at the DB level. + self.env.cr.execute( + "UPDATE spp_dci_data_source SET registry_type = 'XX' WHERE id = %s", + (source.id,), + ) + source.invalidate_recordset() + with self.assertRaises(DCIConfigurationError): + self.dispatcher.fetch_values_for_variable(var, [1], "current") + + def test_sr_handler_raises_configuration_error(self): + """Social Registry handler is a v1 stub — must surface, not silently + return empty (which would cause silent eligibility failure).""" + var, _, _ = self._make_variable("ns:org:RegistryType:Social", "sr_stub") + with self.assertRaises(DCIConfigurationError): + self.dispatcher.fetch_values_for_variable(var, [1], "current") + + def test_fr_handler_raises_configuration_error(self): + """Functional Registry handler is a v1 stub — must surface.""" + var, _, _ = self._make_variable("ns:org:RegistryType:FR", "fr_stub") + with self.assertRaises(DCIConfigurationError): + self.dispatcher.fetch_values_for_variable(var, [1], "current") + + def test_dci_configuration_error_is_user_error(self): + """DCIConfigurationError must inherit UserError so existing + catch-blocks expecting UserError continue to handle it.""" + self.assertTrue(issubclass(DCIConfigurationError, UserError)) + + def test_dr_handler_returns_empty_skeleton(self): + var, _, _ = self._make_variable("DR", "dr_skel") + result = self.dispatcher.fetch_values_for_variable(var, [1], "current") + self.assertEqual(result, {}) + + def test_extract_by_path_returns_top_level(self): + result = self.dispatcher._extract_by_path({"has_disability": True}, "has_disability") + self.assertIs(result, True) + + def test_extract_by_path_returns_nested(self): + payload = {"functional_scores": {"cognition": 3}} + result = self.dispatcher._extract_by_path(payload, "functional_scores.cognition") + self.assertEqual(result, 3) + + def test_extract_by_path_missing_segment_returns_none(self): + result = self.dispatcher._extract_by_path({"a": {"b": 1}}, "a.c") + self.assertIsNone(result) + + def test_extract_by_path_none_payload(self): + self.assertIsNone(self.dispatcher._extract_by_path(None, "x")) + + def test_extract_by_path_non_dict_segment(self): + # Cannot descend into a non-dict value + result = self.dispatcher._extract_by_path({"a": "not-a-dict"}, "a.b") + self.assertIsNone(result) diff --git a/spp_cel_dci_bridge/tests/test_dr_handler.py b/spp_cel_dci_bridge/tests/test_dr_handler.py new file mode 100644 index 00000000..c7de30f0 --- /dev/null +++ b/spp_cel_dci_bridge/tests/test_dr_handler.py @@ -0,0 +1,132 @@ +from unittest.mock import MagicMock, patch + +from odoo.tests.common import tagged + +from .common import ( + BridgeTestBase, + make_dr_empty_response, + make_dr_search_response, +) + + +@tagged("post_install", "-at_install") +class TestDRHandler(BridgeTestBase): + """Verify the dispatcher's DR handler against a mocked DCIClient.""" + + @patch("odoo.addons.spp_dci_client_dr.services.dr_service.DCIClient") + def test_dr_handler_returns_attribute_value(self, mock_client_class): + mock_client = MagicMock() + mock_client.search_by_id.return_value = make_dr_search_response(has_disability=True) + mock_client_class.return_value = mock_client + + result = self.env["spp.cel.dci.dispatcher"].fetch_values_for_variable( + self.variable, [self.partner_a.id], "current" + ) + + self.assertEqual(result, {self.partner_a.id: True}) + + @patch("odoo.addons.spp_dci_client_dr.services.dr_service.DCIClient") + def test_dr_handler_returns_false_when_no_disability(self, mock_client_class): + mock_client = MagicMock() + mock_client.search_by_id.return_value = make_dr_search_response(has_disability=False) + mock_client_class.return_value = mock_client + + result = self.env["spp.cel.dci.dispatcher"].fetch_values_for_variable( + self.variable, [self.partner_a.id], "current" + ) + + self.assertEqual(result, {self.partner_a.id: False}) + + @patch("odoo.addons.spp_dci_client_dr.services.dr_service.DCIClient") + def test_dr_handler_extracts_nested_attribute(self, mock_client_class): + # Reconfigure the variable to read a nested path + self.variable.dci_attribute_path = "functional_scores.Vision" + + mock_client = MagicMock() + mock_client.search_by_id.return_value = make_dr_search_response( + functional_scores={"Vision": 4, "Mobility": 2, "Cognition": 1} + ) + mock_client_class.return_value = mock_client + + result = self.env["spp.cel.dci.dispatcher"].fetch_values_for_variable( + self.variable, [self.partner_a.id], "current" + ) + + self.assertEqual(result, {self.partner_a.id: 4}) + + @patch("odoo.addons.spp_dci_client_dr.services.dr_service.DCIClient") + def test_dr_handler_omits_subject_with_empty_response(self, mock_client_class): + mock_client = MagicMock() + mock_client.search_by_id.return_value = make_dr_empty_response() + mock_client_class.return_value = mock_client + + result = self.env["spp.cel.dci.dispatcher"].fetch_values_for_variable( + self.variable, [self.partner_a.id], "current" + ) + + self.assertEqual(result, {}) + + @patch("odoo.addons.spp_dci_client_dr.services.dr_service.DCIClient") + def test_dr_handler_omits_subject_without_identifier(self, mock_client_class): + # DRService.get_disability_status returns None when partner has no + # matching identifier; the bridge must treat that as "skip subject", + # not "error the whole batch". + mock_client = MagicMock() + mock_client.search_by_id.return_value = make_dr_search_response(has_disability=True) + mock_client_class.return_value = mock_client + + result = self.env["spp.cel.dci.dispatcher"].fetch_values_for_variable( + self.variable, + [self.partner_a.id, self.partner_no_id.id], + "current", + ) + + # partner_no_id has no identifier; only partner_a is in the result + self.assertEqual(result, {self.partner_a.id: True}) + + @patch("odoo.addons.spp_dci_client_dr.services.dr_service.DCIClient") + def test_dr_handler_batches_multiple_subjects(self, mock_client_class): + # Per-subject loop in v1; verify both subjects appear in the result. + mock_client = MagicMock() + mock_client.search_by_id.return_value = make_dr_search_response(has_disability=True) + mock_client_class.return_value = mock_client + + result = self.env["spp.cel.dci.dispatcher"].fetch_values_for_variable( + self.variable, + [self.partner_a.id, self.partner_b.id], + "current", + ) + + self.assertEqual( + result, + {self.partner_a.id: True, self.partner_b.id: True}, + ) + + @patch("odoo.addons.spp_dci_client_dr.services.dr_service.DCIClient") + def test_dr_handler_swallows_per_subject_errors(self, mock_client_class): + """One subject erroring out must not fail the batch.""" + responses = iter( + [ + make_dr_search_response(has_disability=True), + Exception("simulated network error"), + ] + ) + + def side_effect(**_kwargs): + r = next(responses) + if isinstance(r, Exception): + raise r + return r + + mock_client = MagicMock() + mock_client.search_by_id.side_effect = side_effect + mock_client_class.return_value = mock_client + + result = self.env["spp.cel.dci.dispatcher"].fetch_values_for_variable( + self.variable, + [self.partner_a.id, self.partner_b.id], + "current", + ) + + # First subject succeeded; second errored and is omitted + self.assertEqual(result, {self.partner_a.id: True}) diff --git a/spp_cel_dci_bridge/tests/test_eligibility_prewarm.py b/spp_cel_dci_bridge/tests/test_eligibility_prewarm.py new file mode 100644 index 00000000..08bdbf73 --- /dev/null +++ b/spp_cel_dci_bridge/tests/test_eligibility_prewarm.py @@ -0,0 +1,123 @@ +"""Verify that _prepare_eligible_domain pre-warms the cache. + +The cycle-based eligibility flow calls _precompute_cycle_cached_variables +on its own. The program-level Import Eligible / Enroll Eligible flow +does NOT — without this override, the executor hits an "incomplete cache" +state and falls back to a legacy Python path (spp.indicator.evaluate) +that no longer exists in Odoo 19. + +This test confirms: + - _prepare_eligible_domain calls cache_mgr.precompute_cached_variables + when the manager carries a CEL expression + - It does NOT call precompute when there's no CEL expression (back-compat) +""" + +from unittest.mock import patch + +from odoo.tests.common import TransactionCase, tagged + + +@tagged("post_install", "-at_install") +class TestEligibilityPreWarm(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.program = cls.env["spp.program"].create({"name": "Pre-Warm Test Program", "target_type": "individual"}) + cls.manager_default = cls.env["spp.program.membership.manager.default"].create( + {"name": "Pre-Warm EM", "program_id": cls.program.id} + ) + em = cls.env["spp.eligibility.manager"].create( + { + "program_id": cls.program.id, + "manager_ref_id": f"{cls.manager_default._name},{cls.manager_default.id}", + } + ) + cls.program.write({"eligibility_manager_ids": [(4, em.id)]}) + + def test_no_cel_expression_no_prewarm(self): + """When the manager has no CEL expression, the parent flow runs + and we must NOT touch the cache manager — that path may not even + have cached variables installed.""" + self.manager_default.cel_expression = False + with patch.object( + self.env["spp.data.cache.manager"].__class__, + "precompute_cached_variables", + ) as mock_pre: + self.manager_default._prepare_eligible_domain(membership=None) + mock_pre.assert_not_called() + + def test_cel_expression_triggers_prewarm(self): + """When the manager has a CEL expression, pre-warm fires BEFORE + compile_expression, ensuring the SQL fast path is available.""" + # The cohort search needs at least one registrant matching the base + # domain (is_registrant=True, is_group=False, disabled=False) — + # otherwise pre-warm short-circuits on empty subject_ids. + self.env["res.partner"].create( + { + "name": "Cohort Member", + "is_registrant": True, + "is_group": False, + } + ) + self.manager_default.cel_expression = "true" # trivially valid + with patch.object( + self.env["spp.data.cache.manager"].__class__, + "precompute_cached_variables", + return_value={"success": True, "variables_processed": 0}, + ) as mock_pre: + self.manager_default._prepare_eligible_domain(membership=None) + mock_pre.assert_called_once() + # Verify call signature matches what cycle_manager_base uses + call_kwargs = mock_pre.call_args.kwargs + self.assertEqual(call_kwargs.get("period_key"), "current") + self.assertEqual(call_kwargs.get("program_id"), self.program.id) + + def test_empty_cohort_skips_prewarm(self): + """When no partner matches the base domain, pre-warm is a no-op + (early return before calling the cache manager) — saves a no-op + call into spp_cel_domain.""" + # Ensure no registrants exist that match the base domain + self.env["res.partner"].search([("is_registrant", "=", True), ("is_group", "=", False)]).write( + {"is_registrant": False} + ) + + self.manager_default.cel_expression = "true" + with patch.object( + self.env["spp.data.cache.manager"].__class__, + "precompute_cached_variables", + ) as mock_pre: + self.manager_default._prepare_eligible_domain(membership=None) + mock_pre.assert_not_called() + + def test_prewarm_does_not_raise_when_cache_manager_fails(self): + """A pre-warm failure must not block eligibility evaluation. The + CEL evaluator will report its own error if the cache is still + incomplete after.""" + self.manager_default.cel_expression = "true" + with patch.object( + self.env["spp.data.cache.manager"].__class__, + "precompute_cached_variables", + side_effect=RuntimeError("simulated cache failure"), + ): + # Must not raise + self.manager_default._prepare_eligible_domain(membership=None) + + def test_prewarm_scopes_cohort_to_membership_when_provided(self): + """If membership is passed, pre-warm only those partners — saves + the cost of fetching for the entire registrant base.""" + partner = self.env["res.partner"].create({"name": "Test Reg", "is_registrant": True, "is_group": False}) + membership = self.env["spp.program.membership"].create( + {"partner_id": partner.id, "program_id": self.program.id, "state": "draft"} + ) + self.manager_default.cel_expression = "true" + with patch.object( + self.env["spp.data.cache.manager"].__class__, + "precompute_cached_variables", + return_value={"success": True, "variables_processed": 0}, + ) as mock_pre: + self.manager_default._prepare_eligible_domain(membership=membership) + mock_pre.assert_called_once() + args = mock_pre.call_args.args + # First positional arg is subject_ids — must include the partner + self.assertIn(partner.id, args[0]) diff --git a/spp_cel_dci_bridge/tests/test_end_to_end.py b/spp_cel_dci_bridge/tests/test_end_to_end.py new file mode 100644 index 00000000..9690bddd --- /dev/null +++ b/spp_cel_dci_bridge/tests/test_end_to_end.py @@ -0,0 +1,165 @@ +"""End-to-end test of the demo flow. + +Wires the full chain: + precompute_cached_variables() -> _compute_variable_values() -> + _handler_dr() -> mocked DCIClient -> spp.data.value rows -> + CEL compile_expression() resolves `has_disability_test == true` to a + SQL filter over spp_data_value -> domain returns the right partners. + +If this test passes, the demo flow works. +""" + +from unittest.mock import MagicMock, patch + +from odoo.tests.common import tagged + +from .common import BridgeTestBase, make_dr_search_response + + +@tagged("post_install", "-at_install") +class TestEndToEndEligibility(BridgeTestBase): + """The demo flow under test: DCI fetch -> cache -> CEL filter.""" + + def _patch_dr_responses(self, mock_client_class, responses_by_uin): + """Configure the mocked DCIClient to vary response by identifier value.""" + mock_client = MagicMock() + + def search_by_id(identifier_type, identifier_value, **_kwargs): + return responses_by_uin.get( + identifier_value, + {"message": {"search_response": []}}, + ) + + mock_client.search_by_id.side_effect = search_by_id + mock_client_class.return_value = mock_client + return mock_client + + @patch("odoo.addons.spp_dci_client_dr.services.dr_service.DCIClient") + def test_demo_flow_precompute_then_cel_filter(self, mock_client_class): + """Pre-fetch via DCI, then verify CEL filter selects the right subjects.""" + # Mock OpenG2P-shaped DR responses + self._patch_dr_responses( + mock_client_class, + { + "UIN-BRIDGE-A": make_dr_search_response(has_disability=True), + "UIN-BRIDGE-B": make_dr_search_response(has_disability=False), + }, + ) + + # Phase 1: pre-compute. This is what cycle_manager_base does before + # eligibility checks, via _precompute_cycle_cached_variables(). + cache_mgr = self.env["spp.data.cache.manager"] + result = cache_mgr.precompute_cached_variables( + [self.partner_a.id, self.partner_b.id], + period_key="current", + variable_names=[self.variable.name], + ) + + self.assertTrue(result["success"], result.get("error_message")) + self.assertEqual(result["total_computed"], 2) + + # Phase 2: verify cache rows exist + DataValue = self.env["spp.data.value"] + rows = DataValue.search( + [ + ("variable_name", "=", self.variable.name), + ("subject_id", "in", [self.partner_a.id, self.partner_b.id]), + ] + ) + self.assertEqual(len(rows), 2) + by_subject = {r.subject_id: r.value_json["value"] for r in rows} + self.assertEqual(by_subject[self.partner_a.id], True) + self.assertEqual(by_subject[self.partner_b.id], False) + + # Phase 3: compile the CEL eligibility rule. The variable resolver + # expands `has_disability_test == true` to `metric('has_disability_test', me) == true`, + # the translator emits a MetricCompare plan, and the executor uses the + # SQL fast path against spp_data_value. + service = self.env["spp.cel.service"] + compiled = service.compile_expression( + f"{self.variable.cel_accessor} == true", + profile="registry_individuals", + base_domain=[ + ("id", "in", [self.partner_a.id, self.partner_b.id]), + ], + limit=0, + ) + + self.assertTrue(compiled["valid"], compiled.get("error")) + # Exactly one matching partner (partner_a) + self.assertEqual(compiled["count"], 1) + + # Inverse comparison exercises the cel_executor boolean `!=` branch, + # which is symmetric with `==` but a separate SQL emission path. + compiled_neg = service.compile_expression( + f"{self.variable.cel_accessor} != true", + profile="registry_individuals", + base_domain=[("id", "in", [self.partner_a.id, self.partner_b.id])], + limit=0, + ) + self.assertTrue(compiled_neg["valid"], compiled_neg.get("error")) + # partner_b's cached value is False → matches `!= true` + self.assertEqual(compiled_neg["count"], 1) + + # Phase 4: verify audit rows reflect the two fetches + Audit = self.env["spp.dci.fetch.audit"] + audits = Audit.search([("variable_name", "=", self.variable.name)]) + self.assertEqual(len(audits), 2) + self.assertEqual({a.result for a in audits}, {"ok"}) + self.assertEqual( + {a.subject_id for a in audits}, + {self.partner_a.id, self.partner_b.id}, + ) + + @patch("odoo.addons.spp_dci_client_dr.services.dr_service.DCIClient") + def test_demo_flow_with_partial_dci_results(self, mock_client_class): + """Partner A has a DR record, B doesn't. CEL must still filter correctly.""" + self._patch_dr_responses( + mock_client_class, + { + "UIN-BRIDGE-A": make_dr_search_response(has_disability=True), + # B is omitted → empty response → not_found + }, + ) + + cache_mgr = self.env["spp.data.cache.manager"] + cache_mgr.precompute_cached_variables( + [self.partner_a.id, self.partner_b.id], + period_key="current", + variable_names=[self.variable.name], + ) + + # Both subjects have a cache row — A holds the live True, B holds the + # explicit None recorded when the DR returned no data. Keeping the + # cache complete across the cohort is what lets the CEL executor use + # the SQL fast path instead of falling back to Python evaluation. + DataValue = self.env["spp.data.value"] + rows = DataValue.search( + [("variable_name", "=", self.variable.name)], + order="subject_id", + ) + self.assertEqual(len(rows), 2) + by_subject = {r.subject_id: r.value_json["value"] for r in rows} + self.assertEqual(by_subject[self.partner_a.id], True) + self.assertIsNone(by_subject[self.partner_b.id]) + + # CEL filter: A is included, B has no cache row and is excluded + service = self.env["spp.cel.service"] + compiled = service.compile_expression( + f"{self.variable.cel_accessor} == true", + profile="registry_individuals", + base_domain=[ + ("id", "in", [self.partner_a.id, self.partner_b.id]), + ], + limit=0, + ) + + self.assertTrue(compiled["valid"], compiled.get("error")) + self.assertEqual(compiled["count"], 1) + + # Audit: A=ok, B=not_found + Audit = self.env["spp.dci.fetch.audit"] + audits = Audit.search([("variable_name", "=", self.variable.name)]) + by_subject = {a.subject_id: a.result for a in audits} + self.assertEqual(by_subject[self.partner_a.id], "ok") + self.assertEqual(by_subject[self.partner_b.id], "not_found") diff --git a/spp_cel_dci_bridge/tests/test_failure_policy.py b/spp_cel_dci_bridge/tests/test_failure_policy.py new file mode 100644 index 00000000..0e189a99 --- /dev/null +++ b/spp_cel_dci_bridge/tests/test_failure_policy.py @@ -0,0 +1,339 @@ +from unittest.mock import MagicMock, patch + +from odoo.exceptions import UserError +from odoo.tests.common import tagged + +from odoo.addons.spp_cel_dci_bridge.exceptions import DCIConfigurationError + +from .common import BridgeTestBase, make_dr_search_response + + +@tagged("post_install", "-at_install") +class TestFailurePolicy(BridgeTestBase): + """Verify the three external_failure_policy values behave correctly.""" + + def _patch_client(self, mock_client_class, side_effect=None, return_value=None): + mock_client = MagicMock() + if side_effect is not None: + mock_client.search_by_id.side_effect = side_effect + else: + mock_client.search_by_id.return_value = return_value or make_dr_search_response() + mock_client_class.return_value = mock_client + return mock_client + + # ------------------------------------------------------------------ null + + @patch("odoo.addons.spp_dci_client_dr.services.dr_service.DCIClient") + def test_null_policy_swallows_per_subject_errors(self, mock_client_class): + # Dispatcher succeeds for partner_a, errors for partner_b + responses = iter([make_dr_search_response(True), Exception("boom")]) + + def side_effect(**_): + r = next(responses) + if isinstance(r, Exception): + raise r + return r + + self._patch_client(mock_client_class, side_effect=side_effect) + self.variable.external_failure_policy = "null" + + cache_mgr = self.env["spp.data.cache.manager"] + result = cache_mgr._compute_dci_values( + self.variable, + [self.partner_a.id, self.partner_b.id], + "current", + program_id=None, + ) + + # null policy: errored subject gets an explicit None entry so the + # cache stays complete; CEL evaluates against null and the subject + # fails the `== true` filter. + self.assertEqual(result, {self.partner_a.id: True, self.partner_b.id: None}) + + @patch("odoo.addons.spp_dci_client_dr.services.dr_service.DCIClient") + def test_null_policy_returns_null_on_wholesale_failure(self, mock_client_class): + # Simulate the dispatcher itself raising (e.g., bad config caught late) + with patch.object( + self.env["spp.cel.dci.dispatcher"].__class__, + "fetch_values_for_variable", + side_effect=RuntimeError("dispatcher broke"), + ): + self.variable.external_failure_policy = "null" + cache_mgr = self.env["spp.data.cache.manager"] + result = cache_mgr._compute_dci_values( + self.variable, + [self.partner_a.id], + "current", + program_id=None, + ) + + # Wholesale failure under null policy: subject filled with None + self.assertEqual(result, {self.partner_a.id: None}) + + # -------------------------------------------------------------- fail + + @patch("odoo.addons.spp_dci_client_dr.services.dr_service.DCIClient") + def test_fail_policy_propagates_wholesale_exception(self, mock_client_class): + with patch.object( + self.env["spp.cel.dci.dispatcher"].__class__, + "fetch_values_for_variable", + side_effect=RuntimeError("dispatcher broke"), + ): + self.variable.external_failure_policy = "fail" + cache_mgr = self.env["spp.data.cache.manager"] + + with self.assertRaises(UserError): + cache_mgr._compute_dci_values( + self.variable, + [self.partner_a.id], + "current", + program_id=None, + ) + + @patch("odoo.addons.spp_dci_client_dr.services.dr_service.DCIClient") + def test_fail_policy_succeeds_when_dispatcher_succeeds(self, mock_client_class): + # Even with fail policy, a clean run returns values normally + self._patch_client(mock_client_class, return_value=make_dr_search_response(True)) + self.variable.external_failure_policy = "fail" + + cache_mgr = self.env["spp.data.cache.manager"] + result = cache_mgr._compute_dci_values( + self.variable, + [self.partner_a.id], + "current", + program_id=None, + ) + + self.assertEqual(result, {self.partner_a.id: True}) + + # --------------------------------------------------------- last_known + + @patch("odoo.addons.spp_dci_client_dr.services.dr_service.DCIClient") + def test_last_known_picks_most_recent_of_many_history_rows(self, mock_client_class): + """When several historical rows exist for the same subject, + last_known must return the most recent one. Locks in the + DISTINCT ON (subject_id) ORDER BY recorded_at DESC semantic. + """ + DataValue = self.env["spp.data.value"] + # Three rows for partner_a with different recorded_at — newest is False. + # Use period_key to write three distinct rows (unique by name+model+id+period). + from datetime import datetime, timedelta + + now = datetime(2026, 5, 1, 12, 0, 0) + for offset_days, value, period in [ + (-30, True, "2026-04"), # oldest + (-15, True, "2026-04b"), # middle + (-1, False, "current"), # newest -> wins + ]: + DataValue.create( + { + "variable_name": self.variable.name, + "subject_model": "res.partner", + "subject_id": self.partner_a.id, + "period_key": period, + "value_json": {"value": value}, + "value_type": "boolean", + "source_type": "external", + "provider": self.provider.code, + "recorded_at": now + timedelta(days=offset_days), + } + ) + + with patch.object( + self.env["spp.cel.dci.dispatcher"].__class__, + "fetch_values_for_variable", + side_effect=RuntimeError("dispatcher broke"), + ): + self.variable.external_failure_policy = "last_known" + cache_mgr = self.env["spp.data.cache.manager"] + result = cache_mgr._compute_dci_values( + self.variable, + [self.partner_a.id], + "current", + program_id=None, + ) + + # Newest row (offset_days=-1) wins, value=False + self.assertEqual(result, {self.partner_a.id: False}) + + @patch("odoo.addons.spp_dci_client_dr.services.dr_service.DCIClient") + def test_last_known_policy_uses_prior_cached_value(self, mock_client_class): + # Pre-seed a known value in spp.data.value + DataValue = self.env["spp.data.value"] + DataValue.create( + { + "variable_name": self.variable.name, + "subject_model": "res.partner", + "subject_id": self.partner_a.id, + "period_key": "current", + "value_json": {"value": True}, + "value_type": "boolean", + "source_type": "external", + "provider": self.provider.code, + } + ) + + # Simulate a wholesale failure + with patch.object( + self.env["spp.cel.dci.dispatcher"].__class__, + "fetch_values_for_variable", + side_effect=RuntimeError("dispatcher broke"), + ): + self.variable.external_failure_policy = "last_known" + cache_mgr = self.env["spp.data.cache.manager"] + result = cache_mgr._compute_dci_values( + self.variable, + [self.partner_a.id], + "current", + program_id=None, + ) + + # Should surface the previously-cached value + self.assertEqual(result, {self.partner_a.id: True}) + + @patch("odoo.addons.spp_dci_client_dr.services.dr_service.DCIClient") + def test_last_known_policy_no_prior_value_yields_null(self, mock_client_class): + with patch.object( + self.env["spp.cel.dci.dispatcher"].__class__, + "fetch_values_for_variable", + side_effect=RuntimeError("dispatcher broke"), + ): + self.variable.external_failure_policy = "last_known" + cache_mgr = self.env["spp.data.cache.manager"] + result = cache_mgr._compute_dci_values( + self.variable, + [self.partner_a.id], + "current", + program_id=None, + ) + + # No prior cached value; subject filled with None to keep cache complete + self.assertEqual(result, {self.partner_a.id: None}) + + @patch("odoo.addons.spp_dci_client_dr.services.dr_service.DCIClient") + def test_last_known_skips_null_prior_values(self, mock_client_class): + DataValue = self.env["spp.data.value"] + DataValue.create( + { + "variable_name": self.variable.name, + "subject_model": "res.partner", + "subject_id": self.partner_a.id, + "period_key": "current", + "value_json": {"value": None}, + "value_type": "boolean", + "source_type": "external", + "provider": self.provider.code, + } + ) + + with patch.object( + self.env["spp.cel.dci.dispatcher"].__class__, + "fetch_values_for_variable", + side_effect=RuntimeError("dispatcher broke"), + ): + self.variable.external_failure_policy = "last_known" + cache_mgr = self.env["spp.data.cache.manager"] + result = cache_mgr._compute_dci_values( + self.variable, + [self.partner_a.id], + "current", + program_id=None, + ) + + # Null prior values are not surfaced as "last known"; subject still + # gets a None entry from the fill-missing step + self.assertEqual(result, {self.partner_a.id: None}) + + @patch("odoo.addons.spp_dci_client_dr.services.dr_service.DCIClient") + def test_last_known_fills_only_missing_subjects(self, mock_client_class): + """Partial success: live fetch returns partner_a, last_known fills partner_b.""" + DataValue = self.env["spp.data.value"] + DataValue.create( + { + "variable_name": self.variable.name, + "subject_model": "res.partner", + "subject_id": self.partner_b.id, + "period_key": "current", + "value_json": {"value": False}, + "value_type": "boolean", + "source_type": "external", + "provider": self.provider.code, + } + ) + + # Live fetch returns A but errors B + responses = iter([make_dr_search_response(True), Exception("partial fail")]) + + def side_effect(**_): + r = next(responses) + if isinstance(r, Exception): + raise r + return r + + self._patch_client(mock_client_class, side_effect=side_effect) + self.variable.external_failure_policy = "last_known" + + cache_mgr = self.env["spp.data.cache.manager"] + result = cache_mgr._compute_dci_values( + self.variable, + [self.partner_a.id, self.partner_b.id], + "current", + program_id=None, + ) + + # A: live fetch True; B: last_known False + self.assertEqual( + result, + {self.partner_a.id: True, self.partner_b.id: False}, + ) + + # ----------------------------- DCIConfigurationError bypasses policy + + def _force_configuration_error(self): + """Patch the dispatcher to raise DCIConfigurationError.""" + return patch.object( + self.env["spp.cel.dci.dispatcher"].__class__, + "fetch_values_for_variable", + side_effect=DCIConfigurationError("Stub handler called"), + ) + + def test_configuration_error_propagates_under_null_policy(self): + """Configuration errors (missing module, stub handler) must surface + immediately even with null policy. Silently filling None when the + integration is broken would be a compliance hazard.""" + self.variable.external_failure_policy = "null" + cache_mgr = self.env["spp.data.cache.manager"] + with self._force_configuration_error(): + with self.assertRaises(DCIConfigurationError): + cache_mgr._compute_dci_values( + self.variable, + [self.partner_a.id], + "current", + program_id=None, + ) + + def test_configuration_error_propagates_under_last_known_policy(self): + """last_known is for transient runtime failures, not config errors.""" + self.variable.external_failure_policy = "last_known" + cache_mgr = self.env["spp.data.cache.manager"] + with self._force_configuration_error(): + with self.assertRaises(DCIConfigurationError): + cache_mgr._compute_dci_values( + self.variable, + [self.partner_a.id], + "current", + program_id=None, + ) + + def test_configuration_error_propagates_under_fail_policy(self): + """fail policy already raises; configuration errors must too.""" + self.variable.external_failure_policy = "fail" + cache_mgr = self.env["spp.data.cache.manager"] + with self._force_configuration_error(): + with self.assertRaises(DCIConfigurationError): + cache_mgr._compute_dci_values( + self.variable, + [self.partner_a.id], + "current", + program_id=None, + ) diff --git a/spp_cel_dci_bridge/tests/test_schema_extensions.py b/spp_cel_dci_bridge/tests/test_schema_extensions.py new file mode 100644 index 00000000..385576c3 --- /dev/null +++ b/spp_cel_dci_bridge/tests/test_schema_extensions.py @@ -0,0 +1,140 @@ +from odoo.exceptions import ValidationError +from odoo.tests.common import TransactionCase, tagged + + +@tagged("post_install", "-at_install") +class TestSchemaExtensions(TransactionCase): + """Verify the additive schema extensions to spp.data.provider and spp.cel.variable.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.Provider = cls.env["spp.data.provider"] + cls.Variable = cls.env["spp.cel.variable"] + cls.DCISource = cls.env["spp.dci.data.source"] + + cls.dci_source = cls.DCISource.create( + { + "name": "Test DR Source", + "code": "test_dr_source", + "registry_type": "DR", + "base_url": "https://example.invalid/api", + "auth_type": "none", + "our_sender_id": "test.openspp.example.org", + } + ) + + def test_provider_dci_data_source_field_exists(self): + provider = self.Provider.create({"name": "Plain Provider", "code": "plain_provider"}) + self.assertFalse(provider.dci_data_source_id) + self.assertFalse(provider.is_dci_backed) + + def test_provider_is_dci_backed_reflects_link(self): + provider = self.Provider.create( + { + "name": "DR Provider", + "code": "dr_provider", + "dci_data_source_id": self.dci_source.id, + } + ) + self.assertTrue(provider.is_dci_backed) + + provider.dci_data_source_id = False + self.assertFalse(provider.is_dci_backed) + + def test_variable_dci_attribute_path_required_when_dci_backed(self): + provider = self.Provider.create( + { + "name": "DR Provider 2", + "code": "dr_provider_2", + "dci_data_source_id": self.dci_source.id, + } + ) + + with self.assertRaises(ValidationError): + self.Variable.create( + { + "name": "var_no_path", + "cel_accessor": "var_no_path", + "source_type": "external", + "value_type": "boolean", + "external_provider_id": provider.id, + # missing dci_attribute_path + } + ) + + def test_variable_dci_attribute_path_accepted(self): + provider = self.Provider.create( + { + "name": "DR Provider 3", + "code": "dr_provider_3", + "dci_data_source_id": self.dci_source.id, + } + ) + var = self.Variable.create( + { + "name": "var_ok", + "cel_accessor": "var_ok", + "source_type": "external", + "value_type": "boolean", + "external_provider_id": provider.id, + "dci_attribute_path": "has_disability", + } + ) + self.assertEqual(var.dci_attribute_path, "has_disability") + self.assertEqual(var.external_failure_policy, "null") + + def test_variable_attribute_path_not_required_for_non_dci_provider(self): + provider = self.Provider.create({"name": "REST Provider", "code": "rest_provider"}) + var = self.Variable.create( + { + "name": "var_rest", + "cel_accessor": "var_rest", + "source_type": "external", + "value_type": "number", + "external_provider_id": provider.id, + } + ) + self.assertFalse(var.dci_attribute_path) + + def test_failure_policy_default_is_null(self): + provider = self.Provider.create( + { + "name": "DR Provider 4", + "code": "dr_provider_4", + "dci_data_source_id": self.dci_source.id, + } + ) + var = self.Variable.create( + { + "name": "var_default_policy", + "cel_accessor": "var_default_policy", + "source_type": "external", + "value_type": "boolean", + "external_provider_id": provider.id, + "dci_attribute_path": "x", + } + ) + self.assertEqual(var.external_failure_policy, "null") + + def test_failure_policy_accepts_other_values(self): + provider = self.Provider.create( + { + "name": "DR Provider 5", + "code": "dr_provider_5", + "dci_data_source_id": self.dci_source.id, + } + ) + for policy in ("null", "last_known", "fail"): + var = self.Variable.create( + { + "name": f"var_{policy}", + "cel_accessor": f"var_{policy}", + "source_type": "external", + "value_type": "boolean", + "external_provider_id": provider.id, + "dci_attribute_path": "x", + "external_failure_policy": policy, + } + ) + self.assertEqual(var.external_failure_policy, policy) diff --git a/spp_cel_dci_bridge/views/cel_variable_views.xml b/spp_cel_dci_bridge/views/cel_variable_views.xml new file mode 100644 index 00000000..05aa5208 --- /dev/null +++ b/spp_cel_dci_bridge/views/cel_variable_views.xml @@ -0,0 +1,32 @@ + + + + spp.cel.variable.form.dci + spp.cel.variable + + + + + + + + + + + diff --git a/spp_cel_dci_bridge/views/data_provider_views.xml b/spp_cel_dci_bridge/views/data_provider_views.xml new file mode 100644 index 00000000..3366723e --- /dev/null +++ b/spp_cel_dci_bridge/views/data_provider_views.xml @@ -0,0 +1,65 @@ + + + + spp.data.provider.form.dci + spp.data.provider + + + + + + + + + + + + + + + + is_dci_backed + + + is_dci_backed + + + + + + spp.data.provider.tree.dci + spp.data.provider + + + + + + + + diff --git a/spp_cel_dci_bridge/views/dci_data_source_views.xml b/spp_cel_dci_bridge/views/dci_data_source_views.xml new file mode 100644 index 00000000..a7259a68 --- /dev/null +++ b/spp_cel_dci_bridge/views/dci_data_source_views.xml @@ -0,0 +1,33 @@ + + + + + spp.dci.data.source.form.inherit.vendor + spp.dci.data.source + + + + + + + + + + spp.dci.data.source.tree.inherit.vendor + spp.dci.data.source + + + + + + + + diff --git a/spp_cel_dci_bridge/views/dci_fetch_audit_views.xml b/spp_cel_dci_bridge/views/dci_fetch_audit_views.xml new file mode 100644 index 00000000..fe918e04 --- /dev/null +++ b/spp_cel_dci_bridge/views/dci_fetch_audit_views.xml @@ -0,0 +1,97 @@ + + + + spp.dci.fetch.audit.list + spp.dci.fetch.audit + + + + + + + + + + + + + + + + + + + spp.dci.fetch.audit.search + spp.dci.fetch.audit + + + + + + + + + + + + + + + + + DCI Fetch Audit + spp.dci.fetch.audit + list + + + + + + + + diff --git a/spp_cel_domain/models/cel_executor.py b/spp_cel_domain/models/cel_executor.py index 8e75979a..69edacc1 100644 --- a/spp_cel_domain/models/cel_executor.py +++ b/spp_cel_domain/models/cel_executor.py @@ -425,15 +425,30 @@ def compile_and_preview( ) exec_self = self.with_context(cel_mode="preview", cel_request_id=request_id) ids = exec_self._execute_plan(model, plan, metrics_info) - # If a fast-path domain override was provided in metrics_info, use it instead of materializing ids - override_domain: list[Any] | None = None + # If fast-path domain overrides were provided in metrics_info, + # AND them together. Each is an ('id', 'in', ) + # clause from _exec_metric's SQL fast path. With a single + # MetricCompare, there is one override and it acts as the + # whole filter. With a compound expression like + # `metric('a', me) == X and metric('b', me) == Y` there are + # multiple overrides, and the executor must AND them on the + # final domain — otherwise only the first metric clause + # filters the cohort and the AND is silently dropped. + # + # KNOWN LIMITATION: this branch assumes the metric clauses + # are AND-composed. For an OR of metric clauses (uncommon + # in eligibility rules) the SQL fast path's per-clause + # subqueries cannot be UNION'd through Odoo domain syntax; + # ids materialization in _exec_metric would be required. + override_domains_list: list[list[Any]] = [] for mi in metrics_info: od = mi.get("override_domain") if isinstance(mi, dict) else None if od: - override_domain = od - break - if override_domain: - final_domain = self._and_domains(base_domain, override_domain) + override_domains_list.append(od) + if override_domains_list: + final_domain = list(base_domain) + for od in override_domains_list: + final_domain = self._and_domains(final_domain, od) else: final_domain = self._and_domains(base_domain, [("id", "in", ids)]) # Determine execution path for logging and response @@ -1164,6 +1179,23 @@ def _exec_metric( return [] svc = self.env["spp.indicator"] + # Legacy spp.indicator may be present as a model but with the + # evaluate / enqueue methods removed (mid-migration to + # spp.data.cache.manager per ADR-017). Without this guard, an + # incomplete cache crashes with `AttributeError: 'spp.indicator' + # object has no attribute 'evaluate'`. Treat the missing method + # the same as a missing model: warn and return empty, so the + # caller can continue (preview shows 0 matching instead of an + # error). + if not hasattr(svc, "evaluate"): + self._logger.warning( + "[CEL Metrics] spp.indicator.evaluate is unavailable; cache " + "for metric=%s is %s. Returning empty result. Warm the cache " + "via cache_mgr.precompute_cached_variables() before evaluation.", + p.metric, + status.get("status"), + ) + return [] default_mode = "refresh" if (base_count < async_threshold) else "fallback" if default_mode == "fallback" and status.get("status") != "fresh" and not preview_cache_only_mode: # large + not fresh → enqueue refresh and report queued @@ -1248,6 +1280,9 @@ def _metric_registry_info(self, metric: str) -> tuple[str, str]: return provider, return_type def _metric_cmp_supported(self, op: str, rhs: Any, return_type: str) -> bool: + # Check bool BEFORE int|float because bool is a subclass of int. + if isinstance(rhs, bool): + return op in {"==", "!="} if isinstance(rhs, int | float): return op in {"==", "!=", ">", ">=", "<", "<="} if isinstance(rhs, str): @@ -1368,6 +1403,25 @@ def _metric_inselect_sql( period_key, *clause_args, ) + # Check bool BEFORE int|float because bool is a subclass of int. + # Boolean values must be cast to ::boolean, not ::numeric — comparing + # numeric to boolean fails in postgres ("operator does not exist"). + if isinstance(rhs, bool): + bool_ops = {"==": "=", "!=": "!="} + return SQL( + "(%s)", + SQL( + base_sql + + "AND (CASE " + + "WHEN jsonb_typeof(fv.value_json) = 'object' THEN (fv.value_json -> 'value')::boolean " + + "WHEN jsonb_typeof(fv.value_json) = 'boolean' THEN (fv.value_json)::boolean " + + "END) " + + bool_ops[op] + + " %s", + *base_args, + rhs, + ), + ) if isinstance(rhs, int | float): # Handle both scalar numbers and {"value": number} objects # COALESCE extracts from object first, then tries scalar cast diff --git a/spp_dci_openg2p/README.rst b/spp_dci_openg2p/README.rst new file mode 100644 index 00000000..d15a2724 --- /dev/null +++ b/spp_dci_openg2p/README.rst @@ -0,0 +1,322 @@ +============================ +OpenSPP DCI - OpenG2P Preset +============================ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:b36646b257f8b42e8176ac8af4e042d9df2c0d17a822af508d5ae04ba9852807 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OpenSPP%2FOpenSPP2-lightgray.png?logo=github + :target: https://github.com/OpenSPP/OpenSPP2/tree/19.0/spp_dci_openg2p + :alt: OpenSPP/OpenSPP2 + +|badge1| |badge2| |badge3| + +Permanent OpenG2P preset for the CEL <-> DCI bridge. Ships +pre-configured ``spp.dci.data.source``, ``spp.data.provider``, and +``spp.cel.variable`` records so a deployment targeting an OpenG2P-backed +DCI Social Registry gets the wiring out of the box. Config plus a small +vendor adapter that absorbs OpenG2P's request-shape quirks (see +ADR-024). + +What this module ships +~~~~~~~~~~~~~~~~~~~~~~ + ++--------------------------------------+------------------------------------+ +| Record | Purpose | ++======================================+====================================+ +| ``spp.dci.data.source`` 'openg2p_dr' | DCI data source: base URL, sender | +| | ID, registry_type=SR | ++--------------------------------------+------------------------------------+ +| ``spp.data.provider`` 'openg2p_dr' | CEL-side provider linked to the | +| | DCI source | ++--------------------------------------+------------------------------------+ +| ``spp.cel.variable`` 'var_is_poor' | Semantic ``is_poor`` CEL accessor, | +| | bound to the OpenG2P SR provider | ++--------------------------------------+------------------------------------+ +| ``spp.cel.variable`` | Semantic | +| 'var_has_dependent_under_school_age' | ``has_dependent_under_school_age`` | +| | CEL accessor, bound to OpenG2P | ++--------------------------------------+------------------------------------+ +| ``OpenG2PDCIClient`` | DCIClient subclass for OpenG2P's | +| | expression query shape, namespaced | +| | URI type, hard-coded Individual | +| | reg_type, and required | +| | consent/authorize blocks | ++--------------------------------------+------------------------------------+ +| ``OpenG2PSocialService`` | SR-shaped lookup: partner | +| | identifier → OpenG2P record at | +| | ``data.reg_records[0]`` | ++--------------------------------------+------------------------------------+ + +Note: this preset does NOT override ``spp_studio.var_has_disability``. +Disability data lives in a separate OpenSPP-DR instance over its own DCI +link; the DR-side preset (``spp_dci_openspp_dr``) is responsible for +that binding (see ADR-024 for the federated topology). + +The CEL accessor names stay vendor-neutral (per ADR-023 §1a). The +OpenG2P-ness lives only in the data source, provider, and adapter — +never in the CEL surface. Repointing at a different SR is a +configuration change on the data source, not a CEL change. + +What this module does NOT ship +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +- OAuth2 credentials (admins configure these post-install via the data + source form — no secrets in source control) +- A demo program (operators create their own programs using the relevant + CEL accessors) +- Disability data lookups — disability lives in a separate OpenSPP-DR + instance over its own DCI link (see ADR-024) + +Architectural shape +~~~~~~~~~~~~~~~~~~~ + +``spp_dci_openg2p`` is a vendor preset on top of the bridge, not a DCI +client itself: + +:: + + spp_dci_openg2p (vendor preset — this module) + depends on + spp_cel_dci_bridge (registry-agnostic CEL <-> DCI infrastructure) + depends on + spp_dci_client (base DCI client) + +Other Social Registries would ship as separate sibling preset modules +(``spp_dci_``), reusing ``spp_cel_dci_bridge``. + +See Also +~~~~~~~~ + +- ADR-023 — overall design, why the bridge exists, registry-type vs + vendor-preset module distinction +- ADR-024 — federated demo topology and OpenG2P's SR role +- ``spp_cel_dci_bridge`` — the bridge infrastructure this preset + configures + +.. IMPORTANT:: + This is an alpha version, the data model and design can change at any time without warning. + Only for development or testing purpose, do not use in production. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +After installing this module +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The preset auto-creates a DCI data source, CEL provider, and CEL +variables wired against the OpenG2P playground. The playground does not +require authentication for the demo — the bridge can call it out of the +box. + +1. Navigate to **Custom > DCI > Configuration > Data Sources**. +2. Open the ``openg2p_dr`` data source (the xml id is kept for + upgrade-path stability; the record now represents an OpenG2P **Social + Registry**, see ADR-024). Rename **Code** to ``openg2p_sr`` if you + want runtime UI/audit consistency with the new SR role. +3. **Base URL** — the data XML ships + ``https://partner-registry.play.openg2p.org`` as a historical + default, but the current OpenG2P SR playground (verified 2026-05-15) + is **``https://partner-nsr.play.openg2p.org``**. Change the Base URL + manually. The ``noupdate=1`` on the data XML means module upgrades + cannot rewrite an existing value — operators must edit this through + the form. +4. The **Search Endpoint** is ``/dci/registry/sync/search`` (OpenG2P + uses the ``/dci`` prefix). +5. **Sender ID** / **Receiver ID** — placeholder values are + pre-populated. Replace with what the OpenG2P operator expects from + your deployment. +6. **Vendor Adapter** — set to ``OpenG2P``. The selection is defined + empty by ``spp_cel_dci_bridge``; this preset extends it via + ``selection_add``. The bridge dispatcher routes SR sources marked + with this vendor to ``OpenG2PSocialService`` instead of any default + SR handler. +7. Click **Test Connection**. State should flip to ``Active``. + +For real OpenG2P deployments (not the playground), change ``auth_type`` +to ``oauth2`` and populate ``oauth2_token_url``, ``oauth2_client_id``, +``oauth2_client_secret``. Attach a DCI Signing Key under **Custom > DCI +> Configuration > Signing Keys** if the deployment requires signed +messages. + +OpenG2P plays the Social Registry role +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +OpenG2P serves Social Registry data over DCI (poverty status, household +composition, related attributes). It is not the source of disability +data — that lives in a separate OpenSPP-DR instance (see ADR-024 for the +federated demo topology). + +This preset configures ``registry_type='SR'`` so the CEL bridge routes +through ``_handler_sr``, and ``vendor='openg2p'`` so the preset's +dispatcher override selects ``OpenG2PSocialService``. The service issues +an OpenG2P-canonical request: + +- ``query_type``: ``expression`` +- ``query.type``: ``ns:org:QueryType:expression`` +- ``query.value``: + ``{"expression": {"query": {"search_text": {"$eq": }}}}`` +- ``reg_type`` / ``reg_record_type``: both literal ``"Individual"`` +- ``consent`` and ``authorize`` blocks attached to every search criteria + (purpose code ``ELIGIBILITY_CHECK``) + +The bridge dispatcher applies each CEL variable's ``dci_attribute_path`` +to the raw OpenG2P record at ``data.reg_records[0]``. No vendor-specific +synthesis happens in the service layer — variables extract whatever +attribute they need by path. + +Demo data: which identifiers exist in the OpenG2P playground? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Ask the OpenG2P team for sample ``search_text`` values that exist in +their Social Registry. Configure your test partners with those +identifiers (under their **External Identifiers** / ``reg_ids``), and +the dispatcher's ``OpenG2PSocialService._get_partner_search_text`` +priority order will pick them up: + +:: + + UIN > DRN > NATIONAL_ID > NID > (first available) + +Partners with no matching identifier are recorded in +``spp.dci.fetch.audit`` as ``result='not_found'`` and excluded from CEL +evaluation. + +When OpenG2P's request shape converges on standard DCI +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The vendor-specific path is opt-in. If OpenG2P's published API ever +drops the namespaced URI query type, the nested ``search_text`` shape, +or the mandatory consent/authorize blocks and aligns with the upstream +DCI defaults, clear the ``vendor`` field on the data source. The +dispatcher's override falls through to the bridge's default +``_handler_sr`` (currently a not-implemented stub; the bridge will gain +a standard SR client when one ships). + +CEL variables and field mapping +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The OpenG2P SR record at ``data.reg_records[0]`` exposes the following +top-level fields (verified against ``partner-nsr.play.openg2p.org`` on +2026-05-15): + +:: + + member_identifier, demographic_info, related_person, self_id_disability, + is_disabled, disability_info, marital_status, employment_status, occupation, + income_level, language_code, education_level, additional_attributes, + registration_date, last_updated + +OpenG2P does not surface a top-level boolean ``is_poor`` — the closest +signal is ``income_level``, a categorical string (``"low"`` / +``"medium"`` / ``"high"``). The preset binds the semantic CEL variable +``is_poor`` to read ``income_level`` and surfaces it to CEL rules as a +string. Eligibility rules then express the poverty threshold via +comparison: + +:: + + is_poor == "low" + +(or whichever tier your policy treats as poor — ``"medium"``, an ``in`` +list, etc.). The variable name is intentionally kept as ``is_poor`` so +CEL rules read semantically; the underlying field is ``income_level``. + +Deferred features +~~~~~~~~~~~~~~~~~ + ++------------------------------------+----------------------+------------------------+ +| Variable | Reason | Path to revive | ++====================================+======================+========================+ +| ``has_dependent_under_school_age`` | OpenG2P's | (a) Ask the OpenG2P | +| | ``reg_records[0]`` | team to add a | +| | is per-individual | top-level boolean; or | +| | and does not embed | (b) issue a secondary | +| | household | household-search call | +| | composition or | against OpenG2P | +| | dependent birth | (different endpoint) | +| | dates. No top-level | and aggregate the | +| | field maps cleanly. | results. The CEL | +| | | variable record is | +| | | kept in inactive state | +| | | — flip it active + set | +| | | ``dci_attribute_path`` | +| | | to the new field name | +| | | once the data is | +| | | available. | ++------------------------------------+----------------------+------------------------+ + +The inactive variable stays registered in ``spp.cel.variable`` so any +CEL rules that still reference it gracefully evaluate to null (and fail +the comparison) instead of crashing the resolver. + +Cache TTL +~~~~~~~~~ + +The preset ships with ``cache_ttl_seconds = 300`` (5 minutes) on every +SR variable so the DCI round-trip is visible during demos. For +production, raise to 86400 (24h) or higher on each variable form +(**Custom > CEL > Variables**). + +Switching to a different SR vendor +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you target a non-OpenG2P Social Registry, the preset is the wrong +starting point — clone it as ``spp_dci_`` and adjust: + +- The data source's ``base_url`` and ``vendor`` field +- The service class (mirror ``OpenG2PSocialService`` for that vendor's + quirks) +- The dispatcher override's branch + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* OpenSPP.org + +Maintainers +----------- + +.. |maintainer-jeremi| image:: https://github.com/jeremi.png?size=40px + :target: https://github.com/jeremi + :alt: jeremi +.. |maintainer-gonzalesedwin1123| image:: https://github.com/gonzalesedwin1123.png?size=40px + :target: https://github.com/gonzalesedwin1123 + :alt: gonzalesedwin1123 + +Current maintainers: + +|maintainer-jeremi| |maintainer-gonzalesedwin1123| + +This module is part of the `OpenSPP/OpenSPP2 `_ project on GitHub. + +You are welcome to contribute. \ No newline at end of file diff --git a/spp_dci_openg2p/__init__.py b/spp_dci_openg2p/__init__.py new file mode 100644 index 00000000..790d999c --- /dev/null +++ b/spp_dci_openg2p/__init__.py @@ -0,0 +1,169 @@ +import logging + +from . import models +from . import services +from . import wizards + +_logger = logging.getLogger(__name__) + + +# Fields the preset insists on every install/upgrade. Anything else on the +# variable (labels, descriptions, category) is left to whoever last edited it. +_EXPECTED_BINDING_FIELDS = ( + "source_type", + "source_field", + "external_provider_id", + "dci_attribute_path", + "value_type", + "cache_strategy", + "cache_ttl_seconds", + "external_failure_policy", + "state", + "active", +) + + +# Per-variable bindings re-asserted on every install/upgrade. Each entry +# carries everything that varies between variables: +# +# xml_id - ir.model.data reference identifying the record +# attribute_path - dotted path applied to OpenG2P's reg_records[0] +# value_type - CEL value type; controls cache JSON typing and CEL +# SQL fast-path projection +# state - 'active' (live) or 'inactive' (skipped by precompute +# and resolver — used as a deferred-feature placeholder) +# +# ADD a row when introducing a new SR-sourced variable; the rest of the +# hook handles drift correction uniformly. +_PRESET_VARIABLES = ( + { + "xml_id": "spp_dci_openg2p.var_is_poor", + "attribute_path": "income_level", + "value_type": "string", + "state": "active", + }, + # has_dependent_under_school_age is parked inactive — OpenG2P's + # per-individual record does not embed household composition or + # dependent birth dates, so the variable cannot be resolved without + # a second OpenG2P endpoint call or schema extension. Kept here so + # the data XML and the hook stay in sync; revive when OpenG2P + # exposes the data. See CONFIGURE.md "Deferred features". + { + "xml_id": "spp_dci_openg2p.var_has_dependent_under_school_age", + "attribute_path": "has_dependent_under_school_age", + "value_type": "boolean", + "state": "inactive", + }, +) + + +def post_init_hook(env): + """Re-assert DCI bindings on every preset-owned CEL variable. + + Runs on every install AND upgrade of this module (Odoo invokes + post_init_hook on -i and -u). Detects drift on each SR variable + declared by this preset and rewrites the necessary fields so the + bridge dispatcher can route them to OpenG2P. + + Why this exists vs. just trusting the data XML: + + 1. The data XML uses noupdate="1", which Odoo honours by setting + noupdate=True on the ir.model.data entries. On subsequent + upgrades of THIS module, the XML is skipped — but operators + may have clobbered the bindings manually, or another module's + data load may have reset them. The hook is the one place that + always runs on -u and can restore drift. + + 2. spp.cel.variable records ship in DRAFT state by default. The + preset must explicitly activate them so they participate in + the cache pre-warm (active=True) and in the CEL resolver's + symbol lookup (state='active'). The XML data load doesn't + reliably push them through the state machine. + + 3. If the data XML failed to apply for any reason (load-order + issue, transient validation error during -i), the hook is the + safety net that catches it. + + The hook does NOT touch ``spp_studio.var_has_disability`` — that + binding is the responsibility of the DR-side preset + (``spp_dci_openspp_dr``, ADR-024). If an earlier version of this + preset bound ``has_disability`` to OpenG2P (FR-as-DR pretense), the + binding is left in place on upgrade for backwards compatibility; + operators can clear it manually or install the DR preset to override. + """ + provider = env.ref( + "spp_dci_openg2p.openg2p_dr_provider", + raise_if_not_found=False, + ) + if not provider: + _logger.error( + "spp_dci_openg2p.openg2p_dr_provider not found; cannot " + "re-assert SR variable bindings. Verify " + "data/openg2p_data_provider.xml loaded successfully." + ) + return + + for binding in _PRESET_VARIABLES: + xml_id = binding["xml_id"] + variable = env.ref(xml_id, raise_if_not_found=False) + if not variable: + _logger.warning( + "%s not found during post_init_hook; skipping DCI " + "binding re-assert. Verify data/openg2p_cel_variables.xml " + "loaded successfully.", + xml_id, + ) + continue + + is_active = binding["state"] == "active" + expected = { + "source_type": "external", + "source_field": False, + "external_provider_id": provider.id, + "dci_attribute_path": binding["attribute_path"], + "value_type": binding["value_type"], + "cache_strategy": "ttl", + "cache_ttl_seconds": 300, + "external_failure_policy": "null", + # state + active control whether the variable participates + # in the resolver / precompute pipeline: + # - state='active' is the workflow status used by spp_studio's + # lifecycle and CEL symbol visibility + # - active=True is the Odoo archived/unarchived flag used by + # precompute_cached_variables' search domain + # An "inactive" preset variable (e.g., a deferred-feature + # placeholder) is kept registered but excluded from both + # paths so the dispatcher never tries to fetch it. + "state": binding["state"], + "active": is_active, + } + + drift = {} + for field in _EXPECTED_BINDING_FIELDS: + current = variable[field] + # Many2one comparison: compare ids, not recordsets + if hasattr(current, "id"): + current_value = current.id if current else False + else: + current_value = current + if current_value != expected[field]: + drift[field] = expected[field] + + if drift: + # Bypass workflow validation by writing state directly. + # _pre_activate would reject draft -> active if source_type + # is 'field' and the field is missing; we're flipping + # source_type and state in the same write so that path + # doesn't apply. + variable.write(expected) + _logger.info( + "Re-asserted DCI binding on %s: %d field(s) restored (%s)", + xml_id, + len(drift), + ", ".join(drift.keys()), + ) + else: + _logger.info( + "%s DCI binding already correct; no changes.", + xml_id, + ) diff --git a/spp_dci_openg2p/__manifest__.py b/spp_dci_openg2p/__manifest__.py new file mode 100644 index 00000000..53a48e1d --- /dev/null +++ b/spp_dci_openg2p/__manifest__.py @@ -0,0 +1,30 @@ +# pylint: disable=pointless-statement +{ + "name": "OpenSPP DCI - OpenG2P Preset", + "summary": ("Pre-configured DCI data source, provider, and CEL variables for OpenG2P deployments"), + "version": "19.0.1.0.0", + "category": "OpenSPP/Integration", + "author": "OpenSPP.org", + "website": "https://github.com/OpenSPP/OpenSPP2", + "license": "LGPL-3", + "development_status": "Alpha", + "maintainers": ["jeremi", "gonzalesedwin1123"], + "depends": [ + "spp_cel_dci_bridge", + "spp_vocabulary", + "spp_registry", + ], + "external_dependencies": {"python": []}, + "data": [ + "security/ir.model.access.csv", + "data/openg2p_id_types.xml", + "data/openg2p_data_source.xml", + "data/openg2p_data_provider.xml", + "data/openg2p_cel_variables.xml", + "views/sr_import_wizard_views.xml", + ], + "installable": True, + "application": False, + "auto_install": False, + "post_init_hook": "post_init_hook", +} diff --git a/spp_dci_openg2p/data/openg2p_cel_variables.xml b/spp_dci_openg2p/data/openg2p_cel_variables.xml new file mode 100644 index 00000000..8f3a2d59 --- /dev/null +++ b/spp_dci_openg2p/data/openg2p_cel_variables.xml @@ -0,0 +1,109 @@ + + + + + is_poor + Is Poor (Social Registry income_level) + Poverty proxy sourced from the Social Registry's `income_level` field. CEL rules match `is_poor == "low"` (or other income tiers). The variable surfaces the raw `income_level` string, not a boolean — the eligibility rule decides which level counts as poor. See ADR-024 for the federated topology and CONFIGURE.md for the field-mapping rationale. + + string + external + res.partner + is_poor + individual + + income_level + ttl + 300 + null + active + + True + + + has_dependent_under_school_age + Has Dependent Under School Age (DEFERRED — Social Registry does not expose) + Deferred: the Social Registry's `reg_records[0]` does not include household composition or dependent birth dates, so this variable cannot be sourced today. Kept as an inactive placeholder for the demo rule's eventual revival. See CONFIGURE.md "Deferred features" for the path forward. + + boolean + external + res.partner + has_dependent_under_school_age + individual + + has_dependent_under_school_age + ttl + 300 + null + inactive + + True + + diff --git a/spp_dci_openg2p/data/openg2p_data_provider.xml b/spp_dci_openg2p/data/openg2p_data_provider.xml new file mode 100644 index 00000000..efb86186 --- /dev/null +++ b/spp_dci_openg2p/data/openg2p_data_provider.xml @@ -0,0 +1,17 @@ + + + + + Social Registry + openg2p_dr + + + 86400 + + diff --git a/spp_dci_openg2p/data/openg2p_data_source.xml b/spp_dci_openg2p/data/openg2p_data_source.xml new file mode 100644 index 00000000..968c3e77 --- /dev/null +++ b/spp_dci_openg2p/data/openg2p_data_source.xml @@ -0,0 +1,50 @@ + + + + + Social Registry + openg2p_dr + ns:org:RegistryType:Social + openg2p + https://partner-registry.play.openg2p.org + /dci/registry/sync/search + + none + openspp.test + openg2p.test + + 30 + + draft + Social Registry DCI source preset. Routed through the bridge's _handler_sr by registry_type=SR; the vendor-specific service (services/openg2p_social_service.py) is selected by vendor=openg2p. See ADR-024 for the federated demo topology. + + diff --git a/spp_dci_openg2p/data/openg2p_id_types.xml b/spp_dci_openg2p/data/openg2p_id_types.xml new file mode 100644 index 00000000..8b3ae6ee --- /dev/null +++ b/spp_dci_openg2p/data/openg2p_id_types.xml @@ -0,0 +1,31 @@ + + + + + + UIN + UIN (Universal Identification Number) + individual + Universal Identification Number — the canonical SPDCI identifier sent to the Social Registry's DCI search endpoint as the `id_type` field. + 20 + + diff --git a/spp_dci_openg2p/models/__init__.py b/spp_dci_openg2p/models/__init__.py new file mode 100644 index 00000000..9176489f --- /dev/null +++ b/spp_dci_openg2p/models/__init__.py @@ -0,0 +1,2 @@ +from . import dci_data_source +from . import dci_dispatcher diff --git a/spp_dci_openg2p/models/dci_data_source.py b/spp_dci_openg2p/models/dci_data_source.py new file mode 100644 index 00000000..2397cd63 --- /dev/null +++ b/spp_dci_openg2p/models/dci_data_source.py @@ -0,0 +1,18 @@ +from odoo import fields, models + + +class DCIDataSource(models.Model): + """Register the OpenG2P vendor adapter on the shared vendor selection. + + The ``vendor`` field is defined by ``spp_cel_dci_bridge``; this + preset only adds its own selection value. Once set on a data source, + the bridge dispatcher delegates to the OpenG2P-specific service for + that source's registry-type handler. + """ + + _inherit = "spp.dci.data.source" + + vendor = fields.Selection( + selection_add=[("openg2p", "OpenG2P")], + ondelete={"openg2p": "set null"}, + ) diff --git a/spp_dci_openg2p/models/dci_dispatcher.py b/spp_dci_openg2p/models/dci_dispatcher.py new file mode 100644 index 00000000..1f5476b5 --- /dev/null +++ b/spp_dci_openg2p/models/dci_dispatcher.py @@ -0,0 +1,88 @@ +"""Bridge dispatcher override for OpenG2P-vendor sources. + +When a CEL variable's DCI data source has ``vendor='openg2p'``, route the +SR handler to ``OpenG2PSocialService`` instead of failing through the +bridge's not-implemented stub. The handler is otherwise structurally +identical to the bridge's other registry-type handlers: same per-subject +loop, same audit row shape, same attribute-path extraction. + +This is the Option C "adapter code" path from ADR-023 §6, retargeted by +ADR-024 — OpenG2P now plays its proper Social Registry role rather than +the FR-as-DR pretense it briefly held during v1 demo prep. +""" + +import logging +import time + +from odoo import models + +from odoo.addons.spp_cel_dci_bridge.exceptions import DCIConfigurationError + +_logger = logging.getLogger(__name__) + + +class DCIDispatcher(models.AbstractModel): + _inherit = "spp.cel.dci.dispatcher" + + def _handler_sr(self, variable, source, subject_ids, period_key): + # Route OpenG2P sources to the vendor-specific service. Sources + # without a vendor (or with a different vendor) fall through to + # the bridge's not-implemented stub, which raises + # DCIConfigurationError — preserving the silent-failure guard. + if getattr(source, "vendor", False) == "openg2p": + return self._handler_openg2p_sr(variable, source, subject_ids, period_key) + return super()._handler_sr(variable, source, subject_ids, period_key) + + def _handler_openg2p_sr(self, variable, source, subject_ids, period_key): + """SR handler backed by OpenG2PSocialService. + + Structurally identical to the bridge's other handlers (_handler_dr, + _handler_crvs, _handler_ibr): per-subject loop, audit row per + subject, attribute extraction via variable.dci_attribute_path, + error swallow with audit row capture. + """ + try: + from ..services.openg2p_social_service import OpenG2PSocialService + except ImportError as e: + # Should never happen — this module's __init__ imports the + # service — but raise a clear error rather than silently + # returning {} (would trigger ADR-023 Critical #2's silent + # failure mode). + raise DCIConfigurationError( + f"OpenG2P Social service is not importable; cannot fetch " + f"variable {variable.name}. Reinstall spp_dci_openg2p." + ) from e + + service = OpenG2PSocialService(self.env, data_source_code=source.code) + Partner = self.env["res.partner"] + partners = Partner.browse(subject_ids).exists() + path = variable.dci_attribute_path + + result = {} + for partner in partners: + started = time.monotonic() + try: + payload = service.get_partner_record(partner) + except Exception as e: + self._record_audit(variable, source, partner.id, "error", started, error_message=str(e)) + _logger.warning( + "OpenG2P SR fetch failed for partner %d (var=%s): %s", + partner.id, + variable.name, + e, + ) + continue + + if payload is None: + self._record_audit(variable, source, partner.id, "not_found", started) + continue + + value = self._extract_by_path(payload, path) + if value is None: + self._record_audit(variable, source, partner.id, "not_found", started) + continue + + result[partner.id] = value + self._record_audit(variable, source, partner.id, "ok", started) + + return result diff --git a/spp_dci_openg2p/pyproject.toml b/spp_dci_openg2p/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/spp_dci_openg2p/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/spp_dci_openg2p/readme/CONFIGURE.md b/spp_dci_openg2p/readme/CONFIGURE.md new file mode 100644 index 00000000..5ee9bc38 --- /dev/null +++ b/spp_dci_openg2p/readme/CONFIGURE.md @@ -0,0 +1,80 @@ +### After installing this module + +The preset auto-creates a DCI data source, CEL provider, and CEL variables wired against the OpenG2P playground. The playground does not require authentication for the demo — the bridge can call it out of the box. + +1. Navigate to **Custom > DCI > Configuration > Data Sources**. +2. Open the `openg2p_dr` data source (the xml id is kept for upgrade-path stability; the record now represents an OpenG2P **Social Registry**, see ADR-024). Rename **Code** to `openg2p_sr` if you want runtime UI/audit consistency with the new SR role. +3. **Base URL** — the data XML ships `https://partner-registry.play.openg2p.org` as a historical default, but the current OpenG2P SR playground (verified 2026-05-15) is **`https://partner-nsr.play.openg2p.org`**. Change the Base URL manually. The `noupdate=1` on the data XML means module upgrades cannot rewrite an existing value — operators must edit this through the form. +4. The **Search Endpoint** is `/dci/registry/sync/search` (OpenG2P uses the `/dci` prefix). +5. **Sender ID** / **Receiver ID** — placeholder values are pre-populated. Replace with what the OpenG2P operator expects from your deployment. +6. **Vendor Adapter** — set to `OpenG2P`. The selection is defined empty by `spp_cel_dci_bridge`; this preset extends it via `selection_add`. The bridge dispatcher routes SR sources marked with this vendor to `OpenG2PSocialService` instead of any default SR handler. +7. Click **Test Connection**. State should flip to `Active`. + +For real OpenG2P deployments (not the playground), change `auth_type` to `oauth2` and populate `oauth2_token_url`, `oauth2_client_id`, `oauth2_client_secret`. Attach a DCI Signing Key under **Custom > DCI > Configuration > Signing Keys** if the deployment requires signed messages. + +### OpenG2P plays the Social Registry role + +OpenG2P serves Social Registry data over DCI (poverty status, household composition, related attributes). It is not the source of disability data — that lives in a separate OpenSPP-DR instance (see ADR-024 for the federated demo topology). + +This preset configures `registry_type='SR'` so the CEL bridge routes through `_handler_sr`, and `vendor='openg2p'` so the preset's dispatcher override selects `OpenG2PSocialService`. The service issues an OpenG2P-canonical request: + +- `query_type`: `expression` +- `query.type`: `ns:org:QueryType:expression` +- `query.value`: `{"expression": {"query": {"search_text": {"$eq": }}}}` +- `reg_type` / `reg_record_type`: both literal `"Individual"` +- `consent` and `authorize` blocks attached to every search criteria (purpose code `ELIGIBILITY_CHECK`) + +The bridge dispatcher applies each CEL variable's `dci_attribute_path` to the raw OpenG2P record at `data.reg_records[0]`. No vendor-specific synthesis happens in the service layer — variables extract whatever attribute they need by path. + +### Demo data: which identifiers exist in the OpenG2P playground? + +Ask the OpenG2P team for sample `search_text` values that exist in their Social Registry. Configure your test partners with those identifiers (under their **External Identifiers** / `reg_ids`), and the dispatcher's `OpenG2PSocialService._get_partner_search_text` priority order will pick them up: + +``` +UIN > DRN > NATIONAL_ID > NID > (first available) +``` + +Partners with no matching identifier are recorded in `spp.dci.fetch.audit` as `result='not_found'` and excluded from CEL evaluation. + +### When OpenG2P's request shape converges on standard DCI + +The vendor-specific path is opt-in. If OpenG2P's published API ever drops the namespaced URI query type, the nested `search_text` shape, or the mandatory consent/authorize blocks and aligns with the upstream DCI defaults, clear the `vendor` field on the data source. The dispatcher's override falls through to the bridge's default `_handler_sr` (currently a not-implemented stub; the bridge will gain a standard SR client when one ships). + +### CEL variables and field mapping + +The OpenG2P SR record at `data.reg_records[0]` exposes the following top-level fields (verified against `partner-nsr.play.openg2p.org` on 2026-05-15): + +``` +member_identifier, demographic_info, related_person, self_id_disability, +is_disabled, disability_info, marital_status, employment_status, occupation, +income_level, language_code, education_level, additional_attributes, +registration_date, last_updated +``` + +OpenG2P does not surface a top-level boolean `is_poor` — the closest signal is `income_level`, a categorical string (`"low"` / `"medium"` / `"high"`). The preset binds the semantic CEL variable `is_poor` to read `income_level` and surfaces it to CEL rules as a string. Eligibility rules then express the poverty threshold via comparison: + +``` +is_poor == "low" +``` + +(or whichever tier your policy treats as poor — `"medium"`, an `in` list, etc.). The variable name is intentionally kept as `is_poor` so CEL rules read semantically; the underlying field is `income_level`. + +### Deferred features + +| Variable | Reason | Path to revive | +|---|---|---| +| `has_dependent_under_school_age` | OpenG2P's `reg_records[0]` is per-individual and does not embed household composition or dependent birth dates. No top-level field maps cleanly. | (a) Ask the OpenG2P team to add a top-level boolean; or (b) issue a secondary household-search call against OpenG2P (different endpoint) and aggregate the results. The CEL variable record is kept in inactive state — flip it active + set `dci_attribute_path` to the new field name once the data is available. | + +The inactive variable stays registered in `spp.cel.variable` so any CEL rules that still reference it gracefully evaluate to null (and fail the comparison) instead of crashing the resolver. + +### Cache TTL + +The preset ships with `cache_ttl_seconds = 300` (5 minutes) on every SR variable so the DCI round-trip is visible during demos. For production, raise to 86400 (24h) or higher on each variable form (**Custom > CEL > Variables**). + +### Switching to a different SR vendor + +If you target a non-OpenG2P Social Registry, the preset is the wrong starting point — clone it as `spp_dci_` and adjust: + +- The data source's `base_url` and `vendor` field +- The service class (mirror `OpenG2PSocialService` for that vendor's quirks) +- The dispatcher override's branch diff --git a/spp_dci_openg2p/readme/DESCRIPTION.md b/spp_dci_openg2p/readme/DESCRIPTION.md new file mode 100644 index 00000000..14126644 --- /dev/null +++ b/spp_dci_openg2p/readme/DESCRIPTION.md @@ -0,0 +1,42 @@ +Permanent OpenG2P preset for the CEL <-> DCI bridge. Ships pre-configured `spp.dci.data.source`, `spp.data.provider`, and `spp.cel.variable` records so a deployment targeting an OpenG2P-backed DCI Social Registry gets the wiring out of the box. Config plus a small vendor adapter that absorbs OpenG2P's request-shape quirks (see ADR-024). + +### What this module ships + +| Record | Purpose | +| ----------------------------------------------------- | ------------------------------------------------------------------------ | +| `spp.dci.data.source` 'openg2p_dr' | DCI data source: base URL, sender ID, registry_type=SR | +| `spp.data.provider` 'openg2p_dr' | CEL-side provider linked to the DCI source | +| `spp.cel.variable` 'var_is_poor' | Semantic `is_poor` CEL accessor, bound to the OpenG2P SR provider | +| `spp.cel.variable` 'var_has_dependent_under_school_age' | Semantic `has_dependent_under_school_age` CEL accessor, bound to OpenG2P | +| `OpenG2PDCIClient` | DCIClient subclass for OpenG2P's expression query shape, namespaced URI type, hard-coded Individual reg_type, and required consent/authorize blocks | +| `OpenG2PSocialService` | SR-shaped lookup: partner identifier → OpenG2P record at `data.reg_records[0]` | + +Note: this preset does NOT override `spp_studio.var_has_disability`. Disability data lives in a separate OpenSPP-DR instance over its own DCI link; the DR-side preset (`spp_dci_openspp_dr`) is responsible for that binding (see ADR-024 for the federated topology). + +The CEL accessor names stay vendor-neutral (per ADR-023 §1a). The OpenG2P-ness lives only in the data source, provider, and adapter — never in the CEL surface. Repointing at a different SR is a configuration change on the data source, not a CEL change. + +### What this module does NOT ship + +- OAuth2 credentials (admins configure these post-install via the data source form — no secrets in source control) +- A demo program (operators create their own programs using the relevant CEL accessors) +- Disability data lookups — disability lives in a separate OpenSPP-DR instance over its own DCI link (see ADR-024) + +### Architectural shape + +`spp_dci_openg2p` is a vendor preset on top of the bridge, not a DCI client itself: + +``` +spp_dci_openg2p (vendor preset — this module) + depends on +spp_cel_dci_bridge (registry-agnostic CEL <-> DCI infrastructure) + depends on +spp_dci_client (base DCI client) +``` + +Other Social Registries would ship as separate sibling preset modules (`spp_dci_`), reusing `spp_cel_dci_bridge`. + +### See Also + +- ADR-023 — overall design, why the bridge exists, registry-type vs vendor-preset module distinction +- ADR-024 — federated demo topology and OpenG2P's SR role +- `spp_cel_dci_bridge` — the bridge infrastructure this preset configures diff --git a/spp_dci_openg2p/security/ir.model.access.csv b/spp_dci_openg2p/security/ir.model.access.csv new file mode 100644 index 00000000..20440bd9 --- /dev/null +++ b/spp_dci_openg2p/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_spp_dci_sr_import_wizard_user,spp.dci.sr.import.wizard user,model_spp_dci_sr_import_wizard,base.group_user,1,1,1,1 +access_spp_dci_sr_import_wizard_line_user,spp.dci.sr.import.wizard.line user,model_spp_dci_sr_import_wizard_line,base.group_user,1,1,1,1 diff --git a/spp_dci_openg2p/services/__init__.py b/spp_dci_openg2p/services/__init__.py new file mode 100644 index 00000000..7197cfe3 --- /dev/null +++ b/spp_dci_openg2p/services/__init__.py @@ -0,0 +1,2 @@ +from . import openg2p_dci_client +from . import openg2p_social_service diff --git a/spp_dci_openg2p/services/openg2p_dci_client.py b/spp_dci_openg2p/services/openg2p_dci_client.py new file mode 100644 index 00000000..3d6b8379 --- /dev/null +++ b/spp_dci_openg2p/services/openg2p_dci_client.py @@ -0,0 +1,179 @@ +"""OpenG2P-aware DCIClient subclass. + +OpenG2P's canonical request envelope (per the sample payload provided +by the OpenG2P team on 2026-05-14) differs from upstream's defaults in +five places. This subclass absorbs all five so callers can issue +``client.search(query_type=QueryType.EXPRESSION, query_value=, ...)`` +and produce an OpenG2P-acceptable request without further adapter work. + +The five deltas: + +1. ``query_type`` is ``"expression"`` (not ``"idtype-value"``). + Upstream supports both; we make this subclass's preferred path + expression. + +2. ``query.type`` is the namespaced URI ``"ns:org:QueryType:expression"`` + (not the short form ``"expression"``). + +3. ``query.value`` is the nested expression shape:: + + {"expression": {"query": {"search_text": {"$eq": ""}}}} + + The partner identifier we want to look up is the ``$eq`` value. + +4. ``reg_type`` and ``reg_record_type`` are both the literal string + ``"Individual"`` (not the namespaced URIs we'd previously guessed, + like ``ns:org:RegistryType:Social`` or + ``spdci-extensions-dci:Farmer``). Upstream's ``SearchCriteria`` + Pydantic model also doesn't carry ``reg_record_type``, so we inject + it post-build. + +5. ``consent`` and ``authorize`` blocks are required on every + ``search_criteria``. We hard-code sensible defaults (purpose code + ``ELIGIBILITY_CHECK``) — production deployments can override via + a future ``spp.dci.data.source.consent_purpose_code`` field + (planned, see ADR-024 §6.2). + +Everything else (header, signing, OAuth2, retries, async, transport) +reuses upstream ``DCIClient`` unchanged. +""" + +import logging +from datetime import UTC, datetime + +from odoo.addons.spp_dci.schemas import QueryType +from odoo.addons.spp_dci_client.services.client import DCIClient + +_logger = logging.getLogger(__name__) + +# OpenG2P's record-type discriminator. Both reg_type and reg_record_type +# are literally "Individual" — verified against the OpenG2P-provided sample. +DEFAULT_OPENG2P_REG_TYPE = "Individual" +DEFAULT_OPENG2P_REG_RECORD_TYPE = "Individual" + +# Namespaced URI form of the query type used inside the query payload. +# search_criteria.query_type stays as the short form per the DCI envelope. +OPENG2P_QUERY_TYPE_URI = "ns:org:QueryType:expression" + +# Consent + authorize defaults. Production deployments override per source. +DEFAULT_CONSENT_PURPOSE = { + "text": "Eligibility verification for social-protection program", + "code": "ELIGIBILITY_CHECK", + "ref_uri": "https://docs.openspp.org/consent/eligibility-check", +} +DEFAULT_CONSENT_CONTEXT = "https://schema.spdci.org/common/v1/api-schemas/Consent.jsonld" +DEFAULT_AUTHORIZE_CONTEXT = "https://schema.spdci.org/common/v1/api-schemas/Authorize.jsonld" + + +class OpenG2PDCIClient(DCIClient): + """DCIClient that emits OpenG2P-compatible search payloads.""" + + def __init__(self, data_source, env, reg_type=None, reg_record_type=None): + super().__init__(data_source, env) + self._reg_type = reg_type or DEFAULT_OPENG2P_REG_TYPE + self._reg_record_type = reg_record_type or DEFAULT_OPENG2P_REG_RECORD_TYPE + + # ------------------------------------------------------------------ + # Query shape: expression with nested search_text + # ------------------------------------------------------------------ + + def _parse_query(self, query_type, query_value): + """Build OpenG2P's canonical query.value for expression queries. + + For QueryType.EXPRESSION, ``query_value`` is the search_text to + match (typically a partner identifier like ``IND-NSR-0001``). + Returns the full DciQuery object — ``search_criteria.query`` gets + this dict directly with no further wrapping. + + Other query types fall through to upstream behaviour. Idtype-value + is no longer overridden here; if a caller really wants it, they + get upstream's flat-shape format (which OpenG2P rejects). + """ + if query_type == QueryType.EXPRESSION: + return { + "type": OPENG2P_QUERY_TYPE_URI, + "value": { + "expression": { + "query": { + "search_text": {"$eq": query_value}, + }, + }, + }, + } + return super()._parse_query(query_type, query_value) + + # ------------------------------------------------------------------ + # Envelope shaping: force reg_type, inject reg_record_type, + # attach consent + authorize blocks, re-sign. + # ------------------------------------------------------------------ + + def _build_search_envelope( + self, + query_type, + query, + registry_type, + registry_event_type, + record_type, + page, + page_size, + callback_url=None, + ): + envelope = super()._build_search_envelope( + query_type=query_type, + query=query, + # Always force OpenG2P's reg_type (literal "Individual") even + # if the caller passed something else. The data source's + # configured registry_type is a routing concept; OpenG2P's + # reg_type is a wire-format concept. + registry_type=self._reg_type, + registry_event_type=registry_event_type, + record_type=record_type, + page=page, + page_size=page_size, + callback_url=callback_url, + ) + message = envelope.get("message") or {} + now_iso = datetime.now(UTC).isoformat().replace("+00:00", "Z") + consent_block = self._build_consent_block(now_iso) + authorize_block = self._build_authorize_block(now_iso) + for item in message.get("search_request") or []: + criteria = item.get("search_criteria") + if isinstance(criteria, dict): + # reg_record_type is required by OpenG2P but absent from + # upstream's SearchCriteria Pydantic model — inject. + criteria["reg_record_type"] = self._reg_record_type + # consent + authorize are required on every criteria. + # Insert only if upstream didn't already populate them + # (so a future feature can pass them through unchanged). + criteria.setdefault("consent", consent_block) + criteria.setdefault("authorize", authorize_block) + # Re-sign with the modified message so the signature is consistent + # with what we actually send over the wire. + return self._sign_request(envelope["header"], message) + + # ------------------------------------------------------------------ + # Consent + authorize block construction + # ------------------------------------------------------------------ + + def _build_consent_block(self, timestamp_iso): + """Return a JSON-LD consent block matching OpenG2P's expected shape. + + Hard-coded defaults for v1. Future enhancement: read consent + purpose from a configurable field on ``spp.dci.data.source`` + (planned, ADR-024 §6.2). + """ + return { + "@context": DEFAULT_CONSENT_CONTEXT, + "@type": "Consent", + "ts": timestamp_iso, + "purpose": dict(DEFAULT_CONSENT_PURPOSE), + } + + def _build_authorize_block(self, timestamp_iso): + """Return a JSON-LD authorize block matching OpenG2P's expected shape.""" + return { + "@context": DEFAULT_AUTHORIZE_CONTEXT, + "@type": "Authorize", + "ts": timestamp_iso, + "purpose": dict(DEFAULT_CONSENT_PURPOSE), + } diff --git a/spp_dci_openg2p/services/openg2p_social_service.py b/spp_dci_openg2p/services/openg2p_social_service.py new file mode 100644 index 00000000..fb430741 --- /dev/null +++ b/spp_dci_openg2p/services/openg2p_social_service.py @@ -0,0 +1,164 @@ +"""OpenG2P Social Registry service. + +Queries OpenG2P's DCI Social Registry endpoint by `search_text` (typically +the partner's reg_id value, e.g., ``IND-NSR-0001``) and returns the raw +record dict from ``data.reg_records[0]``. The bridge dispatcher applies +the variable's ``dci_attribute_path`` to that dict — so each variable +extracts whatever field it needs (``is_poor``, ``has_dependent_under_school_age``, +etc.) without this service needing to know which. + +This service replaces the earlier ``OpenG2PFRService`` (the FR-as-DR +pretense). The pretense was retired by ADR-024 once a separate +OpenSPP-DR instance became available — OpenG2P returns to its proper +role as the Social Registry. + +Request shape (per the OpenG2P-provided sample, see ADR-024 §"Findings +from the OpenG2P-provided payload"): + + - query_type: "expression" + - query.type: "ns:org:QueryType:expression" (set by OpenG2PDCIClient) + - query.value: {"expression": {"query": {"search_text": {"$eq": }}}} + - reg_type: "Individual" + - reg_record_type: "Individual" + - consent + authorize blocks attached by OpenG2PDCIClient + +Response unwrap: + + response.message.search_response[0].data.reg_records[0] +""" + +import logging + +from odoo.exceptions import UserError, ValidationError + +from odoo.addons.spp_dci.schemas import QueryType + +from .openg2p_dci_client import OpenG2PDCIClient + +_logger = logging.getLogger(__name__) + +# Identifier priority for resolving the partner's search_text. The first +# matching reg_id type with a non-empty value wins. Priority is preserved +# from the previous FR service so existing test partners with UIN reg_ids +# continue to work; OpenG2P's typical id_type for SR records is plain +# "UIN" or a national-registry-prefixed value (e.g., IND-NSR-XXXX). +IDENTIFIER_PRIORITY = ("UIN", "DRN", "NATIONAL_ID", "NID") + + +class OpenG2PSocialService: + """Service for querying OpenG2P as a Social Registry. + + Mirrors the surface the bridge dispatcher needs: ``__init__(env, + data_source_code)`` and ``get_partner_record(partner)``. The + dispatcher applies ``variable.dci_attribute_path`` to the returned + record, so this service stays generic — no variable-specific + extraction logic. + """ + + def __init__(self, env, data_source_code): + self.env = env + self.data_source_code = data_source_code + self.data_source = env["spp.dci.data.source"].get_by_code(data_source_code) + # OpenG2PDCIClient defaults: reg_type="Individual", reg_record_type="Individual". + self.client = OpenG2PDCIClient(self.data_source, env) + + # ------------------------------------------------------------------ + # Public API — surface called by the bridge dispatcher + # ------------------------------------------------------------------ + + def get_partner_record(self, partner) -> dict | None: + """Look up ``partner`` in OpenG2P and return the first matching record. + + Returns: + dict: The raw OpenG2P record from ``data.reg_records[0]`` if + a match was found. + None: if the partner has no resolvable identifier OR OpenG2P + returned no record (REG-ERR-001 / empty ``search_response``). + + Raises: + UserError: If the request fails for non-not-found reasons + (network error, server 5xx, malformed envelope). The + dispatcher loop catches these per-subject and records + them as audit ``result=error`` rows. + """ + if not partner: + raise ValidationError(self.env._("Partner is required")) + + search_text = self._get_partner_search_text(partner) + if not search_text: + _logger.warning( + "No suitable identifier found for partner ID=%s — skipping OpenG2P query", + partner.id, + ) + return None + + _logger.info( + "Querying OpenG2P SR for partner ID=%s using search_text=%s", + partner.id, + search_text, + ) + + try: + response = self.client.search( + query_type=QueryType.EXPRESSION, + query_value=search_text, + # registry_type / record_type are ignored by OpenG2PDCIClient + # (which always forces "Individual") but we pass something + # plausible for upstream's logging. + registry_type="Individual", + record_type="Individual", + page=1, + page_size=1, + ) + except Exception as e: + _logger.error("OpenG2P SR fetch failed: %s", e, exc_info=True) + raise UserError(self.env._("Failed to query OpenG2P: %s", e)) from e + + return self._extract_first_record(response) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _get_partner_search_text(self, partner): + """Return the search_text value for ``partner`` — the value of + the partner's highest-priority matching reg_id. + + The priority list matches DRService's so swapping between SR and + DR sources doesn't change which identifier is sent first. + """ + reg_ids = self.env["spp.registry.id"].search([("partner_id", "=", partner.id)]) + for id_type in IDENTIFIER_PRIORITY: + for reg_id in reg_ids: + if reg_id.id_type_id.code == id_type and reg_id.value: + return reg_id.value + if reg_ids: + first_id = reg_ids[0] + if first_id.value: + return first_id.value + return None + + @staticmethod + def _extract_first_record(response): + """Unwrap OpenG2P's response envelope to the first registry record. + + OpenG2P returns: + + response.message.search_response[i].data.reg_records[j] + + Returns the first matching record across the response, or None + if no records were found (REG-ERR-001 / empty search_response). + """ + if not isinstance(response, dict): + return None + message = response.get("message") or {} + search_responses = message.get("search_response") or [] + for sr in search_responses: + data = sr.get("data") or {} + if not isinstance(data, dict): + continue + reg_records = data.get("reg_records") or [] + for record in reg_records: + if isinstance(record, dict): + return record + return None diff --git a/spp_dci_openg2p/static/description/index.html b/spp_dci_openg2p/static/description/index.html new file mode 100644 index 00000000..2377b404 --- /dev/null +++ b/spp_dci_openg2p/static/description/index.html @@ -0,0 +1,683 @@ + + + + + +OpenSPP DCI - OpenG2P Preset + + + +
+

OpenSPP DCI - OpenG2P Preset

+ + +

Alpha License: LGPL-3 OpenSPP/OpenSPP2

+

Permanent OpenG2P preset for the CEL <-> DCI bridge. Ships +pre-configured spp.dci.data.source, spp.data.provider, and +spp.cel.variable records so a deployment targeting an OpenG2P-backed +DCI Social Registry gets the wiring out of the box. Config plus a small +vendor adapter that absorbs OpenG2P’s request-shape quirks (see +ADR-024).

+
+

What this module ships

+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + + +
RecordPurpose
spp.dci.data.source ‘openg2p_dr’DCI data source: base URL, sender +ID, registry_type=SR
spp.data.provider ‘openg2p_dr’CEL-side provider linked to the +DCI source
spp.cel.variable ‘var_is_poor’Semantic is_poor CEL accessor, +bound to the OpenG2P SR provider
spp.cel.variable +‘var_has_dependent_under_school_age’Semantic +has_dependent_under_school_age +CEL accessor, bound to OpenG2P
OpenG2PDCIClientDCIClient subclass for OpenG2P’s +expression query shape, namespaced +URI type, hard-coded Individual +reg_type, and required +consent/authorize blocks
OpenG2PSocialServiceSR-shaped lookup: partner +identifier → OpenG2P record at +data.reg_records[0]
+

Note: this preset does NOT override spp_studio.var_has_disability. +Disability data lives in a separate OpenSPP-DR instance over its own DCI +link; the DR-side preset (spp_dci_openspp_dr) is responsible for +that binding (see ADR-024 for the federated topology).

+

The CEL accessor names stay vendor-neutral (per ADR-023 §1a). The +OpenG2P-ness lives only in the data source, provider, and adapter — +never in the CEL surface. Repointing at a different SR is a +configuration change on the data source, not a CEL change.

+
+
+

What this module does NOT ship

+
    +
  • OAuth2 credentials (admins configure these post-install via the data +source form — no secrets in source control)
  • +
  • A demo program (operators create their own programs using the relevant +CEL accessors)
  • +
  • Disability data lookups — disability lives in a separate OpenSPP-DR +instance over its own DCI link (see ADR-024)
  • +
+
+
+

Architectural shape

+

spp_dci_openg2p is a vendor preset on top of the bridge, not a DCI +client itself:

+
+spp_dci_openg2p        (vendor preset — this module)
+    depends on
+spp_cel_dci_bridge     (registry-agnostic CEL <-> DCI infrastructure)
+    depends on
+spp_dci_client         (base DCI client)
+
+

Other Social Registries would ship as separate sibling preset modules +(spp_dci_<vendor>), reusing spp_cel_dci_bridge.

+
+
+

See Also

+
    +
  • ADR-023 — overall design, why the bridge exists, registry-type vs +vendor-preset module distinction
  • +
  • ADR-024 — federated demo topology and OpenG2P’s SR role
  • +
  • spp_cel_dci_bridge — the bridge infrastructure this preset +configures
  • +
+
+

Important

+

This is an alpha version, the data model and design can change at any time without warning. +Only for development or testing purpose, do not use in production.

+
+

Table of contents

+ + +
+
+

After installing this module

+

The preset auto-creates a DCI data source, CEL provider, and CEL +variables wired against the OpenG2P playground. The playground does not +require authentication for the demo — the bridge can call it out of the +box.

+
    +
  1. Navigate to Custom > DCI > Configuration > Data Sources.
  2. +
  3. Open the openg2p_dr data source (the xml id is kept for +upgrade-path stability; the record now represents an OpenG2P Social +Registry, see ADR-024). Rename Code to openg2p_sr if you +want runtime UI/audit consistency with the new SR role.
  4. +
  5. Base URL — the data XML ships +https://partner-registry.play.openg2p.org as a historical +default, but the current OpenG2P SR playground (verified 2026-05-15) +is ``https://partner-nsr.play.openg2p.org``. Change the Base URL +manually. The noupdate=1 on the data XML means module upgrades +cannot rewrite an existing value — operators must edit this through +the form.
  6. +
  7. The Search Endpoint is /dci/registry/sync/search (OpenG2P +uses the /dci prefix).
  8. +
  9. Sender ID / Receiver ID — placeholder values are +pre-populated. Replace with what the OpenG2P operator expects from +your deployment.
  10. +
  11. Vendor Adapter — set to OpenG2P. The selection is defined +empty by spp_cel_dci_bridge; this preset extends it via +selection_add. The bridge dispatcher routes SR sources marked +with this vendor to OpenG2PSocialService instead of any default +SR handler.
  12. +
  13. Click Test Connection. State should flip to Active.
  14. +
+

For real OpenG2P deployments (not the playground), change auth_type +to oauth2 and populate oauth2_token_url, oauth2_client_id, +oauth2_client_secret. Attach a DCI Signing Key under Custom > DCI +> Configuration > Signing Keys if the deployment requires signed +messages.

+
+
+

OpenG2P plays the Social Registry role

+

OpenG2P serves Social Registry data over DCI (poverty status, household +composition, related attributes). It is not the source of disability +data — that lives in a separate OpenSPP-DR instance (see ADR-024 for the +federated demo topology).

+

This preset configures registry_type='SR' so the CEL bridge routes +through _handler_sr, and vendor='openg2p' so the preset’s +dispatcher override selects OpenG2PSocialService. The service issues +an OpenG2P-canonical request:

+
    +
  • query_type: expression
  • +
  • query.type: ns:org:QueryType:expression
  • +
  • query.value: +{"expression": {"query": {"search_text": {"$eq": <partner_id>}}}}
  • +
  • reg_type / reg_record_type: both literal "Individual"
  • +
  • consent and authorize blocks attached to every search criteria +(purpose code ELIGIBILITY_CHECK)
  • +
+

The bridge dispatcher applies each CEL variable’s dci_attribute_path +to the raw OpenG2P record at data.reg_records[0]. No vendor-specific +synthesis happens in the service layer — variables extract whatever +attribute they need by path.

+
+
+

Demo data: which identifiers exist in the OpenG2P playground?

+

Ask the OpenG2P team for sample search_text values that exist in +their Social Registry. Configure your test partners with those +identifiers (under their External Identifiers / reg_ids), and +the dispatcher’s OpenG2PSocialService._get_partner_search_text +priority order will pick them up:

+
+UIN > DRN > NATIONAL_ID > NID > (first available)
+
+

Partners with no matching identifier are recorded in +spp.dci.fetch.audit as result='not_found' and excluded from CEL +evaluation.

+
+
+

When OpenG2P’s request shape converges on standard DCI

+

The vendor-specific path is opt-in. If OpenG2P’s published API ever +drops the namespaced URI query type, the nested search_text shape, +or the mandatory consent/authorize blocks and aligns with the upstream +DCI defaults, clear the vendor field on the data source. The +dispatcher’s override falls through to the bridge’s default +_handler_sr (currently a not-implemented stub; the bridge will gain +a standard SR client when one ships).

+
+
+

CEL variables and field mapping

+

The OpenG2P SR record at data.reg_records[0] exposes the following +top-level fields (verified against partner-nsr.play.openg2p.org on +2026-05-15):

+
+member_identifier, demographic_info, related_person, self_id_disability,
+is_disabled, disability_info, marital_status, employment_status, occupation,
+income_level, language_code, education_level, additional_attributes,
+registration_date, last_updated
+
+

OpenG2P does not surface a top-level boolean is_poor — the closest +signal is income_level, a categorical string ("low" / +"medium" / "high"). The preset binds the semantic CEL variable +is_poor to read income_level and surfaces it to CEL rules as a +string. Eligibility rules then express the poverty threshold via +comparison:

+
+is_poor == "low"
+
+

(or whichever tier your policy treats as poor — "medium", an in +list, etc.). The variable name is intentionally kept as is_poor so +CEL rules read semantically; the underlying field is income_level.

+
+
+

Deferred features

+ +++++ + + + + + + + + + + + + +
VariableReasonPath to revive
has_dependent_under_school_ageOpenG2P’s +reg_records[0] +is per-individual +and does not embed +household +composition or +dependent birth +dates. No top-level +field maps cleanly.(a) Ask the OpenG2P +team to add a +top-level boolean; or +(b) issue a secondary +household-search call +against OpenG2P +(different endpoint) +and aggregate the +results. The CEL +variable record is +kept in inactive state +— flip it active + set +dci_attribute_path +to the new field name +once the data is +available.
+

The inactive variable stays registered in spp.cel.variable so any +CEL rules that still reference it gracefully evaluate to null (and fail +the comparison) instead of crashing the resolver.

+
+
+

Cache TTL

+

The preset ships with cache_ttl_seconds = 300 (5 minutes) on every +SR variable so the DCI round-trip is visible during demos. For +production, raise to 86400 (24h) or higher on each variable form +(Custom > CEL > Variables).

+
+
+

Switching to a different SR vendor

+

If you target a non-OpenG2P Social Registry, the preset is the wrong +starting point — clone it as spp_dci_<vendor> and adjust:

+
    +
  • The data source’s base_url and vendor field
  • +
  • The service class (mirror OpenG2PSocialService for that vendor’s +quirks)
  • +
  • The dispatcher override’s branch
  • +
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • OpenSPP.org
  • +
+
+
+

Maintainers

+

Current maintainers:

+

jeremi gonzalesedwin1123

+

This module is part of the OpenSPP/OpenSPP2 project on GitHub.

+

You are welcome to contribute.

+
+
+
+
+ + diff --git a/spp_dci_openg2p/tests/__init__.py b/spp_dci_openg2p/tests/__init__.py new file mode 100644 index 00000000..711ce97a --- /dev/null +++ b/spp_dci_openg2p/tests/__init__.py @@ -0,0 +1,5 @@ +from . import test_dispatcher_routing +from . import test_install +from . import test_openg2p_dci_client +from . import test_openg2p_social_service +from . import test_sr_import_wizard diff --git a/spp_dci_openg2p/tests/test_dispatcher_routing.py b/spp_dci_openg2p/tests/test_dispatcher_routing.py new file mode 100644 index 00000000..65e247c6 --- /dev/null +++ b/spp_dci_openg2p/tests/test_dispatcher_routing.py @@ -0,0 +1,145 @@ +"""End-to-end test: bridge dispatcher routes vendor=openg2p sources +(registry_type=SR) to the OpenG2P Social service, and the result +populates the dispatcher's return dict for attribute-path extraction. +""" + +from unittest.mock import MagicMock, patch + +from odoo.tests.common import TransactionCase, tagged + +from odoo.addons.spp_cel_dci_bridge.exceptions import DCIConfigurationError + + +def make_sr_response_for_search_text(search_text_to_records): + """Build a stateful client.search mock: response depends on the + ``search_text`` value passed in ``query_value``, so a single mock can + distinguish between matching and non-matching partners. + """ + + def _search(**kwargs): + # OpenG2PSocialService passes the partner identifier value as + # query_value (the search_text). No type prefix; the client + # wraps it into the expression query shape. + search_text = kwargs.get("query_value", "") + records = search_text_to_records.get(search_text, []) + if not records: + return {"message": {"search_response": []}} + return { + "message": { + "search_response": [ + { + "reference_id": "r1", + "timestamp": "2026-05-14T00:00:00Z", + "status": "succ", + "data": { + "reg_type": "Individual", + "reg_record_type": "Individual", + "reg_records": records, + }, + } + ] + } + } + + return _search + + +@tagged("post_install", "-at_install") +class TestDispatcherRoutesOpenG2P(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Reuse the UIN vocab code seeded by the preset itself + # (data/openg2p_id_types.xml). Creating a fresh UIN here would hit + # the spp.vocabulary.code uniqueness constraint. + cls.id_type_uin = cls.env.ref("spp_dci_openg2p.id_type_uin") + cls.partner_in_sr = cls.env["res.partner"].create( + {"name": "SR Partner", "is_registrant": True, "is_group": False} + ) + cls.env["spp.registry.id"].create( + { + "partner_id": cls.partner_in_sr.id, + "id_type_id": cls.id_type_uin.id, + "value": "IND-NSR-0001", + } + ) + cls.partner_not_in_sr = cls.env["res.partner"].create( + {"name": "Unknown Partner", "is_registrant": True, "is_group": False} + ) + cls.env["spp.registry.id"].create( + { + "partner_id": cls.partner_not_in_sr.id, + "id_type_id": cls.id_type_uin.id, + "value": "IND-UNKNOWN", + } + ) + + # The OpenG2P preset auto-creates this data source + provider + + # SR variables. Routing tests use var_is_poor (canonical SR + # variable on this preset). + cls.data_source = cls.env.ref("spp_dci_openg2p.openg2p_dr_source") + cls.variable = cls.env.ref("spp_dci_openg2p.var_is_poor") + + def test_data_source_has_vendor_openg2p_and_registry_type_sr(self): + self.assertEqual(self.data_source.vendor, "openg2p") + self.assertEqual(self.data_source.registry_type, "SR") + + @patch("odoo.addons.spp_dci_openg2p.services.openg2p_social_service.OpenG2PDCIClient") + def test_openg2p_handler_extracts_attribute_path_from_reg_record(self, mock_client_class): + """Partner with a matching OpenG2P record returns the value at + ``dci_attribute_path`` from the raw reg_record (no synthesis). + + The is_poor variable's path is `income_level`, so the dispatcher + extracts the raw string ("low" / "medium" / "high") — CEL rules + decide which tier counts as poor via `is_poor == "low"`. + """ + mock_client = MagicMock() + mock_client.search.side_effect = make_sr_response_for_search_text( + {"IND-NSR-0001": [{"income_level": "low", "is_disabled": False}]} + ) + mock_client_class.return_value = mock_client + + result = self.env["spp.cel.dci.dispatcher"].fetch_values_for_variable( + self.variable, [self.partner_in_sr.id], "current" + ) + + self.assertEqual(result, {self.partner_in_sr.id: "low"}) + + @patch("odoo.addons.spp_dci_openg2p.services.openg2p_social_service.OpenG2PDCIClient") + def test_openg2p_handler_records_not_found_for_unknown_partner(self, mock_client_class): + """REG-ERR-001 / empty search_response → no entry in result dict, + audit row says ``not_found``.""" + mock_client = MagicMock() + mock_client.search.side_effect = make_sr_response_for_search_text({}) + mock_client_class.return_value = mock_client + + result = self.env["spp.cel.dci.dispatcher"].fetch_values_for_variable( + self.variable, [self.partner_not_in_sr.id], "current" + ) + + self.assertEqual(result, {}) + audits = self.env["spp.dci.fetch.audit"].search( + [("variable_name", "=", "is_poor"), ("subject_id", "=", self.partner_not_in_sr.id)] + ) + self.assertEqual(audits.result, "not_found") + + @patch("odoo.addons.spp_dci_openg2p.services.openg2p_social_service.OpenG2PDCIClient") + def test_clearing_vendor_falls_back_to_bridge_sr_stub(self, mock_client_class): + """When vendor is cleared, the bridge's not-implemented SR handler + runs and raises DCIConfigurationError — ADR-023 Critical #2's + silent-failure guard. The OpenG2P client must not be invoked. + + This is the migration test: setting vendor on a data source is + what opts into the vendor-specific adapter; clearing it returns + the variable to the bridge's default behaviour (which, for SR, + is "no handler installed"). + """ + self.data_source.vendor = False + + with self.assertRaises(DCIConfigurationError): + self.env["spp.cel.dci.dispatcher"].fetch_values_for_variable( + self.variable, [self.partner_in_sr.id], "current" + ) + + mock_client_class.assert_not_called() diff --git a/spp_dci_openg2p/tests/test_install.py b/spp_dci_openg2p/tests/test_install.py new file mode 100644 index 00000000..03ddf607 --- /dev/null +++ b/spp_dci_openg2p/tests/test_install.py @@ -0,0 +1,263 @@ +from unittest.mock import patch + +from odoo.tests.common import TransactionCase, tagged + +from odoo.addons.spp_dci_openg2p import post_init_hook + +_PRESET_VARIABLE_XMLIDS = ( + "spp_dci_openg2p.var_is_poor", + "spp_dci_openg2p.var_has_dependent_under_school_age", +) + + +@tagged("post_install", "-at_install") +class TestOpenG2PPresetInstall(TransactionCase): + """Smoke test: the preset records exist after install and are linked correctly.""" + + # ------------------------------------------------------------------ + # Vocabulary code + # ------------------------------------------------------------------ + + def test_uin_id_type_vocab_code_present(self): + """The preset ships a `UIN` vocabulary code on the urn:openspp:vocab:id-type + vocabulary so operators can pick it as `ID Type` on the registrant's + Identity tab. The code value (UIN, uppercase) matches the SPDCI wire + convention and the first entry in OpenG2PSocialService.IDENTIFIER_PRIORITY. + """ + code = self.env.ref("spp_dci_openg2p.id_type_uin") + self.assertEqual(code.code, "UIN") + self.assertEqual(code.target_type, "individual") + self.assertEqual( + code.vocabulary_id, + self.env.ref("spp_vocabulary.vocab_id_type"), + ) + + def test_uin_code_matches_service_priority_first(self): + """Regression: the vocab code must equal the first entry in the + service's IDENTIFIER_PRIORITY tuple, otherwise the dispatcher would + not pick up a partner's UIN reg_id when querying OpenG2P.""" + from odoo.addons.spp_dci_openg2p.services.openg2p_social_service import ( + IDENTIFIER_PRIORITY, + ) + + code = self.env.ref("spp_dci_openg2p.id_type_uin") + self.assertEqual(IDENTIFIER_PRIORITY[0], code.code) + + # ------------------------------------------------------------------ + # Data source and provider + # ------------------------------------------------------------------ + + def test_data_source_present(self): + source = self.env.ref("spp_dci_openg2p.openg2p_dr_source") + self.assertEqual(source.code, "openg2p_dr") + # OpenG2P plays the Social Registry role in the federated topology + # (ADR-024). Disability data lives on a separate OpenSPP-DR instance. + self.assertEqual(source.registry_type, "SR") + self.assertEqual(source.vendor, "openg2p") + self.assertEqual(source.auth_type, "none") + self.assertTrue(source.active) + + def test_provider_links_to_data_source(self): + provider = self.env.ref("spp_dci_openg2p.openg2p_dr_provider") + source = self.env.ref("spp_dci_openg2p.openg2p_dr_source") + self.assertEqual(provider.code, "openg2p_dr") + self.assertEqual(provider.dci_data_source_id, source) + self.assertTrue(provider.is_dci_backed) + + # ------------------------------------------------------------------ + # CEL variables: preset ships two SR-sourced variables + # ------------------------------------------------------------------ + + def test_var_is_poor_bound_to_dci_provider(self): + variable = self.env.ref("spp_dci_openg2p.var_is_poor") + provider = self.env.ref("spp_dci_openg2p.openg2p_dr_provider") + self.assertEqual(variable.name, "is_poor") + self.assertEqual(variable.cel_accessor, "is_poor") + self.assertEqual(variable.source_type, "external") + # OpenG2P SR exposes `income_level` as a string ("low" / "medium" / + # "high"); the preset binds is_poor to that raw value rather than + # synthesizing a boolean. CEL rules match `is_poor == "low"`. + self.assertEqual(variable.value_type, "string") + self.assertEqual(variable.external_provider_id, provider) + self.assertEqual(variable.dci_attribute_path, "income_level") + self.assertEqual(variable.cache_strategy, "ttl") + self.assertEqual(variable.cache_ttl_seconds, 300) + self.assertEqual(variable.external_failure_policy, "null") + self.assertEqual(variable.state, "active") + self.assertTrue(variable.active) + + def test_var_has_dependent_under_school_age_parked_inactive(self): + """Deferred: OpenG2P's reg_records[0] doesn't expose household + composition / dependent birth dates. The variable record stays + registered (so revival is a config-only change) but is parked + inactive so the dispatcher's pre-warm skips it.""" + variable = self.env.ref("spp_dci_openg2p.var_has_dependent_under_school_age") + provider = self.env.ref("spp_dci_openg2p.openg2p_dr_provider") + self.assertEqual(variable.name, "has_dependent_under_school_age") + self.assertEqual(variable.cel_accessor, "has_dependent_under_school_age") + self.assertEqual(variable.source_type, "external") + self.assertEqual(variable.value_type, "boolean") + self.assertEqual(variable.external_provider_id, provider) + self.assertEqual( + variable.dci_attribute_path, + "has_dependent_under_school_age", + ) + self.assertEqual(variable.state, "inactive") + self.assertFalse(variable.active) + + def test_cel_accessors_are_semantic_not_vendor_named(self): + """ADR-023 §1a: CEL accessors must be vendor-neutral. OpenG2P-ness + lives only in data-source/provider records, never in the CEL surface.""" + for xml_id in _PRESET_VARIABLE_XMLIDS: + variable = self.env.ref(xml_id) + for forbidden in ("openg2p", "g2p", "vendor"): + self.assertNotIn(forbidden, variable.cel_accessor.lower()) + self.assertNotIn(forbidden, variable.name.lower()) + + def test_preset_does_not_override_var_has_disability(self): + """ADR-024: disability data lives on the OpenSPP-DR instance, not + OpenG2P. This preset must NOT rebind var_has_disability — that + belongs to the DR-side preset (spp_dci_openspp_dr). + + We can't assert the variable is at its spp_studio default (a + previous version of this preset may have already bound it, and + bindings stick across upgrades), but we CAN assert no XML record + in this preset is responsible for the binding. + """ + # Look up the ir.model.data entries that reference var_has_disability + # and verify none come from this module. + variable = self.env.ref("spp_studio.var_has_disability") + owners = self.env["ir.model.data"].search( + [ + ("model", "=", "spp.cel.variable"), + ("res_id", "=", variable.id), + ("module", "=", "spp_dci_openg2p"), + ] + ) + self.assertFalse( + owners, + "spp_dci_openg2p must not own var_has_disability bindings — " + "that responsibility belongs to spp_dci_openspp_dr per ADR-024.", + ) + + # ------------------------------------------------------------------ + # post_init_hook: drift correction + # ------------------------------------------------------------------ + + def test_post_init_hook_re_asserts_after_reset(self): + """Simulate var_is_poor getting reset back to draft state, then run + the hook. The hook must restore the DCI binding. Without this + protection, an unrelated upgrade silently breaks the demo.""" + variable = self.env.ref("spp_dci_openg2p.var_is_poor") + provider = self.env.ref("spp_dci_openg2p.openg2p_dr_provider") + + # Simulate someone resetting the variable to draft / no provider + variable.write( + { + "external_provider_id": False, + "dci_attribute_path": False, + "cache_strategy": "none", + "state": "draft", + "active": False, + } + ) + self.assertFalse(variable.external_provider_id) + self.assertEqual(variable.state, "draft") + + post_init_hook(self.env) + + variable.invalidate_recordset() + self.assertEqual(variable.source_type, "external") + self.assertEqual(variable.external_provider_id, provider) + self.assertEqual(variable.dci_attribute_path, "income_level") + self.assertEqual(variable.value_type, "string") + self.assertEqual(variable.cache_strategy, "ttl") + self.assertEqual(variable.cache_ttl_seconds, 300) + self.assertEqual(variable.state, "active") + self.assertTrue(variable.active) + + def test_post_init_hook_parks_deferred_variable_inactive(self): + """has_dependent_under_school_age is a deferred-feature placeholder. + Even if someone activates it manually (e.g., via the UI), the next + hook run must drag it back to state='inactive' / active=False so + the dispatcher's pre-warm skips it. This prevents accidental DCI + round-trips for a field OpenG2P does not expose.""" + var_dep = self.env.ref("spp_dci_openg2p.var_has_dependent_under_school_age") + # Simulate someone activating it + var_dep.write( + { + "external_provider_id": False, + "dci_attribute_path": False, + "state": "active", + "active": True, + } + ) + + post_init_hook(self.env) + + var_dep.invalidate_recordset() + provider = self.env.ref("spp_dci_openg2p.openg2p_dr_provider") + self.assertEqual(var_dep.external_provider_id, provider) + self.assertEqual(var_dep.dci_attribute_path, "has_dependent_under_school_age") + # Hook re-parks it inactive + self.assertEqual(var_dep.state, "inactive") + self.assertFalse(var_dep.active) + + def test_post_init_hook_handles_missing_variable_gracefully(self): + """If a preset variable is missing (e.g., data load failed), the + hook must log and continue — not raise — so partial-install + scenarios don't break the database initialization.""" + original_ref = self.env.ref + + def selective_ref(xmlid, *args, **kwargs): + if xmlid == "spp_dci_openg2p.var_is_poor": + # raise_if_not_found defaults to True, so we have to + # honour it for non-matching xmlids + if kwargs.get("raise_if_not_found", True): + raise ValueError(f"Mock: {xmlid} not found") + return self.env["spp.cel.variable"].browse() + return original_ref(xmlid, *args, **kwargs) + + with patch.object(type(self.env), "ref", side_effect=selective_ref): + # Should not raise + post_init_hook(self.env) + + def test_post_init_hook_handles_missing_provider_gracefully(self): + """If the OpenG2P provider record was deleted post-install, the + hook must log an error and return early — not raise.""" + original_ref = self.env.ref + + def selective_ref(xmlid, *args, **kwargs): + if xmlid == "spp_dci_openg2p.openg2p_dr_provider": + return self.env["spp.data.provider"].browse() # empty + return original_ref(xmlid, *args, **kwargs) + + with patch.object(type(self.env), "ref", side_effect=selective_ref): + # Should not raise + post_init_hook(self.env) + + def test_post_init_hook_is_idempotent(self): + """Running the hook when the bindings are already correct must + not change anything.""" + before = {} + for xml_id in _PRESET_VARIABLE_XMLIDS: + variable = self.env.ref(xml_id) + before[xml_id] = { + "source_type": variable.source_type, + "external_provider_id": variable.external_provider_id.id, + "dci_attribute_path": variable.dci_attribute_path, + } + + post_init_hook(self.env) + + for xml_id in _PRESET_VARIABLE_XMLIDS: + variable = self.env.ref(xml_id) + variable.invalidate_recordset() + self.assertEqual( + { + "source_type": variable.source_type, + "external_provider_id": variable.external_provider_id.id, + "dci_attribute_path": variable.dci_attribute_path, + }, + before[xml_id], + ) diff --git a/spp_dci_openg2p/tests/test_openg2p_dci_client.py b/spp_dci_openg2p/tests/test_openg2p_dci_client.py new file mode 100644 index 00000000..27716856 --- /dev/null +++ b/spp_dci_openg2p/tests/test_openg2p_dci_client.py @@ -0,0 +1,182 @@ +"""OpenG2PDCIClient request-shape regression tests. + +Locks in the five delta behaviours vs. upstream DCIClient (see +``services/openg2p_dci_client.py`` module docstring): + + 1. ``query_type`` is ``"expression"`` (this client's preferred path) + 2. ``query.type`` carries the namespaced URI ``"ns:org:QueryType:expression"`` + 3. ``query.value`` is the nested ``{expression: {query: {search_text: {$eq}}}}`` shape + 4. ``reg_type`` and ``reg_record_type`` are both the literal ``"Individual"`` + 5. ``consent`` and ``authorize`` blocks are attached to every search criteria +""" + +from odoo.tests.common import TransactionCase, tagged + +from odoo.addons.spp_dci.schemas import QueryType +from odoo.addons.spp_dci_openg2p.services.openg2p_dci_client import ( + DEFAULT_CONSENT_PURPOSE, + DEFAULT_OPENG2P_REG_RECORD_TYPE, + DEFAULT_OPENG2P_REG_TYPE, + OPENG2P_QUERY_TYPE_URI, + OpenG2PDCIClient, +) + + +@tagged("post_install", "-at_install") +class TestOpenG2PDCIClient(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.data_source = cls.env["spp.dci.data.source"].create( + { + "name": "OpenG2P Test Source", + "code": "openg2p_test", + "registry_type": "SR", + "vendor": "openg2p", + "base_url": "https://partner-registry.play.openg2p.org", + "search_endpoint": "/dci/registry/sync/search", + "auth_type": "none", + "our_sender_id": "openspp.test", + "receiver_id": "openg2p.test", + } + ) + + # ------------------------------------------------------------------ + # _parse_query: expression query produces nested search_text shape + # ------------------------------------------------------------------ + + def test_parse_query_expression_produces_nested_search_text_shape(self): + client = OpenG2PDCIClient(self.data_source, self.env) + query = client._parse_query(QueryType.EXPRESSION, "IND-NSR-0001") + self.assertEqual( + query, + { + "type": OPENG2P_QUERY_TYPE_URI, + "value": { + "expression": { + "query": { + "search_text": {"$eq": "IND-NSR-0001"}, + }, + }, + }, + }, + ) + + def test_parse_query_non_expression_falls_through_to_super(self): + """Non-expression query types defer to upstream DCIClient — this + adapter only owns the expression path.""" + client = OpenG2PDCIClient(self.data_source, self.env) + out = client._parse_query(QueryType.PREDICATE, "some predicate") + self.assertEqual(out, "some predicate") + + # ------------------------------------------------------------------ + # _build_search_envelope: reg_type forced, reg_record_type injected, + # consent + authorize attached + # ------------------------------------------------------------------ + + def _build_envelope(self, client, search_text="IND-NSR-0001", **overrides): + kwargs = dict( + query_type=QueryType.EXPRESSION, + query=client._parse_query(QueryType.EXPRESSION, search_text), + registry_type="ns:org:RegistryType:Social", + registry_event_type=None, + record_type="PERSON", + page=1, + page_size=1, + ) + kwargs.update(overrides) + return client._build_search_envelope(**kwargs) + + def test_search_envelope_forces_reg_type_to_individual(self): + """Even if the caller passes a different registry_type (a routing + concept), the wire reg_type is always Individual.""" + client = OpenG2PDCIClient(self.data_source, self.env) + envelope = self._build_envelope(client) + criterias = [item["search_criteria"] for item in envelope["message"]["search_request"]] + self.assertTrue(criterias) + for criteria in criterias: + self.assertEqual(criteria.get("reg_type"), DEFAULT_OPENG2P_REG_TYPE) + + def test_search_envelope_injects_reg_record_type(self): + """Upstream's SearchCriteria Pydantic model omits reg_record_type, + so this adapter must inject it post-build.""" + client = OpenG2PDCIClient(self.data_source, self.env) + envelope = self._build_envelope(client) + criterias = [item["search_criteria"] for item in envelope["message"]["search_request"]] + for criteria in criterias: + self.assertEqual( + criteria.get("reg_record_type"), + DEFAULT_OPENG2P_REG_RECORD_TYPE, + ) + + def test_search_envelope_query_is_namespaced_expression_shape(self): + """End-to-end: envelope.message.search_request[i].search_criteria.query + carries the namespaced URI type and the nested search_text body.""" + client = OpenG2PDCIClient(self.data_source, self.env) + envelope = self._build_envelope(client, search_text="IND-NSR-7777") + query = envelope["message"]["search_request"][0]["search_criteria"]["query"] + self.assertEqual(query["type"], OPENG2P_QUERY_TYPE_URI) + self.assertEqual( + query["value"]["expression"]["query"]["search_text"]["$eq"], + "IND-NSR-7777", + ) + + def test_search_envelope_attaches_consent_and_authorize(self): + """Every search_criteria must carry consent + authorize blocks. + Defaults to ELIGIBILITY_CHECK purpose.""" + client = OpenG2PDCIClient(self.data_source, self.env) + envelope = self._build_envelope(client) + criteria = envelope["message"]["search_request"][0]["search_criteria"] + self.assertIn("consent", criteria) + self.assertIn("authorize", criteria) + self.assertEqual(criteria["consent"]["@type"], "Consent") + self.assertEqual(criteria["authorize"]["@type"], "Authorize") + self.assertEqual( + criteria["consent"]["purpose"]["code"], + DEFAULT_CONSENT_PURPOSE["code"], + ) + self.assertEqual( + criteria["authorize"]["purpose"]["code"], + DEFAULT_CONSENT_PURPOSE["code"], + ) + + def test_consent_and_authorize_blocks_not_overwritten_when_already_set(self): + """If upstream ever starts populating consent/authorize itself, this + adapter must not clobber it (setdefault semantics).""" + client = OpenG2PDCIClient(self.data_source, self.env) + + original_super = client.__class__.__mro__[1]._build_search_envelope + + def upstream_with_consent(self, **kwargs): + envelope = original_super(self, **kwargs) + for item in envelope["message"]["search_request"]: + item["search_criteria"]["consent"] = {"sentinel": "preserved"} + item["search_criteria"]["authorize"] = {"sentinel": "preserved"} + return envelope + + # Monkey-patch upstream to populate consent/authorize, then call + # our adapter's _build_search_envelope and verify it preserved them. + original_method = client.__class__.__mro__[1]._build_search_envelope + try: + client.__class__.__mro__[1]._build_search_envelope = upstream_with_consent + envelope = self._build_envelope(client) + finally: + client.__class__.__mro__[1]._build_search_envelope = original_method + + criteria = envelope["message"]["search_request"][0]["search_criteria"] + self.assertEqual(criteria["consent"], {"sentinel": "preserved"}) + self.assertEqual(criteria["authorize"], {"sentinel": "preserved"}) + + def test_custom_reg_type_via_constructor(self): + """Operators can override the reg_type/reg_record_type via the + constructor when OpenG2P serves something other than 'Individual'.""" + client = OpenG2PDCIClient( + self.data_source, + self.env, + reg_type="HouseholdMember", + reg_record_type="HouseholdMember", + ) + envelope = self._build_envelope(client) + criteria = envelope["message"]["search_request"][0]["search_criteria"] + self.assertEqual(criteria["reg_type"], "HouseholdMember") + self.assertEqual(criteria["reg_record_type"], "HouseholdMember") diff --git a/spp_dci_openg2p/tests/test_openg2p_social_service.py b/spp_dci_openg2p/tests/test_openg2p_social_service.py new file mode 100644 index 00000000..ab131f32 --- /dev/null +++ b/spp_dci_openg2p/tests/test_openg2p_social_service.py @@ -0,0 +1,213 @@ +"""OpenG2PSocialService unit tests. + +Locks in: + - get_partner_record returns the raw reg_record dict when OpenG2P matches + - Returns None on REG-ERR-001 / empty search_response + - Returns None when the partner has no resolvable identifier (no HTTP call) + - Response unwrap walks message.search_response[i].data.reg_records[0] + - Search request is issued with the partner identifier value as the + expression query's search_text +""" + +from unittest.mock import MagicMock, patch + +from odoo.tests.common import TransactionCase, tagged + +from odoo.addons.spp_dci.schemas import QueryType +from odoo.addons.spp_dci_openg2p.services.openg2p_social_service import ( + OpenG2PSocialService, +) + + +def make_sr_response(reg_records): + """Shape that matches OpenG2P's actual response envelope (Social Registry).""" + return { + "signature": "", + "header": { + "version": "1.0.0", + "message_id": "m1", + "message_ts": "2026-05-14T00:00:00Z", + "action": "search", + "status": "succ", + "sender_id": "openg2p.test", + "receiver_id": "openspp.test", + }, + "message": { + "transaction_id": "t1", + "correlation_id": "c1", + "search_response": [ + { + "reference_id": "r1", + "timestamp": "2026-05-14T00:00:00Z", + "status": "succ", + "data": { + "version": "1.0.0", + "reg_type": "Individual", + "reg_record_type": "Individual", + "reg_records": reg_records, + }, + } + ], + }, + } + + +def make_sr_not_found_response(): + """Shape returned by OpenG2P for REG-ERR-001 / unknown identifier.""" + return { + "signature": "", + "header": { + "version": "1.0.0", + "message_id": "m1", + "message_ts": "2026-05-14T00:00:00Z", + "action": "search", + "status": "rjct", + "status_reason_code": "REG-ERR-001", + "status_reason_message": "REGISTER_NOT_FOUND", + "sender_id": "openg2p.test", + "receiver_id": "openspp.test", + }, + "message": { + "transaction_id": "t1", + "correlation_id": "c1", + "search_response": [], + }, + } + + +@tagged("post_install", "-at_install") +class TestOpenG2PSocialService(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.data_source = cls.env["spp.dci.data.source"].create( + { + "name": "OpenG2P SR Test Source", + "code": "openg2p_sr_test", + "registry_type": "SR", + "vendor": "openg2p", + "base_url": "https://partner-registry.play.openg2p.org", + "search_endpoint": "/dci/registry/sync/search", + "auth_type": "none", + "our_sender_id": "openspp.test", + "receiver_id": "openg2p.test", + } + ) + + cls.id_type_uin = cls.env.ref("spp_dci_openg2p.id_type_uin") + + cls.partner_known = cls.env["res.partner"].create( + {"name": "Known Registrant", "is_registrant": True, "is_group": False} + ) + cls.env["spp.registry.id"].create( + { + "partner_id": cls.partner_known.id, + "id_type_id": cls.id_type_uin.id, + "value": "IND-NSR-0001", + } + ) + + cls.partner_no_id = cls.env["res.partner"].create( + {"name": "Partner Without ID", "is_registrant": True, "is_group": False} + ) + + @staticmethod + def _make_service_with_mock_client(env, data_source, mock_client): + """Construct an OpenG2PSocialService with a mocked OpenG2PDCIClient + injected, bypassing __init__ which would touch the DCI client + constructor and the data source loader.""" + with patch.object( + OpenG2PSocialService.__mro__[0], + "__init__", + lambda self, env, data_source_code: None, + ): + service = OpenG2PSocialService.__new__(OpenG2PSocialService) + service.env = env + service.data_source_code = data_source.code + service.data_source = data_source + service.client = mock_client + return service + + def test_returns_record_when_openg2p_matches(self): + """get_partner_record returns the raw reg_record dict (no synthesis).""" + mock_client = MagicMock() + mock_client.search.return_value = make_sr_response( + [{"is_poor": True, "has_dependent_under_school_age": False, "name": "Known"}] + ) + service = self._make_service_with_mock_client(self.env, self.data_source, mock_client) + + result = service.get_partner_record(self.partner_known) + + self.assertIsNotNone(result) + self.assertEqual(result["is_poor"], True) + self.assertEqual(result["has_dependent_under_school_age"], False) + self.assertEqual(result["name"], "Known") + + def test_issues_expression_query_with_partner_identifier_as_search_text(self): + """Search must use QueryType.EXPRESSION with the partner's UIN value + as the query_value (search_text). Verifies the SR semantics: partner + identifier flows through unchanged, no vendor-specific synthesis.""" + mock_client = MagicMock() + mock_client.search.return_value = make_sr_response([{"is_poor": True}]) + service = self._make_service_with_mock_client(self.env, self.data_source, mock_client) + + service.get_partner_record(self.partner_known) + + mock_client.search.assert_called_once() + kwargs = mock_client.search.call_args.kwargs + self.assertEqual(kwargs["query_type"], QueryType.EXPRESSION) + self.assertEqual(kwargs["query_value"], "IND-NSR-0001") + + def test_returns_none_when_no_records(self): + mock_client = MagicMock() + mock_client.search.return_value = make_sr_not_found_response() + service = self._make_service_with_mock_client(self.env, self.data_source, mock_client) + + result = service.get_partner_record(self.partner_known) + + self.assertIsNone(result) + + def test_returns_none_when_partner_has_no_identifier(self): + """Service must not call OpenG2P at all when the partner has no + identifier to send as search_text — saves an HTTP round-trip.""" + mock_client = MagicMock() + service = self._make_service_with_mock_client(self.env, self.data_source, mock_client) + + result = service.get_partner_record(self.partner_no_id) + + self.assertIsNone(result) + mock_client.search.assert_not_called() + + def test_extract_first_record_handles_empty_reg_records(self): + response = make_sr_response([]) + self.assertIsNone(OpenG2PSocialService._extract_first_record(response)) + + def test_extract_first_record_handles_missing_data_key(self): + response = {"message": {"search_response": [{"reference_id": "r1"}]}} + self.assertIsNone(OpenG2PSocialService._extract_first_record(response)) + + def test_extract_first_record_returns_first_across_responses(self): + response = make_sr_response([]) + response["message"]["search_response"].append( + { + "reference_id": "r2", + "data": {"reg_records": [{"is_poor": True}]}, + } + ) + record = OpenG2PSocialService._extract_first_record(response) + self.assertEqual(record["is_poor"], True) + + def test_extract_first_record_handles_non_dict_response(self): + """Defensive: non-dict input must return None, not raise.""" + self.assertIsNone(OpenG2PSocialService._extract_first_record(None)) + self.assertIsNone(OpenG2PSocialService._extract_first_record("not a dict")) + + def test_extract_first_record_skips_non_dict_record_entries(self): + response = make_sr_response([]) + response["message"]["search_response"][0]["data"]["reg_records"] = [ + "junk", + {"is_poor": False}, + ] + record = OpenG2PSocialService._extract_first_record(response) + self.assertEqual(record, {"is_poor": False}) diff --git a/spp_dci_openg2p/tests/test_sr_import_wizard.py b/spp_dci_openg2p/tests/test_sr_import_wizard.py new file mode 100644 index 00000000..be4636c6 --- /dev/null +++ b/spp_dci_openg2p/tests/test_sr_import_wizard.py @@ -0,0 +1,279 @@ +"""SR-import wizard tests.""" + +from unittest.mock import MagicMock, patch + +from odoo.tests.common import TransactionCase, tagged + + +def _sr_response(reg_records): + """Shape that matches OpenG2P SR's actual envelope.""" + return { + "header": {"status": "succ"}, + "message": { + "search_response": [ + { + "reference_id": "r1", + "status": "succ", + "data": { + "reg_type": "Individual", + "reg_record_type": "Individual", + "reg_records": reg_records, + }, + } + ] + }, + } + + +def _not_found_response(): + return {"header": {"status": "rjct"}, "message": {"search_response": []}} + + +def _payload(given, surname, sex="male", birth_date="1990-01-01"): + return { + "demographic_info": { + "name": {"given_name": given, "surname": surname}, + "sex": sex, + "birth_date": birth_date, + } + } + + +@tagged("post_install", "-at_install") +class TestSrImportWizard(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.data_source = cls.env.ref("spp_dci_openg2p.openg2p_dr_source") + cls.uin_type = cls.env.ref("spp_dci_openg2p.id_type_uin") + + def _wizard(self, **overrides): + defaults = { + "data_source_id": self.data_source.id, + "discovery_mode": "range", + "range_prefix": "IND-NSR-", + "range_start": 1, + "range_end": 3, + "range_pad": 4, + } + defaults.update(overrides) + return self.env["spp.dci.sr.import.wizard"].create(defaults) + + # ------------------------------------------------------------------ + # Identifier collection + # ------------------------------------------------------------------ + + def test_collect_identifiers_range_pads_correctly(self): + wiz = self._wizard(range_start=1, range_end=5, range_pad=4) + idents = wiz._collect_identifiers() + self.assertEqual( + idents, + ["IND-NSR-0001", "IND-NSR-0002", "IND-NSR-0003", "IND-NSR-0004", "IND-NSR-0005"], + ) + + def test_collect_identifiers_list_strips_comments_and_dedupes(self): + wiz = self._wizard( + discovery_mode="list", + identifier_list_raw="# header\nIND-NSR-0001\n\nIND-NSR-0002\nIND-NSR-0001\n", + ) + self.assertEqual(wiz._collect_identifiers(), ["IND-NSR-0001", "IND-NSR-0002"]) + + def test_collect_identifiers_rejects_empty_range(self): + from odoo.exceptions import UserError + + wiz = self._wizard(range_start=10, range_end=5) + with self.assertRaises(UserError): + wiz._collect_identifiers() + + def test_collect_identifiers_rejects_empty_list(self): + from odoo.exceptions import UserError + + wiz = self._wizard(discovery_mode="list", identifier_list_raw=" \n#only comments\n") + with self.assertRaises(UserError): + wiz._collect_identifiers() + + # ------------------------------------------------------------------ + # Preview step + # ------------------------------------------------------------------ + + @patch("odoo.addons.spp_dci_openg2p.services.openg2p_social_service.OpenG2PDCIClient") + def test_preview_matched_not_found_and_existing_partner(self, mock_client_class): + # 0001 matches, 0002 already on SP, 0003 not found + existing_partner = self.env["res.partner"].create( + {"name": "Existing", "is_registrant": True, "is_group": False} + ) + self.env["spp.registry.id"].create( + { + "partner_id": existing_partner.id, + "id_type_id": self.uin_type.id, + "value": "IND-NSR-0002", + } + ) + + def search(**kwargs): + v = kwargs.get("query_value") + if v == "IND-NSR-0001": + return _sr_response([_payload("Alex", "Rivera")]) + if v == "IND-NSR-0002": + return _sr_response([_payload("Priya", "Rivera", sex="female")]) + return _not_found_response() + + mock_client = MagicMock() + mock_client.search.side_effect = search + mock_client_class.return_value = mock_client + + wiz = self._wizard(range_start=1, range_end=3) + wiz.action_preview() + + self.assertEqual(wiz.state, "preview") + self.assertEqual(len(wiz.preview_line_ids), 3) + + by_uin = {line.uin: line for line in wiz.preview_line_ids} + self.assertEqual(by_uin["IND-NSR-0001"].status, "matched") + self.assertEqual(by_uin["IND-NSR-0001"].given_name, "Alex") + self.assertEqual(by_uin["IND-NSR-0001"].surname, "Rivera") + self.assertFalse(by_uin["IND-NSR-0001"].already_exists) + self.assertTrue(by_uin["IND-NSR-0001"].selected) + + self.assertEqual(by_uin["IND-NSR-0002"].status, "matched") + self.assertTrue(by_uin["IND-NSR-0002"].already_exists) + self.assertEqual(by_uin["IND-NSR-0002"].existing_partner_id, existing_partner) + self.assertFalse(by_uin["IND-NSR-0002"].selected) # not pre-selected + + self.assertEqual(by_uin["IND-NSR-0003"].status, "not_found") + self.assertFalse(by_uin["IND-NSR-0003"].selected) + + @patch("odoo.addons.spp_dci_openg2p.services.openg2p_social_service.OpenG2PDCIClient") + def test_preview_captures_service_error_per_subject(self, mock_client_class): + mock_client = MagicMock() + mock_client.search.side_effect = RuntimeError("HTTP 500 from OpenG2P") + mock_client_class.return_value = mock_client + + wiz = self._wizard(range_start=1, range_end=2) + wiz.action_preview() + + for line in wiz.preview_line_ids: + self.assertEqual(line.status, "error") + self.assertIn("HTTP 500", line.error_message) + self.assertFalse(line.selected) + + # ------------------------------------------------------------------ + # Import step + # ------------------------------------------------------------------ + + @patch("odoo.addons.spp_dci_openg2p.services.openg2p_social_service.OpenG2PDCIClient") + def test_import_creates_partners_and_reg_ids_for_selected_only(self, mock_client_class): + def search(**kwargs): + v = kwargs.get("query_value") + payloads = { + "IND-NSR-0001": _payload("Alex", "Rivera"), + "IND-NSR-0002": _payload("Priya", "Rivera", sex="female"), + } + if v in payloads: + return _sr_response([payloads[v]]) + return _not_found_response() + + mock_client = MagicMock() + mock_client.search.side_effect = search + mock_client_class.return_value = mock_client + + wiz = self._wizard(range_start=1, range_end=2) + wiz.action_preview() + + # Deselect IND-NSR-0002 — only 0001 should import + for line in wiz.preview_line_ids: + if line.uin == "IND-NSR-0002": + line.selected = False + + wiz.action_import() + + self.assertEqual(wiz.state, "done") + regs = self.env["spp.registry.id"].search([("value", "=", "IND-NSR-0001")]) + self.assertEqual(len(regs), 1) + partner = regs.partner_id + # spp_registry auto-computes individual name as + # "FAMILY_NAME, GIVEN_NAME" (uppercased) — assert the canonical + # form, not the raw "Alex Rivera" we passed in. + self.assertEqual(partner.name, "RIVERA, ALEX") + self.assertEqual(partner.given_name, "Alex") + self.assertEqual(partner.family_name, "Rivera") + self.assertTrue(partner.is_registrant) + self.assertFalse(partner.is_group) + + # 0002 was deselected — no partner created + self.assertFalse(self.env["spp.registry.id"].search([("value", "=", "IND-NSR-0002")])) + + @patch("odoo.addons.spp_dci_openg2p.services.openg2p_social_service.OpenG2PDCIClient") + def test_import_skips_already_existing_partners(self, mock_client_class): + existing = self.env["res.partner"].create({"name": "Existing", "is_registrant": True, "is_group": False}) + self.env["spp.registry.id"].create( + { + "partner_id": existing.id, + "id_type_id": self.uin_type.id, + "value": "IND-NSR-0001", + } + ) + + mock_client = MagicMock() + mock_client.search.side_effect = lambda **k: _sr_response([_payload("Alex", "Rivera")]) + mock_client_class.return_value = mock_client + + wiz = self._wizard(range_start=1, range_end=1) + wiz.action_preview() + + # Operator manually checks the box even though "already on SP" + for line in wiz.preview_line_ids: + line.selected = True + + wiz.action_import() + + # Still only one partner with this UIN — existing one untouched + regs = self.env["spp.registry.id"].search([("value", "=", "IND-NSR-0001")]) + self.assertEqual(len(regs), 1) + self.assertEqual(regs.partner_id, existing) + self.assertEqual(existing.name, "Existing") # not renamed + + @patch("odoo.addons.spp_dci_openg2p.services.openg2p_social_service.OpenG2PDCIClient") + def test_import_auto_enrolls_into_program_when_set(self, mock_client_class): + program = self.env["spp.program"].search([], limit=1) + if not program: + self.skipTest("no spp.program in this environment") + + mock_client = MagicMock() + mock_client.search.side_effect = lambda **k: _sr_response([_payload("Alex", "Rivera")]) + mock_client_class.return_value = mock_client + + wiz = self._wizard( + range_start=1, + range_end=1, + auto_enroll_program_id=program.id, + ) + wiz.action_preview() + wiz.action_import() + + regs = self.env["spp.registry.id"].search([("value", "=", "IND-NSR-0001")]) + mems = self.env["spp.program.membership"].search( + [("partner_id", "=", regs.partner_id.id), ("program_id", "=", program.id)] + ) + self.assertEqual(len(mems), 1) + self.assertEqual(mems.state, "draft") + + def test_back_to_configure_clears_preview(self): + wiz = self._wizard() + # Skip the live preview — fabricate one line manually + self.env["spp.dci.sr.import.wizard.line"].create( + { + "wizard_id": wiz.id, + "uin": "IND-NSR-0001", + "status": "matched", + "given_name": "Alex", + "surname": "Rivera", + "selected": True, + } + ) + wiz.state = "preview" + + wiz.action_back_to_configure() + + self.assertEqual(wiz.state, "configure") + self.assertFalse(wiz.preview_line_ids) diff --git a/spp_dci_openg2p/views/sr_import_wizard_views.xml b/spp_dci_openg2p/views/sr_import_wizard_views.xml new file mode 100644 index 00000000..f7d86f24 --- /dev/null +++ b/spp_dci_openg2p/views/sr_import_wizard_views.xml @@ -0,0 +1,129 @@ + + + + + spp.dci.sr.import.wizard.form + spp.dci.sr.import.wizard + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
+ + + Import from External Registry (DCI) + spp.dci.sr.import.wizard + form + new + + + + +
diff --git a/spp_dci_openg2p/wizards/__init__.py b/spp_dci_openg2p/wizards/__init__.py new file mode 100644 index 00000000..d1900138 --- /dev/null +++ b/spp_dci_openg2p/wizards/__init__.py @@ -0,0 +1 @@ +from . import sr_import_wizard diff --git a/spp_dci_openg2p/wizards/sr_import_wizard.py b/spp_dci_openg2p/wizards/sr_import_wizard.py new file mode 100644 index 00000000..98ea6cbe --- /dev/null +++ b/spp_dci_openg2p/wizards/sr_import_wizard.py @@ -0,0 +1,427 @@ +"""SR-import wizard: discover and ingest registrants from an OpenG2P SR. + +Operator-driven alternative to the seed script — instead of running a +Python file against Odoo shell, the wizard lets a user fire DCI +search-sync requests against the configured OpenG2P SR data source, +preview matched records, pick which ones to import, and (optionally) +auto-enroll them into a program. + +Scope: this wizard intentionally captures only the BARE MINIMUM partner +fields (name, given_name, family_name, sex, birthdate) plus a UIN +``spp.registry.id``. The eligibility rules continue to read the rich +attributes (``income_level``, etc.) on demand via the CEL ↔ DCI bridge +— this wizard is NOT a full SR replica. + +Discovery semantics: the SPDCI search-sync protocol is lookup-only +(``search_text`` → record). There is no standard "list all registrants" +operation, so this wizard offers two practical discovery modes: + + - ``range``: sweep a contiguous identifier range + (e.g., ``IND-NSR-0001`` .. ``IND-NSR-0015``). + Useful against the OpenG2P playground where seeded + identifiers form a known range. + + - ``list``: operator pastes/types a list of identifiers + (one per line). Matches the production-shaped workflow + where the SR operator hands over a partner list out of + band. + +Both modes invoke the same per-identifier DCI lookup through +``OpenG2PSocialService``. +""" + +import logging + +from odoo import api, fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class SppDciSrImportWizard(models.TransientModel): + _name = "spp.dci.sr.import.wizard" + _description = "Import Registrants from External Social Registry (DCI)" + + state = fields.Selection( + [ + ("configure", "Configure"), + ("preview", "Preview"), + ("done", "Done"), + ], + default="configure", + readonly=True, + ) + + # ------------------------------------------------------------------ + # Configure step + # ------------------------------------------------------------------ + + data_source_id = fields.Many2one( + "spp.dci.data.source", + string="Source Registry", + required=True, + domain="[('registry_type', '=', 'ns:org:RegistryType:Social'), ('vendor', '=', 'openg2p'), ('active', '=', True)]", + default=lambda self: self._default_data_source(), + help="DCI data source to query. Restricted to active Social Registry " + "(SR) sources configured with the vendor-specific request semantics " + "used by this wizard.", + ) + + discovery_mode = fields.Selection( + [ + ("range", "Identifier range sweep"), + ("list", "Identifier list"), + ], + default="range", + required=True, + help=( + "How to enumerate registrants on the remote SR. " + "Range = sweep a contiguous numeric suffix (e.g., 0001..0015); " + "List = explicit identifiers, one per line." + ), + ) + + # range mode + range_prefix = fields.Char( + string="Identifier Prefix", + default="IND-NSR-", + help="Prefix for sweep mode. The wizard concatenates this with a zero-padded number in [Start, End].", + ) + range_start = fields.Integer(string="Start", default=1) + range_end = fields.Integer(string="End", default=15) + range_pad = fields.Integer( + string="Zero-pad Width", + default=4, + help="Width to zero-pad the numeric suffix (e.g., 4 → 0001).", + ) + + # list mode + identifier_list_raw = fields.Text( + string="Identifiers", + help="One identifier per line. Lines starting with # are ignored. Blank lines are skipped.", + ) + + # post-import options + auto_enroll_program_id = fields.Many2one( + "spp.program", + string="Auto-enroll into program", + help="Optional: every imported partner is added as a draft " + "membership on this program. Eligibility evaluation flips the " + "membership state on the next Enroll Eligible run.", + ) + + # ------------------------------------------------------------------ + # Preview step + # ------------------------------------------------------------------ + + preview_line_ids = fields.One2many( + "spp.dci.sr.import.wizard.line", + "wizard_id", + string="Preview", + ) + + preview_summary = fields.Char(string="Preview Summary", readonly=True) + + # ------------------------------------------------------------------ + # Defaults / helpers + # ------------------------------------------------------------------ + + @api.model + def _default_data_source(self): + """Pick the first active OpenG2P SR source. + + Most demo deployments have one — `openg2p_sr` / `openg2p_dr` (xml + id kept stable across renames). Operators can change it if they + have multiple. + """ + return self.env["spp.dci.data.source"].search( + [ + ("registry_type", "=", "ns:org:RegistryType:Social"), + ("vendor", "=", "openg2p"), + ("active", "=", True), + ], + limit=1, + order="id asc", + ) + + def _collect_identifiers(self): + """Resolve configure step inputs to a deterministic identifier list.""" + if self.discovery_mode == "range": + if not (self.range_prefix and self.range_start and self.range_end): + raise UserError(self.env._("Provide range prefix, start, and end.")) + if self.range_end < self.range_start: + raise UserError(self.env._("Range end must be ≥ start.")) + return [f"{self.range_prefix}{n:0{self.range_pad}d}" for n in range(self.range_start, self.range_end + 1)] + if not (self.identifier_list_raw or "").strip(): + raise UserError(self.env._("Provide at least one identifier.")) + identifiers = [] + for line in self.identifier_list_raw.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + identifiers.append(stripped) + # De-duplicate while preserving order. + seen, out = set(), [] + for ident in identifiers: + if ident not in seen: + seen.add(ident) + out.append(ident) + if not out: + # Empty after stripping comments / blank lines. + raise UserError(self.env._("Provide at least one identifier.")) + return out + + def _uin_id_type(self): + return self.env.ref("spp_dci_openg2p.id_type_uin", raise_if_not_found=False) + + # ------------------------------------------------------------------ + # Actions + # ------------------------------------------------------------------ + + def action_preview(self): + """Fire DCI lookups for every identifier; populate preview rows. + + Each row carries the resolved record's basic identity (name, + sex, birthdate) plus an ``already_exists`` flag so the operator + can see which UINs are already imported. + """ + self.ensure_one() + + # Lazy import — avoid a hard module-load dependency on the service + # so this wizard can lint cleanly even when the user runs tests + # against a stripped install. + from ..services.openg2p_social_service import OpenG2PSocialService + + identifiers = self._collect_identifiers() + if not identifiers: + raise UserError(self.env._("No identifiers to query.")) + + if not self.data_source_id: + raise UserError(self.env._("Select a Source Registry first.")) + + service = OpenG2PSocialService(self.env, data_source_code=self.data_source_id.code) + + # Wipe any prior preview lines (operator may iterate) + self.preview_line_ids.unlink() + + uin_type = self._uin_id_type() + RegId = self.env["spp.registry.id"] + + lines_vals = [] + n_matched = 0 + n_not_found = 0 + n_already_exists = 0 + + for ident in identifiers: + payload = None + error = None + try: + # Bypass partner-based path; call the client directly so + # we can use the wizard-provided identifier as search_text. + from odoo.addons.spp_dci.schemas import QueryType + + response = service.client.search( + query_type=QueryType.EXPRESSION, + query_value=ident, + registry_type="Individual", + record_type="Individual", + page=1, + page_size=1, + ) + payload = service._extract_first_record(response) + except Exception as e: + error = str(e)[:200] + _logger.warning("SR import: lookup failed for %s: %s", ident, e) + + if error: + status = "error" + n_not_found += 1 + line_vals = self._line_vals_empty(ident, status, error) + elif payload is None: + status = "not_found" + n_not_found += 1 + line_vals = self._line_vals_empty(ident, status, "") + else: + status = "matched" + n_matched += 1 + line_vals = self._line_vals_from_payload(ident, payload) + + # Check for existing partner on the SP with this UIN + if uin_type: + existing = RegId.search( + [("id_type_id", "=", uin_type.id), ("value", "=", ident)], + limit=1, + ) + if existing: + line_vals["already_exists"] = True + line_vals["existing_partner_id"] = existing.partner_id.id + if status == "matched": + n_already_exists += 1 + + # Default-select all newly matched (not-already-on-SP) rows + line_vals["selected"] = status == "matched" and not line_vals.get("already_exists") + lines_vals.append(line_vals) + + self.preview_line_ids.create([dict(vals, wizard_id=self.id) for vals in lines_vals]) + self.state = "preview" + self.preview_summary = self.env._( + "%(matched)s matched (%(already)s already on SP), %(not_found)s not found/error, %(total)s total queries.", + matched=n_matched, + already=n_already_exists, + not_found=n_not_found, + total=len(identifiers), + ) + + return self._reopen() + + def action_import(self): + """Create res.partner + spp.registry.id rows for selected lines. + + Skips rows where ``already_exists`` is True (UIN already on SP). + Optionally creates draft program memberships when + ``auto_enroll_program_id`` is set. + """ + self.ensure_one() + if self.state != "preview": + raise UserError(self.env._("Run Preview first.")) + + uin_type = self._uin_id_type() + if not uin_type: + raise UserError(self.env._("UIN vocabulary code is missing. Verify spp_dci_openg2p.id_type_uin is loaded.")) + + Partner = self.env["res.partner"] + RegId = self.env["spp.registry.id"] + + n_created = 0 + for line in self.preview_line_ids.filtered(lambda r: r.selected and r.status == "matched"): + if line.already_exists: + continue + partner_vals = { + "name": f"{line.given_name or ''} {line.surname or ''}".strip() or line.uin, + "given_name": line.given_name or False, + "family_name": line.surname or False, + "is_registrant": True, + "is_group": False, + } + if line.birth_date: + partner_vals["birthdate"] = line.birth_date + partner = Partner.create(partner_vals) + RegId.create( + { + "partner_id": partner.id, + "id_type_id": uin_type.id, + "value": line.uin, + } + ) + n_created += 1 + + if self.auto_enroll_program_id: + self.env["spp.program.membership"].create( + { + "partner_id": partner.id, + "program_id": self.auto_enroll_program_id.id, + "state": "draft", + } + ) + + self.state = "done" + self.preview_summary = self.env._("%(n)s registrant(s) imported.", n=n_created) + return self._reopen() + + def action_back_to_configure(self): + self.ensure_one() + self.preview_line_ids.unlink() + self.state = "configure" + self.preview_summary = False + return self._reopen() + + def _reopen(self): + """Re-open the wizard on the same record so the next view step shows.""" + return { + "type": "ir.actions.act_window", + "res_model": self._name, + "res_id": self.id, + "view_mode": "form", + "target": "new", + "context": self.env.context, + } + + # ------------------------------------------------------------------ + # Preview-line constructors + # ------------------------------------------------------------------ + + @staticmethod + def _line_vals_empty(uin, status, error_message): + return { + "uin": uin, + "status": status, + "error_message": error_message, + "given_name": "", + "surname": "", + "sex": "", + "birth_date": False, + "already_exists": False, + "existing_partner_id": False, + } + + @staticmethod + def _line_vals_from_payload(uin, payload): + demo = payload.get("demographic_info") or {} + name = demo.get("name") or {} + birth = demo.get("birth_date") or False + return { + "uin": uin, + "status": "matched", + "given_name": name.get("given_name") or "", + "surname": name.get("surname") or "", + "sex": demo.get("sex") or "", + "birth_date": birth, + "already_exists": False, + "existing_partner_id": False, + "error_message": "", + } + + +class SppDciSrImportWizardLine(models.TransientModel): + _name = "spp.dci.sr.import.wizard.line" + _description = "Preview row for the SR-import wizard" + _order = "uin" + + wizard_id = fields.Many2one( + "spp.dci.sr.import.wizard", + required=True, + ondelete="cascade", + ) + + uin = fields.Char(string="UIN", required=True) + status = fields.Selection( + [ + ("matched", "Matched"), + ("not_found", "Not Found"), + ("error", "Error"), + ], + required=True, + ) + + given_name = fields.Char(string="Given Name") + surname = fields.Char(string="Surname") + sex = fields.Char(string="Sex") + birth_date = fields.Date(string="Birth Date") + + already_exists = fields.Boolean( + string="Already on SP", + help="True when a partner with this UIN already exists on the SP. Such rows are skipped on import.", + ) + existing_partner_id = fields.Many2one( + "res.partner", + string="Existing Partner", + help="The partner record this UIN already points at on the SP.", + ) + + selected = fields.Boolean( + string="Import?", + default=False, + help="When checked AND status='matched' AND not already_exists, the row is imported on the next Import step.", + ) + + error_message = fields.Char(string="Error", help="Truncated error text for status='error' rows.") diff --git a/spp_dci_openspp_dr/README.rst b/spp_dci_openspp_dr/README.rst new file mode 100644 index 00000000..30185355 --- /dev/null +++ b/spp_dci_openspp_dr/README.rst @@ -0,0 +1,230 @@ +=============================== +OpenSPP DCI — OpenSPP-DR Preset +=============================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:c0ad974e0b74230356379cf7aa96f0182351fd6531288e0b674498e0eaa63eb1 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OpenSPP%2FOpenSPP2-lightgray.png?logo=github + :target: https://github.com/OpenSPP/OpenSPP2/tree/19.0/spp_dci_openspp_dr + :alt: OpenSPP/OpenSPP2 + +|badge1| |badge2| |badge3| + +Permanent SP-side preset that points the CEL bridge at an OpenSPP-DR +(Disability Registry) instance. Ships pre-configured +``spp.dci.data.source``, ``spp.data.provider``, and the +``has_disability`` CEL variable binding so an SP-side OpenSPP deployment +can ask a sibling OpenSPP-DR for disability data over DCI out of the +box. + +This is the SP-side counterpart to ``spp_dci_server_disability`` (which +runs on the DR instance). Install this preset on the SP instance; +install ``spp_dci_server_disability`` on the DR instance. + +What this module ships +~~~~~~~~~~~~~~~~~~~~~~ + ++-----------------------------------+----------------------------------+ +| Record | Purpose | ++===================================+==================================+ +| ``spp.dci.data.source`` | DCI data source: base URL, | +| 'openspp_dr' | sender ID, registry_type=DR | ++-----------------------------------+----------------------------------+ +| ``spp.data.provider`` | CEL-side provider linked to the | +| 'openspp_dr' | DCI source | ++-----------------------------------+----------------------------------+ +| ``spp_studio.var_has_disability`` | The semantic ``has_disability`` | +| (override) | CEL accessor, repointed at the | +| | DR provider | ++-----------------------------------+----------------------------------+ +| ``OpenSPPDRService`` | DR-shaped lookup: partner | +| | identifier → OpenSPP-DR record | +| | at ``data.reg_records[0]`` | ++-----------------------------------+----------------------------------+ +| Dispatcher override | Routes ``vendor=openspp`` DR | +| | sources to ``OpenSPPDRService`` | +| | instead of upstream | +| | ``DRService`` | ++-----------------------------------+----------------------------------+ + +The CEL accessor stays vendor-neutral (``has_disability``, per ADR-023 +§1a). The OpenSPP-DR-ness lives only in the +data-source/provider/dispatcher-override records — never in the CEL +surface. + +Why the vendor override exists +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Upstream ``spp_dci_client_dr.DRService`` reads disability fields from +``data`` directly, but the SPDCI spec (and our DR server) put records at +``data.reg_records[0]``. Until DRService is fixed upstream, this +preset's ``OpenSPPDRService`` takes ownership of the response unwrap. +Clearing the ``vendor`` field on the data source returns the variable to +the upstream handler. + +See Also +~~~~~~~~ + +- ADR-024 — federated demo topology +- ``spp_dci_server_disability`` — DR-side companion module +- ``spp_cel_dci_bridge`` — registry-agnostic infrastructure +- ``spp_dci_openg2p`` — analogous SR-side preset for OpenG2P + +.. IMPORTANT:: + This is an alpha version, the data model and design can change at any time without warning. + Only for development or testing purpose, do not use in production. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +Launching the DR container +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The DR runs as a separate OpenSPP container alongside the SP. From the +repo root: + +.. code:: bash + + ./spp start # SP via the project's CLI + docker compose -f docker-compose.dr.yml up -d # DR standalone + +The DR joins the SP project's existing Docker network +(``openspp2_openspp`` by default) so the SP can reach it at +``http://openspp-dr:8069`` over the in-network DNS name. The host can +browse the DR UI at ``http://localhost:8070`` (admin/admin). If your SP +project is named something other than ``openspp2``, set +``OPENSPP_NETWORK=_openspp`` before launching. + +After installing this module +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The preset auto-creates a DCI data source, CEL provider, and +``has_disability`` variable binding wired against +``http://openspp-dr:8069/dci_api/v1/disability/registry/sync/search`` +(the docker-compose default for the demo). + +1. Navigate to **Custom > DCI > Configuration > Data Sources**. +2. Open the ``openspp_dr`` data source. +3. Verify (or adjust) **Base URL** — defaults to + ``http://openspp-dr:8069``. For a non-Docker deployment, replace with + the real hostname. +4. **Sender ID** / **Receiver ID** — placeholders are pre-populated. + Replace with what the DR operator expects. +5. Click **Test Connection**. State should flip to ``Active``. + +For real deployments, change ``auth_type`` to ``oauth2`` and populate +``oauth2_token_url``, ``oauth2_client_id``, ``oauth2_client_secret``. +Attach a DCI Signing Key under **Custom > DCI > Configuration > Signing +Keys** if the deployment requires signed messages. + +Required dev-mode flags on the DR for the demo +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The DR's signature + bearer-token middleware blocks +unsigned/unauthenticated requests by default. For the demo (where the SP +sends unsigned envelopes with no bearer token), set TWO system +parameters on the DR's ``openspp_dr`` database — both are required, +either alone is insufficient: + +.. code:: bash + + docker compose -f docker-compose.dr.yml exec openspp-dr odoo shell -d openspp_dr --no-http + >>> env['ir.config_parameter'].sudo().set_param('dci.allow_unsigned_requests', 'true') + >>> env['ir.config_parameter'].sudo().set_param('dci.bypass_bearer_auth', 'true') + >>> env.cr.commit() + +Then restart the DR +(``docker compose -f docker-compose.dr.yml restart openspp-dr``). On the +first request, the DR log emits a one-time +``CRITICAL: SECURITY WARNING: DCI signature verification is DISABLED!`` +line — that confirms the bypass is active. **Production deployments must +leave both at ``false``** and register the SP's public key via the DR's +DCI Sender Registry. + +UIN vocabulary code +~~~~~~~~~~~~~~~~~~~ + +This preset does NOT seed the UIN code on the system +``urn:openspp:vocab:id-type`` vocabulary. The collision against +``spp_dci_openg2p`` (which also seeds UIN) forced its removal — see the +``spp_dci_openspp_dr`` Phase fix commit. On a fresh SP database, install +``spp_dci_openg2p`` first (it owns the UIN seed), or seed UIN manually +before installing this preset. The tests use ``get_or_create_local`` to +be resilient either way. + +Demo data: how to make partners look up the right DR record +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The dispatcher's ``OpenSPPDRService._get_partner_identifier`` priority +order picks the SP-side partner's first matching reg_id type: + +:: + + UIN > DRN > NATIONAL_ID > NID > (first available) + +Tag your SP-side test partners with one of these identifier types using +a value that matches a reg_id on the DR-side partner. The DR's +``DisabilitySearchService`` looks up partners by +``spp.registry.id.value``, so the same value must exist on both sides. + +When upstream DRService is fixed +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The vendor-specific path is opt-in. If ``spp_dci_client_dr.DRService`` +ever starts unwrapping ``data.reg_records[0]`` correctly, clear the +``vendor`` field on the data source. The dispatcher's override falls +through to upstream ``_handler_dr`` → ``DRService`` automatically — no +code change required. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* OpenSPP.org + +Maintainers +----------- + +.. |maintainer-jeremi| image:: https://github.com/jeremi.png?size=40px + :target: https://github.com/jeremi + :alt: jeremi +.. |maintainer-gonzalesedwin1123| image:: https://github.com/gonzalesedwin1123.png?size=40px + :target: https://github.com/gonzalesedwin1123 + :alt: gonzalesedwin1123 + +Current maintainers: + +|maintainer-jeremi| |maintainer-gonzalesedwin1123| + +This module is part of the `OpenSPP/OpenSPP2 `_ project on GitHub. + +You are welcome to contribute. \ No newline at end of file diff --git a/spp_dci_openspp_dr/__init__.py b/spp_dci_openspp_dr/__init__.py new file mode 100644 index 00000000..64a5802b --- /dev/null +++ b/spp_dci_openspp_dr/__init__.py @@ -0,0 +1,113 @@ +import logging + +from . import models +from . import services + +_logger = logging.getLogger(__name__) + + +# Fields the preset insists on every install/upgrade. Anything else on +# the variable (labels, descriptions, category) is left to whoever last +# edited it. +_EXPECTED_BINDING_FIELDS = ( + "source_type", + "source_field", + "external_provider_id", + "dci_attribute_path", + "cache_strategy", + "cache_ttl_seconds", + "external_failure_policy", + "state", + "active", +) + + +def post_init_hook(env): + """Re-assert the DCI binding on spp_studio.var_has_disability. + + Runs on every install AND upgrade of this module (Odoo invokes + post_init_hook on -i and -u). Detects drift on the canonical + has_disability variable and rewrites the necessary fields so the + bridge dispatcher can route it to the OpenSPP-DR instance. + + Why this exists vs. just trusting the data XML override: + + 1. The data XML uses noupdate="1", which Odoo honours by setting + noupdate=True on the ir.model.data entry. On subsequent + upgrades of THIS module, the XML is skipped — but operators + may have clobbered the binding manually, or another module's + data load may have reset it. The hook is the one place that + always runs on -u and can restore drift. + + 2. spp_studio's standard_variables.xml creates the record in + DRAFT state by default. The preset must explicitly activate it + so it participates in the cache pre-warm (`active=True`) and + in the CEL resolver's symbol lookup (`state='active'`). The + XML data load doesn't reliably push it through the state + machine. + + 3. If the data XML failed to apply for any reason (load-order + issue, transient validation error during -i), the hook is the + safety net that catches it. + """ + variable = env.ref("spp_studio.var_has_disability", raise_if_not_found=False) + if not variable: + _logger.warning( + "spp_studio.var_has_disability not found during post_init_hook; " + "skipping DCI binding re-assert. Install spp_studio first." + ) + return + + provider = env.ref( + "spp_dci_openspp_dr.openspp_dr_provider", + raise_if_not_found=False, + ) + if not provider: + _logger.error( + "spp_dci_openspp_dr.openspp_dr_provider not found; cannot " + "re-assert DCI binding on has_disability. Verify " + "data/openspp_dr_data_provider.xml loaded successfully." + ) + return + + expected = { + "source_type": "external", + "source_field": False, + "external_provider_id": provider.id, + "dci_attribute_path": "has_disability", + "cache_strategy": "ttl", + "cache_ttl_seconds": 300, + "external_failure_policy": "null", + # State + active control whether the variable participates in + # the resolver / precompute pipeline: + # - state='active' is the workflow status used by spp_studio's + # lifecycle and CEL symbol visibility + # - active=True is the Odoo archived/unarchived flag used by + # precompute_cached_variables' search domain + "state": "active", + "active": True, + } + + drift = {} + for field in _EXPECTED_BINDING_FIELDS: + current = variable[field] + if hasattr(current, "id"): + current_value = current.id if current else False + else: + current_value = current + if current_value != expected[field]: + drift[field] = expected[field] + + if drift: + # Bypass workflow validation by writing state directly. + # _pre_activate would reject draft -> active if source_type is + # 'field' and the field is missing; we're flipping source_type + # and state in the same write so that path doesn't apply. + variable.write(expected) + _logger.info( + "Re-asserted DCI binding on spp_studio.var_has_disability: %d field(s) restored (%s)", + len(drift), + ", ".join(drift.keys()), + ) + else: + _logger.info("spp_studio.var_has_disability DCI binding already correct; no changes.") diff --git a/spp_dci_openspp_dr/__manifest__.py b/spp_dci_openspp_dr/__manifest__.py new file mode 100644 index 00000000..ccb1b916 --- /dev/null +++ b/spp_dci_openspp_dr/__manifest__.py @@ -0,0 +1,30 @@ +{ # pylint: disable=pointless-statement + "name": "OpenSPP DCI — OpenSPP-DR Preset", + "summary": ( + "Pre-configured DCI data source, provider, and CEL variable binding " + "for an OpenSPP-DR (Disability Registry) instance." + ), + "version": "19.0.1.0.0", + "category": "OpenSPP/Integration", + "author": "OpenSPP.org", + "website": "https://github.com/OpenSPP/OpenSPP2", + "license": "LGPL-3", + "development_status": "Alpha", + "maintainers": ["jeremi", "gonzalesedwin1123"], + "depends": [ + "spp_cel_dci_bridge", + "spp_dci_client_dr", + "spp_vocabulary", + ], + "external_dependencies": {"python": []}, + "data": [ + "security/ir.model.access.csv", + "data/openspp_dr_data_source.xml", + "data/openspp_dr_data_provider.xml", + "data/openspp_dr_cel_variable.xml", + ], + "installable": True, + "application": False, + "auto_install": False, + "post_init_hook": "post_init_hook", +} diff --git a/spp_dci_openspp_dr/data/openspp_dr_cel_variable.xml b/spp_dci_openspp_dr/data/openspp_dr_cel_variable.xml new file mode 100644 index 00000000..4e1c851b --- /dev/null +++ b/spp_dci_openspp_dr/data/openspp_dr_cel_variable.xml @@ -0,0 +1,36 @@ + + + + + external + res.partner + + + has_disability + ttl + 300 + null + active + + + diff --git a/spp_dci_openspp_dr/data/openspp_dr_data_provider.xml b/spp_dci_openspp_dr/data/openspp_dr_data_provider.xml new file mode 100644 index 00000000..7ff6637f --- /dev/null +++ b/spp_dci_openspp_dr/data/openspp_dr_data_provider.xml @@ -0,0 +1,15 @@ + + + + + OpenSPP Disability Registry + openspp_dr + + + 86400 + + diff --git a/spp_dci_openspp_dr/data/openspp_dr_data_source.xml b/spp_dci_openspp_dr/data/openspp_dr_data_source.xml new file mode 100644 index 00000000..c951a470 --- /dev/null +++ b/spp_dci_openspp_dr/data/openspp_dr_data_source.xml @@ -0,0 +1,48 @@ + + + + + OpenSPP Disability Registry + openspp_dr + DR + openspp + http://openspp-dr:8069 + /dci_api/v1/disability/registry/sync/search + none + openspp-sp.demo + openspp-dr.demo + + 30 + + draft + OpenSPP-DR demo preset. Routed through the bridge's _handler_dr by registry_type=DR; the vendor-specific OpenSPPDRService is selected by vendor=openspp. See ADR-024 for the federated topology. + + diff --git a/spp_dci_openspp_dr/models/__init__.py b/spp_dci_openspp_dr/models/__init__.py new file mode 100644 index 00000000..9176489f --- /dev/null +++ b/spp_dci_openspp_dr/models/__init__.py @@ -0,0 +1,2 @@ +from . import dci_data_source +from . import dci_dispatcher diff --git a/spp_dci_openspp_dr/models/dci_data_source.py b/spp_dci_openspp_dr/models/dci_data_source.py new file mode 100644 index 00000000..5380b22d --- /dev/null +++ b/spp_dci_openspp_dr/models/dci_data_source.py @@ -0,0 +1,18 @@ +from odoo import fields, models + + +class DCIDataSource(models.Model): + """Register the OpenSPP-DR vendor adapter on the shared vendor selection. + + The ``vendor`` field is defined by ``spp_cel_dci_bridge``; this + preset only adds its own selection value. Once set on a data source, + the bridge dispatcher delegates to ``OpenSPPDRService`` for the DR + handler. See ADR-024 for the federated demo topology. + """ + + _inherit = "spp.dci.data.source" + + vendor = fields.Selection( + selection_add=[("openspp", "OpenSPP")], + ondelete={"openspp": "set null"}, + ) diff --git a/spp_dci_openspp_dr/models/dci_dispatcher.py b/spp_dci_openspp_dr/models/dci_dispatcher.py new file mode 100644 index 00000000..c9fce983 --- /dev/null +++ b/spp_dci_openspp_dr/models/dci_dispatcher.py @@ -0,0 +1,85 @@ +"""Bridge dispatcher override for vendor=openspp DR sources. + +When a CEL variable's DCI data source has ``vendor='openspp'`` AND +``registry_type='DR'``, route the DR handler to ``OpenSPPDRService`` +instead of the upstream ``DRService``. The handler is otherwise +structurally identical to the bridge's other handlers: per-subject +loop, audit row shape, attribute-path extraction. + +Why this override exists: upstream ``DRService._extract_disability_data`` +reads disability fields from ``data`` directly, but the SPDCI spec +(and our ``spp_dci_server_disability`` implementation) place records +at ``data.reg_records[0]``. Until upstream is fixed, this adapter owns +the response unwrap. + +Clearing the ``vendor`` field on the data source returns the variable +to upstream ``DRService`` — useful once upstream is fixed and the +override becomes unnecessary. +""" + +import logging +import time + +from odoo import models + +from odoo.addons.spp_cel_dci_bridge.exceptions import DCIConfigurationError + +_logger = logging.getLogger(__name__) + + +class DCIDispatcher(models.AbstractModel): + _inherit = "spp.cel.dci.dispatcher" + + def _handler_dr(self, variable, source, subject_ids, period_key): + if getattr(source, "vendor", False) == "openspp": + return self._handler_openspp_dr(variable, source, subject_ids, period_key) + return super()._handler_dr(variable, source, subject_ids, period_key) + + def _handler_openspp_dr(self, variable, source, subject_ids, period_key): + """DR handler backed by OpenSPPDRService. + + Structurally identical to the bridge's other handlers: per-subject + loop, one audit row per subject, attribute extraction via + variable.dci_attribute_path, error swallow with audit row capture. + """ + try: + from ..services.openspp_dr_service import OpenSPPDRService + except ImportError as e: + raise DCIConfigurationError( + f"OpenSPP-DR service is not importable; cannot fetch " + f"variable {variable.name}. Reinstall spp_dci_openspp_dr." + ) from e + + service = OpenSPPDRService(self.env, data_source_code=source.code) + Partner = self.env["res.partner"] + partners = Partner.browse(subject_ids).exists() + path = variable.dci_attribute_path + + result = {} + for partner in partners: + started = time.monotonic() + try: + payload = service.get_partner_record(partner) + except Exception as e: + self._record_audit(variable, source, partner.id, "error", started, error_message=str(e)) + _logger.warning( + "OpenSPP-DR fetch failed for partner %d (var=%s): %s", + partner.id, + variable.name, + e, + ) + continue + + if payload is None: + self._record_audit(variable, source, partner.id, "not_found", started) + continue + + value = self._extract_by_path(payload, path) + if value is None: + self._record_audit(variable, source, partner.id, "not_found", started) + continue + + result[partner.id] = value + self._record_audit(variable, source, partner.id, "ok", started) + + return result diff --git a/spp_dci_openspp_dr/pyproject.toml b/spp_dci_openspp_dr/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/spp_dci_openspp_dr/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/spp_dci_openspp_dr/readme/CONFIGURE.md b/spp_dci_openspp_dr/readme/CONFIGURE.md new file mode 100644 index 00000000..70dd54e1 --- /dev/null +++ b/spp_dci_openspp_dr/readme/CONFIGURE.md @@ -0,0 +1,59 @@ +### Launching the DR container + +The DR runs as a separate OpenSPP container alongside the SP. From the +repo root: + +```bash +./spp start # SP via the project's CLI +docker compose -f docker-compose.dr.yml up -d # DR standalone +``` + +The DR joins the SP project's existing Docker network (`openspp2_openspp` +by default) so the SP can reach it at `http://openspp-dr:8069` over the +in-network DNS name. The host can browse the DR UI at +`http://localhost:8070` (admin/admin). If your SP project is named +something other than `openspp2`, set `OPENSPP_NETWORK=_openspp` +before launching. + +### After installing this module + +The preset auto-creates a DCI data source, CEL provider, and `has_disability` variable binding wired against `http://openspp-dr:8069/dci_api/v1/disability/registry/sync/search` (the docker-compose default for the demo). + +1. Navigate to **Custom > DCI > Configuration > Data Sources**. +2. Open the `openspp_dr` data source. +3. Verify (or adjust) **Base URL** — defaults to `http://openspp-dr:8069`. For a non-Docker deployment, replace with the real hostname. +4. **Sender ID** / **Receiver ID** — placeholders are pre-populated. Replace with what the DR operator expects. +5. Click **Test Connection**. State should flip to `Active`. + +For real deployments, change `auth_type` to `oauth2` and populate `oauth2_token_url`, `oauth2_client_id`, `oauth2_client_secret`. Attach a DCI Signing Key under **Custom > DCI > Configuration > Signing Keys** if the deployment requires signed messages. + +### Required dev-mode flags on the DR for the demo + +The DR's signature + bearer-token middleware blocks unsigned/unauthenticated requests by default. For the demo (where the SP sends unsigned envelopes with no bearer token), set TWO system parameters on the DR's `openspp_dr` database — both are required, either alone is insufficient: + +```bash +docker compose -f docker-compose.dr.yml exec openspp-dr odoo shell -d openspp_dr --no-http +>>> env['ir.config_parameter'].sudo().set_param('dci.allow_unsigned_requests', 'true') +>>> env['ir.config_parameter'].sudo().set_param('dci.bypass_bearer_auth', 'true') +>>> env.cr.commit() +``` + +Then restart the DR (`docker compose -f docker-compose.dr.yml restart openspp-dr`). On the first request, the DR log emits a one-time `CRITICAL: SECURITY WARNING: DCI signature verification is DISABLED!` line — that confirms the bypass is active. **Production deployments must leave both at `false`** and register the SP's public key via the DR's DCI Sender Registry. + +### UIN vocabulary code + +This preset does NOT seed the UIN code on the system `urn:openspp:vocab:id-type` vocabulary. The collision against `spp_dci_openg2p` (which also seeds UIN) forced its removal — see the `spp_dci_openspp_dr` Phase fix commit. On a fresh SP database, install `spp_dci_openg2p` first (it owns the UIN seed), or seed UIN manually before installing this preset. The tests use `get_or_create_local` to be resilient either way. + +### Demo data: how to make partners look up the right DR record + +The dispatcher's `OpenSPPDRService._get_partner_identifier` priority order picks the SP-side partner's first matching reg_id type: + +``` +UIN > DRN > NATIONAL_ID > NID > (first available) +``` + +Tag your SP-side test partners with one of these identifier types using a value that matches a reg_id on the DR-side partner. The DR's `DisabilitySearchService` looks up partners by `spp.registry.id.value`, so the same value must exist on both sides. + +### When upstream DRService is fixed + +The vendor-specific path is opt-in. If `spp_dci_client_dr.DRService` ever starts unwrapping `data.reg_records[0]` correctly, clear the `vendor` field on the data source. The dispatcher's override falls through to upstream `_handler_dr` → `DRService` automatically — no code change required. diff --git a/spp_dci_openspp_dr/readme/DESCRIPTION.md b/spp_dci_openspp_dr/readme/DESCRIPTION.md new file mode 100644 index 00000000..2c49a6f6 --- /dev/null +++ b/spp_dci_openspp_dr/readme/DESCRIPTION.md @@ -0,0 +1,26 @@ +Permanent SP-side preset that points the CEL bridge at an OpenSPP-DR (Disability Registry) instance. Ships pre-configured `spp.dci.data.source`, `spp.data.provider`, and the `has_disability` CEL variable binding so an SP-side OpenSPP deployment can ask a sibling OpenSPP-DR for disability data over DCI out of the box. + +This is the SP-side counterpart to `spp_dci_server_disability` (which runs on the DR instance). Install this preset on the SP instance; install `spp_dci_server_disability` on the DR instance. + +### What this module ships + +| Record | Purpose | +| --------------------------------------- | ------------------------------------------------------------------------ | +| `spp.dci.data.source` 'openspp_dr' | DCI data source: base URL, sender ID, registry_type=DR | +| `spp.data.provider` 'openspp_dr' | CEL-side provider linked to the DCI source | +| `spp_studio.var_has_disability` (override) | The semantic `has_disability` CEL accessor, repointed at the DR provider | +| `OpenSPPDRService` | DR-shaped lookup: partner identifier → OpenSPP-DR record at `data.reg_records[0]` | +| Dispatcher override | Routes `vendor=openspp` DR sources to `OpenSPPDRService` instead of upstream `DRService` | + +The CEL accessor stays vendor-neutral (`has_disability`, per ADR-023 §1a). The OpenSPP-DR-ness lives only in the data-source/provider/dispatcher-override records — never in the CEL surface. + +### Why the vendor override exists + +Upstream `spp_dci_client_dr.DRService` reads disability fields from `data` directly, but the SPDCI spec (and our DR server) put records at `data.reg_records[0]`. Until DRService is fixed upstream, this preset's `OpenSPPDRService` takes ownership of the response unwrap. Clearing the `vendor` field on the data source returns the variable to the upstream handler. + +### See Also + +- ADR-024 — federated demo topology +- `spp_dci_server_disability` — DR-side companion module +- `spp_cel_dci_bridge` — registry-agnostic infrastructure +- `spp_dci_openg2p` — analogous SR-side preset for OpenG2P diff --git a/spp_dci_openspp_dr/security/ir.model.access.csv b/spp_dci_openspp_dr/security/ir.model.access.csv new file mode 100644 index 00000000..97dd8b91 --- /dev/null +++ b/spp_dci_openspp_dr/security/ir.model.access.csv @@ -0,0 +1 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink diff --git a/spp_dci_openspp_dr/services/__init__.py b/spp_dci_openspp_dr/services/__init__.py new file mode 100644 index 00000000..b4b03731 --- /dev/null +++ b/spp_dci_openspp_dr/services/__init__.py @@ -0,0 +1 @@ +from . import openspp_dr_service diff --git a/spp_dci_openspp_dr/services/openspp_dr_service.py b/spp_dci_openspp_dr/services/openspp_dr_service.py new file mode 100644 index 00000000..0f04dfde --- /dev/null +++ b/spp_dci_openspp_dr/services/openspp_dr_service.py @@ -0,0 +1,140 @@ +"""OpenSPP-DR Disability Registry client service. + +Queries the sibling OpenSPP-DR instance over DCI (``spp_dci_server_disability`` +endpoint at ``/dci_api/v1/disability/registry/sync/search``) and returns the raw +``data.reg_records[0]`` dict. The bridge dispatcher applies the variable's +``dci_attribute_path`` to that dict — so the CEL variable +``has_disability`` extracts the wire-format ``has_disability`` field +without this service needing to know which. + +Why this exists rather than reusing upstream ``DRService``: + + The upstream ``spp_dci_client_dr.DRService`` reads disability fields + from the search response's ``data`` object directly, but the SPDCI + spec (and our OpenSPP-DR server) put records at + ``data.reg_records[0]``. Until DRService is fixed upstream, this + adapter takes ownership of the response unwrap so the bridge sees + the correct wire-format keys. +""" + +import logging + +from odoo.exceptions import UserError, ValidationError + +from odoo.addons.spp_dci_client.services import DCIClient + +_logger = logging.getLogger(__name__) + +# Identifier priority for resolving which reg_id value to send. Matches +# upstream DRService's priority so swapping between SR and DR sources +# doesn't change which identifier gets sent first. +IDENTIFIER_PRIORITY = ("UIN", "DRN", "NATIONAL_ID", "NID") + + +class OpenSPPDRService: + """Service for querying an OpenSPP-DR instance over DCI.""" + + def __init__(self, env, data_source_code): + self.env = env + self.data_source_code = data_source_code + self.data_source = env["spp.dci.data.source"].get_by_code(data_source_code) + # Upstream DCIClient is sufficient — OpenSPP-DR speaks vanilla + # SPDCI; no query/envelope quirks to absorb (unlike the + # OpenG2P-vendor adapter). + self.client = DCIClient(self.data_source, env) + + # ------------------------------------------------------------------ + # Public API — surface called by the bridge dispatcher + # ------------------------------------------------------------------ + + def get_partner_record(self, partner) -> dict | None: + """Look up ``partner`` in the OpenSPP-DR and return the first matching record. + + Returns: + dict: The raw OpenSPP-DR record from ``data.reg_records[0]`` + if a match was found. + None: if the partner has no resolvable identifier OR the + OpenSPP-DR returned no record (status='rjct' with + REG-ERR-001 / empty ``search_response``). + + Raises: + UserError: If the request fails for non-not-found reasons + (network error, server 5xx, malformed envelope). The + dispatcher loop catches these per-subject and records + them as audit ``result=error`` rows. + """ + if not partner: + raise ValidationError(self.env._("Partner is required")) + + identifier = self._get_partner_identifier(partner) + if not identifier: + _logger.warning( + "No suitable identifier found for partner ID=%s — skipping OpenSPP-DR query", + partner.id, + ) + return None + + id_type, id_value = identifier + _logger.info( + "Querying OpenSPP-DR for partner ID=%s using %s:%s", + partner.id, + id_type, + id_value, + ) + + try: + response = self.client.search_by_id( + identifier_type=id_type, + identifier_value=id_value, + record_type="PERSON", + page=1, + page_size=1, + ) + except Exception as e: + _logger.error("OpenSPP-DR fetch failed: %s", e, exc_info=True) + raise UserError(self.env._("Failed to query OpenSPP-DR: %s", e)) from e + + return self._extract_first_record(response) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _get_partner_identifier(self, partner): + """Return ``(id_type_code, id_value)`` for the partner's highest- + priority matching reg_id, or None if no usable id was found.""" + reg_ids = self.env["spp.registry.id"].search([("partner_id", "=", partner.id)]) + for id_type in IDENTIFIER_PRIORITY: + for reg_id in reg_ids: + if reg_id.id_type_id.code == id_type and reg_id.value: + return (id_type, reg_id.value) + if reg_ids: + first_id = reg_ids[0] + if first_id.value and first_id.id_type_id: + return (first_id.id_type_id.code, first_id.value) + return None + + @staticmethod + def _extract_first_record(response): + """Unwrap the OpenSPP-DR response envelope to the first registry record. + + SPDCI shape: + + response.message.search_response[i].data.reg_records[j] + + Returns the first matching record across the response, or None + if no records were found (status='rjct' / empty search_response). + """ + if not isinstance(response, dict): + return None + message = response.get("message") or {} + search_responses = message.get("search_response") or [] + for sr in search_responses: + data = sr.get("data") or {} + if not isinstance(data, dict): + continue + reg_records = data.get("reg_records") or [] + for record in reg_records: + if isinstance(record, dict): + return record + return None diff --git a/spp_dci_openspp_dr/static/description/index.html b/spp_dci_openspp_dr/static/description/index.html new file mode 100644 index 00000000..1fe401c4 --- /dev/null +++ b/spp_dci_openspp_dr/static/description/index.html @@ -0,0 +1,574 @@ + + + + + +OpenSPP DCI — OpenSPP-DR Preset + + + +
+

OpenSPP DCI — OpenSPP-DR Preset

+ + +

Alpha License: LGPL-3 OpenSPP/OpenSPP2

+

Permanent SP-side preset that points the CEL bridge at an OpenSPP-DR +(Disability Registry) instance. Ships pre-configured +spp.dci.data.source, spp.data.provider, and the +has_disability CEL variable binding so an SP-side OpenSPP deployment +can ask a sibling OpenSPP-DR for disability data over DCI out of the +box.

+

This is the SP-side counterpart to spp_dci_server_disability (which +runs on the DR instance). Install this preset on the SP instance; +install spp_dci_server_disability on the DR instance.

+
+

What this module ships

+ ++++ + + + + + + + + + + + + + + + + + + + + + + +
RecordPurpose
spp.dci.data.source +‘openspp_dr’DCI data source: base URL, +sender ID, registry_type=DR
spp.data.provider +‘openspp_dr’CEL-side provider linked to the +DCI source
spp_studio.var_has_disability +(override)The semantic has_disability +CEL accessor, repointed at the +DR provider
OpenSPPDRServiceDR-shaped lookup: partner +identifier → OpenSPP-DR record +at data.reg_records[0]
Dispatcher overrideRoutes vendor=openspp DR +sources to OpenSPPDRService +instead of upstream +DRService
+

The CEL accessor stays vendor-neutral (has_disability, per ADR-023 +§1a). The OpenSPP-DR-ness lives only in the +data-source/provider/dispatcher-override records — never in the CEL +surface.

+
+
+

Why the vendor override exists

+

Upstream spp_dci_client_dr.DRService reads disability fields from +data directly, but the SPDCI spec (and our DR server) put records at +data.reg_records[0]. Until DRService is fixed upstream, this +preset’s OpenSPPDRService takes ownership of the response unwrap. +Clearing the vendor field on the data source returns the variable to +the upstream handler.

+
+
+

See Also

+
    +
  • ADR-024 — federated demo topology
  • +
  • spp_dci_server_disability — DR-side companion module
  • +
  • spp_cel_dci_bridge — registry-agnostic infrastructure
  • +
  • spp_dci_openg2p — analogous SR-side preset for OpenG2P
  • +
+
+

Important

+

This is an alpha version, the data model and design can change at any time without warning. +Only for development or testing purpose, do not use in production.

+
+

Table of contents

+ + +
+
+

Launching the DR container

+

The DR runs as a separate OpenSPP container alongside the SP. From the +repo root:

+
+./spp start                                      # SP via the project's CLI
+docker compose -f docker-compose.dr.yml up -d    # DR standalone
+
+

The DR joins the SP project’s existing Docker network +(openspp2_openspp by default) so the SP can reach it at +http://openspp-dr:8069 over the in-network DNS name. The host can +browse the DR UI at http://localhost:8070 (admin/admin). If your SP +project is named something other than openspp2, set +OPENSPP_NETWORK=<project>_openspp before launching.

+
+
+

After installing this module

+

The preset auto-creates a DCI data source, CEL provider, and +has_disability variable binding wired against +http://openspp-dr:8069/dci_api/v1/disability/registry/sync/search +(the docker-compose default for the demo).

+
    +
  1. Navigate to Custom > DCI > Configuration > Data Sources.
  2. +
  3. Open the openspp_dr data source.
  4. +
  5. Verify (or adjust) Base URL — defaults to +http://openspp-dr:8069. For a non-Docker deployment, replace with +the real hostname.
  6. +
  7. Sender ID / Receiver ID — placeholders are pre-populated. +Replace with what the DR operator expects.
  8. +
  9. Click Test Connection. State should flip to Active.
  10. +
+

For real deployments, change auth_type to oauth2 and populate +oauth2_token_url, oauth2_client_id, oauth2_client_secret. +Attach a DCI Signing Key under Custom > DCI > Configuration > Signing +Keys if the deployment requires signed messages.

+
+
+

Required dev-mode flags on the DR for the demo

+

The DR’s signature + bearer-token middleware blocks +unsigned/unauthenticated requests by default. For the demo (where the SP +sends unsigned envelopes with no bearer token), set TWO system +parameters on the DR’s openspp_dr database — both are required, +either alone is insufficient:

+
+docker compose -f docker-compose.dr.yml exec openspp-dr odoo shell -d openspp_dr --no-http
+>>> env['ir.config_parameter'].sudo().set_param('dci.allow_unsigned_requests', 'true')
+>>> env['ir.config_parameter'].sudo().set_param('dci.bypass_bearer_auth', 'true')
+>>> env.cr.commit()
+
+

Then restart the DR +(docker compose -f docker-compose.dr.yml restart openspp-dr). On the +first request, the DR log emits a one-time +CRITICAL: SECURITY WARNING: DCI signature verification is DISABLED! +line — that confirms the bypass is active. Production deployments must +leave both at ``false`` and register the SP’s public key via the DR’s +DCI Sender Registry.

+
+
+

UIN vocabulary code

+

This preset does NOT seed the UIN code on the system +urn:openspp:vocab:id-type vocabulary. The collision against +spp_dci_openg2p (which also seeds UIN) forced its removal — see the +spp_dci_openspp_dr Phase fix commit. On a fresh SP database, install +spp_dci_openg2p first (it owns the UIN seed), or seed UIN manually +before installing this preset. The tests use get_or_create_local to +be resilient either way.

+
+
+

Demo data: how to make partners look up the right DR record

+

The dispatcher’s OpenSPPDRService._get_partner_identifier priority +order picks the SP-side partner’s first matching reg_id type:

+
+UIN > DRN > NATIONAL_ID > NID > (first available)
+
+

Tag your SP-side test partners with one of these identifier types using +a value that matches a reg_id on the DR-side partner. The DR’s +DisabilitySearchService looks up partners by +spp.registry.id.value, so the same value must exist on both sides.

+
+
+

When upstream DRService is fixed

+

The vendor-specific path is opt-in. If spp_dci_client_dr.DRService +ever starts unwrapping data.reg_records[0] correctly, clear the +vendor field on the data source. The dispatcher’s override falls +through to upstream _handler_drDRService automatically — no +code change required.

+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • OpenSPP.org
  • +
+
+
+

Maintainers

+

Current maintainers:

+

jeremi gonzalesedwin1123

+

This module is part of the OpenSPP/OpenSPP2 project on GitHub.

+

You are welcome to contribute.

+
+
+
+
+ + diff --git a/spp_dci_openspp_dr/tests/__init__.py b/spp_dci_openspp_dr/tests/__init__.py new file mode 100644 index 00000000..03be38db --- /dev/null +++ b/spp_dci_openspp_dr/tests/__init__.py @@ -0,0 +1,3 @@ +from . import test_dispatcher_routing +from . import test_install +from . import test_openspp_dr_service diff --git a/spp_dci_openspp_dr/tests/common.py b/spp_dci_openspp_dr/tests/common.py new file mode 100644 index 00000000..eb443d51 --- /dev/null +++ b/spp_dci_openspp_dr/tests/common.py @@ -0,0 +1,21 @@ +"""Test helpers shared across spp_dci_openspp_dr test cases.""" + + +def get_or_create_uin_code(env): + """Return the UIN vocabulary code, creating it if absent. + + The system ``urn:openspp:vocab:id-type`` vocabulary has + UNIQUE(vocabulary_id, code), so only one preset can seed UIN via + data XML. Tests need access to the code (to tag partner reg_ids) + regardless of which preset installed it — or whether any did. + + Uses ``get_or_create_local`` which is the supported runtime path + for adding codes to system vocabularies (ADR-016 country-extension + pattern). Returns whatever record matches first, marking newly + created ones with ``is_local=True``. + """ + return env["spp.vocabulary.code"].get_or_create_local( + namespace_uri="urn:openspp:vocab:id-type", + code="UIN", + display="UIN (Universal Identification Number)", + ) diff --git a/spp_dci_openspp_dr/tests/test_dispatcher_routing.py b/spp_dci_openspp_dr/tests/test_dispatcher_routing.py new file mode 100644 index 00000000..5070559f --- /dev/null +++ b/spp_dci_openspp_dr/tests/test_dispatcher_routing.py @@ -0,0 +1,129 @@ +"""End-to-end test: bridge dispatcher routes vendor=openspp DR sources to +the OpenSPP-DR service, and the result populates the dispatcher's return +dict for attribute-path extraction. +""" + +from unittest.mock import MagicMock, patch + +from odoo.tests.common import TransactionCase, tagged + +from .common import get_or_create_uin_code + + +def make_dr_response_for_uin(uin_to_records): + """Stateful search_by_id mock: response depends on the identifier_value.""" + + def _search_by_id(**kwargs): + value = kwargs.get("identifier_value", "") + records = uin_to_records.get(value, []) + if not records: + return {"message": {"search_response": []}} + return { + "message": { + "search_response": [ + { + "reference_id": "r1", + "timestamp": "2026-05-14T00:00:00Z", + "status": "succ", + "data": { + "reg_type": "DR", + "reg_record_type": "PWD_PERSON", + "reg_records": records, + }, + } + ] + } + } + + return _search_by_id + + +@tagged("post_install", "-at_install") +class TestDispatcherRoutesOpenSPPDR(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.id_type_uin = get_or_create_uin_code(cls.env) + cls.partner_pwd = cls.env["res.partner"].create( + {"name": "DR Partner", "is_registrant": True, "is_group": False} + ) + cls.env["spp.registry.id"].create( + { + "partner_id": cls.partner_pwd.id, + "id_type_id": cls.id_type_uin.id, + "value": "UIN-DR-1", + } + ) + cls.partner_unknown = cls.env["res.partner"].create( + {"name": "Unknown Partner", "is_registrant": True, "is_group": False} + ) + cls.env["spp.registry.id"].create( + { + "partner_id": cls.partner_unknown.id, + "id_type_id": cls.id_type_uin.id, + "value": "UIN-UNKNOWN", + } + ) + + cls.data_source = cls.env.ref("spp_dci_openspp_dr.openspp_dr_source") + cls.variable = cls.env.ref("spp_studio.var_has_disability") + + def test_data_source_has_vendor_openspp_and_registry_type_dr(self): + self.assertEqual(self.data_source.vendor, "openspp") + self.assertEqual(self.data_source.registry_type, "DR") + + @patch("odoo.addons.spp_dci_openspp_dr.services.openspp_dr_service.DCIClient") + def test_openspp_dr_handler_extracts_has_disability(self, mock_client_class): + """Partner with a matching DR record returns has_disability=True.""" + mock_client = MagicMock() + mock_client.search_by_id.side_effect = make_dr_response_for_uin({"UIN-DR-1": [{"has_disability": True}]}) + mock_client_class.return_value = mock_client + + result = self.env["spp.cel.dci.dispatcher"].fetch_values_for_variable( + self.variable, [self.partner_pwd.id], "current" + ) + + self.assertEqual(result, {self.partner_pwd.id: True}) + + @patch("odoo.addons.spp_dci_openspp_dr.services.openspp_dr_service.DCIClient") + def test_openspp_dr_handler_records_not_found_for_unknown_partner(self, mock_client_class): + mock_client = MagicMock() + mock_client.search_by_id.side_effect = make_dr_response_for_uin({}) + mock_client_class.return_value = mock_client + + result = self.env["spp.cel.dci.dispatcher"].fetch_values_for_variable( + self.variable, [self.partner_unknown.id], "current" + ) + + self.assertEqual(result, {}) + audits = self.env["spp.dci.fetch.audit"].search( + [ + ("variable_name", "=", "has_disability"), + ("subject_id", "=", self.partner_unknown.id), + ] + ) + self.assertEqual(audits.result, "not_found") + + @patch("odoo.addons.spp_dci_openspp_dr.services.openspp_dr_service.DCIClient") + def test_clearing_vendor_falls_back_to_upstream_dr_handler(self, mock_client_class): + """When vendor is cleared, the bridge's standard _handler_dr runs + — using upstream DRService. This is the migration test: vendor + opt-in / fall-back contract.""" + self.data_source.vendor = False + + # Patch upstream DCIClient used by DRService so we can verify it + # was called (and our adapter was not). + with patch("odoo.addons.spp_dci_client_dr.services.dr_service.DCIClient") as mock_upstream_class: + mock_upstream_client = MagicMock() + mock_upstream_client.search_by_id.return_value = {"message": {"search_response": []}} + mock_upstream_class.return_value = mock_upstream_client + + self.env["spp.cel.dci.dispatcher"].fetch_values_for_variable( + self.variable, [self.partner_pwd.id], "current" + ) + + # Our adapter must NOT have been used + mock_client_class.assert_not_called() + # Upstream WAS used + mock_upstream_class.assert_called_once() diff --git a/spp_dci_openspp_dr/tests/test_install.py b/spp_dci_openspp_dr/tests/test_install.py new file mode 100644 index 00000000..9d6f7bdf --- /dev/null +++ b/spp_dci_openspp_dr/tests/test_install.py @@ -0,0 +1,120 @@ +from unittest.mock import patch + +from odoo.tests.common import TransactionCase, tagged + +from odoo.addons.spp_dci_openspp_dr import post_init_hook + + +@tagged("post_install", "-at_install") +class TestOpenSPPDRPresetInstall(TransactionCase): + """Smoke test: the preset records exist after install and are linked correctly.""" + + def test_service_priority_first_is_uin(self): + """The SP-side service walks the partner's reg_ids by + IDENTIFIER_PRIORITY; UIN must be the first entry so the canonical + SPDCI identifier wins over national-registry-specific codes.""" + from odoo.addons.spp_dci_openspp_dr.services.openspp_dr_service import ( + IDENTIFIER_PRIORITY, + ) + + self.assertEqual(IDENTIFIER_PRIORITY[0], "UIN") + + def test_data_source_present(self): + source = self.env.ref("spp_dci_openspp_dr.openspp_dr_source") + self.assertEqual(source.code, "openspp_dr") + self.assertEqual(source.registry_type, "DR") + self.assertEqual(source.vendor, "openspp") + self.assertEqual(source.search_endpoint, "/dci_api/v1/disability/registry/sync/search") + self.assertTrue(source.active) + + def test_provider_links_to_data_source(self): + provider = self.env.ref("spp_dci_openspp_dr.openspp_dr_provider") + source = self.env.ref("spp_dci_openspp_dr.openspp_dr_source") + self.assertEqual(provider.code, "openspp_dr") + self.assertEqual(provider.dci_data_source_id, source) + self.assertTrue(provider.is_dci_backed) + + def test_cel_variable_rewired_to_dci_provider(self): + variable = self.env.ref("spp_studio.var_has_disability") + provider = self.env.ref("spp_dci_openspp_dr.openspp_dr_provider") + self.assertEqual(variable.name, "has_disability") + self.assertEqual(variable.cel_accessor, "has_disability") + self.assertEqual(variable.source_type, "external") + self.assertEqual(variable.value_type, "boolean") + self.assertEqual(variable.external_provider_id, provider) + self.assertEqual(variable.dci_attribute_path, "has_disability") + self.assertEqual(variable.cache_strategy, "ttl") + self.assertEqual(variable.cache_ttl_seconds, 300) + self.assertEqual(variable.external_failure_policy, "null") + self.assertFalse(variable.source_field) + self.assertEqual(variable.state, "active") + self.assertTrue(variable.active) + + def test_cel_accessor_is_semantic_not_vendor_named(self): + """ADR-023 §1a: CEL accessors must be vendor-neutral.""" + variable = self.env.ref("spp_studio.var_has_disability") + for forbidden in ("openspp_dr", "openspp-dr", "vendor"): + self.assertNotIn(forbidden, variable.cel_accessor.lower()) + self.assertNotIn(forbidden, variable.name.lower()) + + def test_post_init_hook_re_asserts_after_studio_reset(self): + """Simulate `-u spp_studio` resetting var_has_disability back to + its original source_type='field' state, then run our hook. The + hook must restore the DCI binding.""" + variable = self.env.ref("spp_studio.var_has_disability") + provider = self.env.ref("spp_dci_openspp_dr.openspp_dr_provider") + + variable.write( + { + "source_type": "field", + "source_model": "res.partner", + "source_field": "is_person_with_disability", + "external_provider_id": False, + "dci_attribute_path": False, + "cache_strategy": "none", + "external_failure_policy": "null", + "state": "draft", + } + ) + + post_init_hook(self.env) + + variable.invalidate_recordset() + self.assertEqual(variable.source_type, "external") + self.assertFalse(variable.source_field) + self.assertEqual(variable.external_provider_id, provider) + self.assertEqual(variable.dci_attribute_path, "has_disability") + self.assertEqual(variable.state, "active") + self.assertTrue(variable.active) + + def test_post_init_hook_handles_missing_variable_gracefully(self): + with patch("odoo.api.Environment.ref") as mock_ref: + mock_ref.return_value = self.env["spp.cel.variable"].browse() + post_init_hook(self.env) + + def test_post_init_hook_handles_missing_provider_gracefully(self): + original_ref = self.env.ref + + def selective_ref(xmlid, *args, **kwargs): + if xmlid == "spp_dci_openspp_dr.openspp_dr_provider": + return self.env["spp.data.provider"].browse() + return original_ref(xmlid, *args, **kwargs) + + with patch.object(type(self.env), "ref", side_effect=selective_ref): + post_init_hook(self.env) + + def test_post_init_hook_is_idempotent(self): + variable = self.env.ref("spp_studio.var_has_disability") + before = { + "source_type": variable.source_type, + "external_provider_id": variable.external_provider_id.id, + "dci_attribute_path": variable.dci_attribute_path, + } + post_init_hook(self.env) + variable.invalidate_recordset() + after = { + "source_type": variable.source_type, + "external_provider_id": variable.external_provider_id.id, + "dci_attribute_path": variable.dci_attribute_path, + } + self.assertEqual(before, after) diff --git a/spp_dci_openspp_dr/tests/test_openspp_dr_service.py b/spp_dci_openspp_dr/tests/test_openspp_dr_service.py new file mode 100644 index 00000000..62b75c8e --- /dev/null +++ b/spp_dci_openspp_dr/tests/test_openspp_dr_service.py @@ -0,0 +1,161 @@ +"""OpenSPPDRService unit tests.""" + +from unittest.mock import MagicMock, patch + +from odoo.tests.common import TransactionCase, tagged + +from odoo.addons.spp_dci_openspp_dr.services.openspp_dr_service import ( + OpenSPPDRService, +) + +from .common import get_or_create_uin_code + + +def make_dr_response(reg_records): + return { + "signature": "", + "header": { + "version": "1.0.0", + "message_id": "m1", + "message_ts": "2026-05-14T00:00:00Z", + "action": "search", + "status": "succ", + "sender_id": "openspp-dr.test", + "receiver_id": "openspp-sp.test", + }, + "message": { + "transaction_id": "t1", + "correlation_id": "c1", + "search_response": [ + { + "reference_id": "r1", + "timestamp": "2026-05-14T00:00:00Z", + "status": "succ", + "data": { + "version": "1.0.0", + "reg_type": "DR", + "reg_record_type": "PWD_PERSON", + "reg_records": reg_records, + }, + } + ], + }, + } + + +def make_dr_not_found_response(): + return { + "header": {"status": "rjct", "status_reason_code": "REG-ERR-001"}, + "message": {"search_response": []}, + } + + +@tagged("post_install", "-at_install") +class TestOpenSPPDRService(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.data_source = cls.env["spp.dci.data.source"].create( + { + "name": "OpenSPP-DR Test Source", + "code": "openspp_dr_test", + "registry_type": "DR", + "vendor": "openspp", + "base_url": "http://openspp-dr.test:8069", + "search_endpoint": "/dci_api/v1/disability/registry/sync/search", + "auth_type": "none", + "our_sender_id": "openspp-sp.test", + "receiver_id": "openspp-dr.test", + } + ) + + cls.id_type_uin = get_or_create_uin_code(cls.env) + cls.partner_known = cls.env["res.partner"].create( + {"name": "Known DR Registrant", "is_registrant": True, "is_group": False} + ) + cls.env["spp.registry.id"].create( + { + "partner_id": cls.partner_known.id, + "id_type_id": cls.id_type_uin.id, + "value": "UIN-DR-1", + } + ) + cls.partner_no_id = cls.env["res.partner"].create( + {"name": "Partner Without ID", "is_registrant": True, "is_group": False} + ) + + @staticmethod + def _make_service(env, data_source, mock_client): + with patch.object( + OpenSPPDRService.__mro__[0], + "__init__", + lambda self, env, data_source_code: None, + ): + service = OpenSPPDRService.__new__(OpenSPPDRService) + service.env = env + service.data_source_code = data_source.code + service.data_source = data_source + service.client = mock_client + return service + + def test_returns_reg_record_when_dr_matches(self): + mock_client = MagicMock() + mock_client.search_by_id.return_value = make_dr_response( + [{"has_disability": True, "disability_certified": True, "partner_uid": 42}] + ) + service = self._make_service(self.env, self.data_source, mock_client) + + result = service.get_partner_record(self.partner_known) + + self.assertIsNotNone(result) + self.assertEqual(result["has_disability"], True) + self.assertEqual(result["disability_certified"], True) + + def test_returns_none_when_dr_says_not_found(self): + mock_client = MagicMock() + mock_client.search_by_id.return_value = make_dr_not_found_response() + service = self._make_service(self.env, self.data_source, mock_client) + + result = service.get_partner_record(self.partner_known) + + self.assertIsNone(result) + + def test_returns_none_when_partner_has_no_identifier(self): + """Service must not call the DR at all if the partner has no + identifier — saves an HTTP round-trip.""" + mock_client = MagicMock() + service = self._make_service(self.env, self.data_source, mock_client) + + result = service.get_partner_record(self.partner_no_id) + + self.assertIsNone(result) + mock_client.search_by_id.assert_not_called() + + def test_uses_uin_as_identifier_type_first(self): + mock_client = MagicMock() + mock_client.search_by_id.return_value = make_dr_response([{"has_disability": False}]) + service = self._make_service(self.env, self.data_source, mock_client) + + service.get_partner_record(self.partner_known) + + mock_client.search_by_id.assert_called_once() + kwargs = mock_client.search_by_id.call_args.kwargs + self.assertEqual(kwargs["identifier_type"], "UIN") + self.assertEqual(kwargs["identifier_value"], "UIN-DR-1") + + def test_extract_first_record_handles_empty_reg_records(self): + response = make_dr_response([]) + self.assertIsNone(OpenSPPDRService._extract_first_record(response)) + + def test_extract_first_record_handles_non_dict_response(self): + self.assertIsNone(OpenSPPDRService._extract_first_record(None)) + self.assertIsNone(OpenSPPDRService._extract_first_record("junk")) + + def test_extract_first_record_skips_non_dict_record_entries(self): + response = make_dr_response([]) + response["message"]["search_response"][0]["data"]["reg_records"] = [ + "junk", + {"has_disability": True}, + ] + record = OpenSPPDRService._extract_first_record(response) + self.assertEqual(record, {"has_disability": True}) diff --git a/spp_dci_server_disability/README.rst b/spp_dci_server_disability/README.rst new file mode 100644 index 00000000..24d52974 --- /dev/null +++ b/spp_dci_server_disability/README.rst @@ -0,0 +1,236 @@ +======================================== +OpenSPP DCI Server — Disability Registry +======================================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:0c758d04128eb83d7b55a53831b601d41b1035bee9350ee47285c0e28251a1b2 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OpenSPP%2FOpenSPP2-lightgray.png?logo=github + :target: https://github.com/OpenSPP/OpenSPP2/tree/19.0/spp_dci_server_disability + :alt: OpenSPP/OpenSPP2 + +|badge1| |badge2| |badge3| + +Server-side DCI Disability Registry implementation. Replaces the 501 +stub at ``/dci_api/v1/disability/registry/sync/search`` in +``spp_dci_server`` with a real handler backed by +``DisabilitySearchService``, so SP-side OpenSPP instances (or any +DCI-compliant client) can query disability data from this OpenSPP-DR +instance. + +This module turns an OpenSPP deployment into a DCI-compliant Disability +Registry. Install it on the registry instance only — not on SP instances +that act as DCI clients. + +What this module ships +~~~~~~~~~~~~~~~~~~~~~~ + ++-------------------------------------------+-------------------------------------------------+ +| Component | Purpose | ++===========================================+=================================================+ +| ``routers/disability_router.py`` | Real | +| | ``/dci_api/v1/disability/registry/sync/search`` | +| | handler; signs and returns a DCI envelope | ++-------------------------------------------+-------------------------------------------------+ +| ``services/disability_search_service.py`` | Parse SearchRequest → look up partner by reg_id | +| | → produce SearchResponse with disability fields | ++-------------------------------------------+-------------------------------------------------+ +| ``models/fastapi_endpoint_dr.py`` | Inherits ``fastapi.endpoint`` to swap the | +| | parent's stub router for our real router on the | +| | DCI app | ++-------------------------------------------+-------------------------------------------------+ + +Wire format returned +~~~~~~~~~~~~~~~~~~~~ + +Each successful response item carries ``reg_records[0]`` as: + +.. code:: json + + { + "has_disability": true, + "disability_severity_code": "moderate", + "disability_review_category": "annual", + "disability_next_review": "2027-01-15", + "partner_name": "Maria Santos", + "partner_uid": 12345 + } + +All fields come from the ``spp_disability_registry`` data model on +``res.partner``: + +- ``has_disability`` — Boolean, related from the current approved + ``spp.disability.assessment.has_disability``. +- ``disability_severity_code`` — projected from + ``disability_severity_id.code`` (a vocabulary code). +- ``disability_review_category`` — Selection (review cadence) from the + current assessment. +- ``disability_next_review`` — Date ISO string from the current + assessment. + +Each is read defensively via ``getattr`` with a default, so the module +remains installable in deployments that don't have +``spp_disability_registry`` (responses would just carry mostly-empty +records, still SPDCI-valid). + +What this module does NOT ship +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +- SR data (use OpenG2P or ``spp_dci_server_social`` for Social Registry) +- CRVS data (deferred to ``spp_dci_server_crvs``) +- Disability data write-back from external systems (this module exposes + data, doesn't accept it) + +See Also +~~~~~~~~ + +- ADR-024 — federated demo topology +- ``spp_dci_server`` — base server with the stub being replaced +- ``spp_cel_dci_bridge`` — bridge that produces requests against this + endpoint + +.. IMPORTANT:: + This is an alpha version, the data model and design can change at any time without warning. + Only for development or testing purpose, do not use in production. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +After installing this module +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The endpoint is live at +``https:///dci_api/v1/disability/registry/sync/search`` (the +``/dci`` prefix comes from the FastAPI endpoint configuration on +``spp_dci_server``). + +1. Verify the DCI FastAPI endpoint is active: **Custom > Technical > + FastAPI > Endpoints**, ensure the row with ``app=dci_api`` is + enabled. +2. Optionally seed test partners with disability data and a known reg_id + value so SP-side queries return matches. +3. Confirm the stub is gone: a ``POST`` to + ``/dci_api/v1/disability/registry/sync/search`` should now return + HTTP 200 with a real SearchResponse (not 501). + +Signing keys +~~~~~~~~~~~~ + +The endpoint signs response envelopes using the active +``spp.dci.signing.key``. If no active key is configured, responses are +emitted unsigned — fine for the demo, not for production. + +Identifier resolution +~~~~~~~~~~~~~~~~~~~~~ + +The service looks up partners by +``spp.registry.id.value == search_text``. Match the SP-side preset's +identifier scheme so the same value is sent and recognised: + +- OpenSPP-DR ships UIN (and any other ``spp.vocabulary.code`` your + registry uses) +- SP-side prefers UIN first; if your data uses NATIONAL_ID, configure + ``IDENTIFIER_PRIORITY`` in the SP-side service accordingly. + +Disability fields +~~~~~~~~~~~~~~~~~ + +The service reads from the ``spp_disability_registry`` data model on +``res.partner``. Each ``res.partner`` is the registrant; the disability +data is computed from its current approved +``spp.disability.assessment``: + +=============================== ===================================== +Local field Wire-format key in ``reg_records[0]`` +=============================== ===================================== +``has_disability`` (Boolean) ``has_disability`` +``disability_severity_id.code`` ``disability_severity_code`` +``disability_review_category`` ``disability_review_category`` +``disability_next_review`` ``disability_next_review`` (ISO date) +=============================== ===================================== + +Missing fields are returned as ``False`` / ``None`` rather than raising +— ``spp_disability_registry`` is a soft dependency. Without it, +responses carry ``has_disability=False`` and the other keys default to +``None``, which is still SPDCI-valid. + +Authentication and ACLs +~~~~~~~~~~~~~~~~~~~~~~~ + +The DCI FastAPI endpoint runs as ``base.public_user``, which has no +Registry access by default. The service uses ``sudo()`` when reading +``spp.registry.id`` and ``res.partner``. The actual authentication +boundary is upstream — DCI signature + bearer-token verification in the +middleware. Once the sender_id is accepted by those checks, the service +trusts the request. + +For demo deployments where you want to bypass both signature and +bearer-token verification, set these system parameters on the DR's +database (Settings → Technical → Parameters → System Parameters): + ++---------------------------------+----------+-----------------------------+ +| Key | Value | Effect | ++=================================+==========+=============================+ +| ``dci.allow_unsigned_requests`` | ``true`` | Skips DCI signature | +| | | verification | ++---------------------------------+----------+-----------------------------+ +| ``dci.bypass_bearer_auth`` | ``true`` | Skips Authorization-header | +| | | check | ++---------------------------------+----------+-----------------------------+ + +Both flags trigger a one-time CRITICAL warning in the DR log on the +first request after restart. **Production deployments must leave both at +``false``** and register sender public keys via the DCI Sender Registry +instead. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* OpenSPP.org + +Maintainers +----------- + +.. |maintainer-jeremi| image:: https://github.com/jeremi.png?size=40px + :target: https://github.com/jeremi + :alt: jeremi +.. |maintainer-gonzalesedwin1123| image:: https://github.com/gonzalesedwin1123.png?size=40px + :target: https://github.com/gonzalesedwin1123 + :alt: gonzalesedwin1123 + +Current maintainers: + +|maintainer-jeremi| |maintainer-gonzalesedwin1123| + +This module is part of the `OpenSPP/OpenSPP2 `_ project on GitHub. + +You are welcome to contribute. \ No newline at end of file diff --git a/spp_dci_server_disability/__init__.py b/spp_dci_server_disability/__init__.py new file mode 100644 index 00000000..1e03ffd4 --- /dev/null +++ b/spp_dci_server_disability/__init__.py @@ -0,0 +1,3 @@ +from . import models +from . import routers +from . import services diff --git a/spp_dci_server_disability/__manifest__.py b/spp_dci_server_disability/__manifest__.py new file mode 100644 index 00000000..8494237e --- /dev/null +++ b/spp_dci_server_disability/__manifest__.py @@ -0,0 +1,27 @@ +{ # pylint: disable=pointless-statement + "name": "OpenSPP DCI Server — Disability Registry", + "summary": ( + "Server-side DCI Disability Registry handler — replaces the 501 stub " + "in spp_dci_server with a real /disability/registry/sync/search endpoint." + ), + "version": "19.0.1.0.0", + "category": "OpenSPP/Integration", + "author": "OpenSPP.org", + "website": "https://github.com/OpenSPP/OpenSPP2", + "license": "LGPL-3", + "development_status": "Alpha", + "maintainers": ["jeremi", "gonzalesedwin1123"], + "depends": [ + "spp_dci_server", + "spp_registry", + "spp_vocabulary", + ], + "external_dependencies": {"python": []}, + "data": [ + "security/ir.model.access.csv", + "data/dr_id_types.xml", + ], + "installable": True, + "application": False, + "auto_install": False, +} diff --git a/spp_dci_server_disability/data/dr_id_types.xml b/spp_dci_server_disability/data/dr_id_types.xml new file mode 100644 index 00000000..d83668ce --- /dev/null +++ b/spp_dci_server_disability/data/dr_id_types.xml @@ -0,0 +1,36 @@ + + + + + + UIN + UIN (Universal Identification Number) + individual + Universal Identification Number — used to tag DR-side registrants for SPDCI lookups. Mirrors the convention on the SP-side preset. + 20 + + diff --git a/spp_dci_server_disability/models/__init__.py b/spp_dci_server_disability/models/__init__.py new file mode 100644 index 00000000..f9f1cbda --- /dev/null +++ b/spp_dci_server_disability/models/__init__.py @@ -0,0 +1 @@ +from . import fastapi_endpoint_dr diff --git a/spp_dci_server_disability/models/fastapi_endpoint_dr.py b/spp_dci_server_disability/models/fastapi_endpoint_dr.py new file mode 100644 index 00000000..427ff033 --- /dev/null +++ b/spp_dci_server_disability/models/fastapi_endpoint_dr.py @@ -0,0 +1,51 @@ +"""Replace spp_dci_server's 501 disability stub with the real router. + +The base ``spp_dci_server.models.fastapi_endpoint_dci.SppDciServerEndpoint`` +appends a ``disability_router`` to the FastAPI app that returns 501 for +every search request. Installing this module is what lights up the real +endpoint: the override below filters the stub out of the parent's +returned router list and substitutes our concrete +``disability_search_router``. + +Why filtering instead of "just add our router on top": + + - FastAPI matches routes by registration order. The stub and the real + router share the path ``/disability/registry/sync/search``, so the + first-registered one wins. The parent's super() call adds the stub + BEFORE we get a chance to add ours, so without filtering the stub + keeps shadowing the real handler. +""" + +import logging + +from odoo import models + +from fastapi import APIRouter + +_logger = logging.getLogger(__name__) + + +class SppDciServerEndpoint(models.Model): + _inherit = "fastapi.endpoint" + + def _get_fastapi_routers(self) -> list[APIRouter]: + routers = super()._get_fastapi_routers() + if self.app != "dci_api": + return routers + + try: + from odoo.addons.spp_dci_server.routers.registry_aliases import ( + disability_router as stub_router, + ) + except ImportError: + stub_router = None + + from ..routers.disability_router import disability_search_router + + if stub_router is not None: + # Remove the parent's stub if it's still in the list — keep any + # other routers (CRVS, Farmer, Social, etc.) untouched. + routers = [r for r in routers if r is not stub_router] + + routers.append(disability_search_router) + return routers diff --git a/spp_dci_server_disability/pyproject.toml b/spp_dci_server_disability/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/spp_dci_server_disability/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/spp_dci_server_disability/readme/CONFIGURE.md b/spp_dci_server_disability/readme/CONFIGURE.md new file mode 100644 index 00000000..2be1ff9b --- /dev/null +++ b/spp_dci_server_disability/readme/CONFIGURE.md @@ -0,0 +1,44 @@ +### After installing this module + +The endpoint is live at `https:///dci_api/v1/disability/registry/sync/search` (the `/dci` prefix comes from the FastAPI endpoint configuration on `spp_dci_server`). + +1. Verify the DCI FastAPI endpoint is active: **Custom > Technical > FastAPI > Endpoints**, ensure the row with `app=dci_api` is enabled. +2. Optionally seed test partners with disability data and a known reg_id value so SP-side queries return matches. +3. Confirm the stub is gone: a `POST` to `/dci_api/v1/disability/registry/sync/search` should now return HTTP 200 with a real SearchResponse (not 501). + +### Signing keys + +The endpoint signs response envelopes using the active `spp.dci.signing.key`. If no active key is configured, responses are emitted unsigned — fine for the demo, not for production. + +### Identifier resolution + +The service looks up partners by `spp.registry.id.value == search_text`. Match the SP-side preset's identifier scheme so the same value is sent and recognised: + +- OpenSPP-DR ships UIN (and any other `spp.vocabulary.code` your registry uses) +- SP-side prefers UIN first; if your data uses NATIONAL_ID, configure `IDENTIFIER_PRIORITY` in the SP-side service accordingly. + +### Disability fields + +The service reads from the `spp_disability_registry` data model on `res.partner`. Each `res.partner` is the registrant; the disability data is computed from its current approved `spp.disability.assessment`: + +| Local field | Wire-format key in `reg_records[0]` | +| --------------------------------- | ------------------------------------ | +| `has_disability` (Boolean) | `has_disability` | +| `disability_severity_id.code` | `disability_severity_code` | +| `disability_review_category` | `disability_review_category` | +| `disability_next_review` | `disability_next_review` (ISO date) | + +Missing fields are returned as `False` / `None` rather than raising — `spp_disability_registry` is a soft dependency. Without it, responses carry `has_disability=False` and the other keys default to `None`, which is still SPDCI-valid. + +### Authentication and ACLs + +The DCI FastAPI endpoint runs as `base.public_user`, which has no Registry access by default. The service uses `sudo()` when reading `spp.registry.id` and `res.partner`. The actual authentication boundary is upstream — DCI signature + bearer-token verification in the middleware. Once the sender_id is accepted by those checks, the service trusts the request. + +For demo deployments where you want to bypass both signature and bearer-token verification, set these system parameters on the DR's database (Settings → Technical → Parameters → System Parameters): + +| Key | Value | Effect | +|---|---|---| +| `dci.allow_unsigned_requests` | `true` | Skips DCI signature verification | +| `dci.bypass_bearer_auth` | `true` | Skips Authorization-header check | + +Both flags trigger a one-time CRITICAL warning in the DR log on the first request after restart. **Production deployments must leave both at `false`** and register sender public keys via the DCI Sender Registry instead. diff --git a/spp_dci_server_disability/readme/DESCRIPTION.md b/spp_dci_server_disability/readme/DESCRIPTION.md new file mode 100644 index 00000000..41b602d5 --- /dev/null +++ b/spp_dci_server_disability/readme/DESCRIPTION.md @@ -0,0 +1,47 @@ +Server-side DCI Disability Registry implementation. Replaces the 501 stub at `/dci_api/v1/disability/registry/sync/search` in `spp_dci_server` with a real handler backed by `DisabilitySearchService`, so SP-side OpenSPP instances (or any DCI-compliant client) can query disability data from this OpenSPP-DR instance. + +This module turns an OpenSPP deployment into a DCI-compliant Disability Registry. Install it on the registry instance only — not on SP instances that act as DCI clients. + +### What this module ships + +| Component | Purpose | +| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `routers/disability_router.py` | Real `/dci_api/v1/disability/registry/sync/search` handler; signs and returns a DCI envelope | +| `services/disability_search_service.py` | Parse SearchRequest → look up partner by reg_id → produce SearchResponse with disability fields | +| `models/fastapi_endpoint_dr.py` | Inherits `fastapi.endpoint` to swap the parent's stub router for our real router on the DCI app | + +### Wire format returned + +Each successful response item carries `reg_records[0]` as: + +```json +{ + "has_disability": true, + "disability_severity_code": "moderate", + "disability_review_category": "annual", + "disability_next_review": "2027-01-15", + "partner_name": "Maria Santos", + "partner_uid": 12345 +} +``` + +All fields come from the `spp_disability_registry` data model on `res.partner`: + +- `has_disability` — Boolean, related from the current approved `spp.disability.assessment.has_disability`. +- `disability_severity_code` — projected from `disability_severity_id.code` (a vocabulary code). +- `disability_review_category` — Selection (review cadence) from the current assessment. +- `disability_next_review` — Date ISO string from the current assessment. + +Each is read defensively via `getattr` with a default, so the module remains installable in deployments that don't have `spp_disability_registry` (responses would just carry mostly-empty records, still SPDCI-valid). + +### What this module does NOT ship + +- SR data (use OpenG2P or `spp_dci_server_social` for Social Registry) +- CRVS data (deferred to `spp_dci_server_crvs`) +- Disability data write-back from external systems (this module exposes data, doesn't accept it) + +### See Also + +- ADR-024 — federated demo topology +- `spp_dci_server` — base server with the stub being replaced +- `spp_cel_dci_bridge` — bridge that produces requests against this endpoint diff --git a/spp_dci_server_disability/routers/__init__.py b/spp_dci_server_disability/routers/__init__.py new file mode 100644 index 00000000..28d02952 --- /dev/null +++ b/spp_dci_server_disability/routers/__init__.py @@ -0,0 +1 @@ +from . import disability_router diff --git a/spp_dci_server_disability/routers/disability_router.py b/spp_dci_server_disability/routers/disability_router.py new file mode 100644 index 00000000..39fc559d --- /dev/null +++ b/spp_dci_server_disability/routers/disability_router.py @@ -0,0 +1,169 @@ +"""DCI Disability Registry FastAPI router. + +Replaces spp_dci_server's 501 stub at ``/disability/registry/sync/search`` +with a real handler backed by ``DisabilitySearchService``. The router +mounts under the existing ``/disability/registry`` prefix so SP-side +clients (e.g., the bridge dispatcher) reach it at the canonical path. + +Authentication / signature verification reuses spp_dci_server's +middleware — no security delta from the stub. +""" + +import logging +import uuid +from datetime import UTC, datetime +from typing import Annotated + +from odoo.api import Environment + +from odoo.addons.fastapi.dependencies import odoo_env +from odoo.addons.spp_dci.schemas import ( + DCICallbackHeader, + DCIEnvelope, + SearchRequest, + SearchResponse, + SearchResponseItem, +) +from odoo.addons.spp_dci.schemas.constants import ( + MsgHeaderStatusReasonCode, + SearchStatusReasonCode, +) +from odoo.addons.spp_dci.services import get_sender_id, truncate_message +from odoo.addons.spp_dci_server.middleware.signature import ( + verify_bearer_token, + verify_dci_signature, +) + +from fastapi import APIRouter, Depends, HTTPException, status + +from ..services.disability_search_service import DisabilitySearchService + +_logger = logging.getLogger(__name__) + +# Same prefix as spp_dci_server's stub router so the canonical +# /disability/registry/sync/search path is honoured. +disability_search_router = APIRouter( + tags=["Disability Registry"], + prefix="/disability/registry", +) + + +@disability_search_router.post( + "/sync/search", + response_model=DCIEnvelope, + response_model_exclude_none=True, + response_model_exclude_unset=True, +) +async def disability_sync_search( + request_envelope: DCIEnvelope, + env: Annotated[Environment, Depends(odoo_env)], + _bearer_token: Annotated[str, Depends(verify_bearer_token)], + verified_sender_id: Annotated[str, Depends(verify_dci_signature)], +): + """SPDCI-compliant Disability Registry synchronous search endpoint. + + Mirrors the shape of spp_dci_server's main ``/sync/search`` handler: + parse the envelope, dispatch to a search service, build a signed + callback envelope back. The disability-specific logic lives in + ``DisabilitySearchService``. + """ + envelope = request_envelope + + try: + search_request = SearchRequest.model_validate(envelope.message) + except Exception as e: + _logger.error("Invalid SearchRequest message: %s", str(e)) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid search request message: {str(e)}", + ) from e + + _logger.info( + "DR search request received — transaction_id: %s, sender: %s, items: %d", + search_request.transaction_id, + envelope.header.sender_id, + len(search_request.search_request), + ) + + try: + search_service = DisabilitySearchService(env) + search_response = search_service.execute_search(search_request) + except Exception as e: + _logger.error("Error executing DR search: %s", str(e), exc_info=True) + # Build a rejection response item per request item, then continue + # building the envelope. This mirrors spp_dci_server's pattern of + # surfacing service-level failures through DCI status codes + # rather than HTTP 500s — the SP side has already parsed our + # envelope; let it parse the rejection too. + response_items = [ + SearchResponseItem( + reference_id=req_item.reference_id, + timestamp=datetime.now(UTC), + status="rjct", + status_reason_code=SearchStatusReasonCode.SEARCH_CRITERIA_INVALID.value, + status_reason_message=truncate_message(str(e)), + ) + for req_item in search_request.search_request + ] + search_response = SearchResponse( + transaction_id=search_request.transaction_id, + correlation_id=str(uuid.uuid4()), + search_response=response_items, + ) + + response_items = search_response.search_response + total_count = len(response_items) + completed_count = sum(1 for item in response_items if item.status == "succ") + rejected_count = sum(1 for item in response_items if item.status == "rjct") + + if completed_count == total_count: + overall_status = "succ" + status_reason_code = None + status_reason_message = None + elif rejected_count == total_count: + overall_status = "rjct" + status_reason_code = MsgHeaderStatusReasonCode.ERRORS_TOO_MANY.value + status_reason_message = "All DR search requests failed" + else: + overall_status = "part" + status_reason_code = None + status_reason_message = f"{completed_count}/{total_count} DR search requests completed" + + our_sender_id = get_sender_id(env) + + callback_header = DCICallbackHeader( + version=envelope.header.version, + message_id=str(uuid.uuid4()), + message_ts=datetime.now(UTC), + action=f"on-{envelope.header.action}", + sender_id=our_sender_id, + receiver_id=envelope.header.sender_id, + total_count=total_count, + status=overall_status, + status_reason_code=status_reason_code, + status_reason_message=status_reason_message, + completed_count=completed_count, + ) + + response_signature = "" + try: + # sudo() for API access — authentication is via signature verification. + signing_key_model = env["spp.dci.signing.key"].sudo() # nosemgrep: odoo-sudo-without-context + active_key = signing_key_model.get_active_key() + if active_key: + signer = active_key.get_signer() + header_dict = callback_header.model_dump(mode="json", exclude_none=True) + message_dict = search_response.model_dump(mode="json", exclude_none=True) + response_signature = signer.sign(header_dict, message_dict) + _logger.debug("DR response signed with key: %s", active_key.key_id) + else: + _logger.warning("No active signing key — DR response will be unsigned") + except Exception as e: + _logger.warning("Failed to sign DR response: %s — continuing unsigned", str(e)) + response_signature = "" + + return DCIEnvelope( + signature=response_signature, + header=callback_header, + message=search_response.model_dump(mode="json", exclude_none=True), + ) diff --git a/spp_dci_server_disability/security/ir.model.access.csv b/spp_dci_server_disability/security/ir.model.access.csv new file mode 100644 index 00000000..97dd8b91 --- /dev/null +++ b/spp_dci_server_disability/security/ir.model.access.csv @@ -0,0 +1 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink diff --git a/spp_dci_server_disability/services/__init__.py b/spp_dci_server_disability/services/__init__.py new file mode 100644 index 00000000..d8646d1a --- /dev/null +++ b/spp_dci_server_disability/services/__init__.py @@ -0,0 +1 @@ +from . import disability_search_service diff --git a/spp_dci_server_disability/services/disability_search_service.py b/spp_dci_server_disability/services/disability_search_service.py new file mode 100644 index 00000000..e4628898 --- /dev/null +++ b/spp_dci_server_disability/services/disability_search_service.py @@ -0,0 +1,270 @@ +"""DCI Disability Registry search service. + +Looks up local res.partner records by the incoming ``search_text`` and +returns disability data in a DCI SearchResponse envelope. + +The service is intentionally narrow — it owns: + + - Query parsing: extracts ``search_text`` from the supported query + types (``idtype-value``, ``expression``). + - Partner lookup: searches ``spp.registry.id.value`` against the + extracted search_text. The first matching partner wins. + - Disability extraction: reads the partner's ``has_disability`` + Boolean (computed by ``spp_disability_registry`` from the latest + approved ``spp.disability.assessment``), plus the assessment's + severity code, review category, and next-review date. + - Response construction: builds ``SearchResponseItem`` records with + ``status='succ'`` for matches and ``status='rjct'`` / + ``status_reason_code='REG-ERR-001'`` for unknown identifiers. + +Authentication, signing, and rate limiting live in the router and its +middleware — this service does no I/O beyond Odoo ORM reads. +""" + +import logging +import uuid +from datetime import UTC, datetime + +from odoo.addons.spp_dci.schemas.constants import ( + QueryType, + SearchStatusReasonCode, +) +from odoo.addons.spp_dci.schemas.search import ( + SearchRequest, + SearchResponse, + SearchResponseData, + SearchResponseItem, +) + +_logger = logging.getLogger(__name__) + +# Wire-format reg_type / reg_record_type for the DR response envelope. +# DR is the canonical SPDCI registry-type code; PWD_PERSON is the +# SPDCI-defined record type for person-with-disability records. The +# spp_cel_dci_bridge dispatcher accepts both literal "DR" and the +# namespaced URI form, so we use the short form. +DR_REG_TYPE = "DR" +DR_REG_RECORD_TYPE = "PWD_PERSON" + +# SPDCI's SearchStatusReasonCode enum doesn't include a "not found" +# code. OpenG2P uses ``REG-ERR-001`` with reason ``REGISTER_NOT_FOUND`` +# at the envelope-header level for the same case; we adopt that +# convention at the per-item level so SP-side audit rows surface a +# stable, recognisable code. +REGISTER_NOT_FOUND_CODE = "REG-ERR-001" +REGISTER_NOT_FOUND_MESSAGE = "REGISTER_NOT_FOUND" + + +class DisabilitySearchService: + """Look up partners by identifier and return disability data.""" + + def __init__(self, env): + self.env = env + + def execute_search(self, search_request: SearchRequest) -> SearchResponse: + """Process a SearchRequest and produce a SearchResponse. + + One SearchResponseItem is produced per SearchRequestItem. Items + are independent: one item's failure does not affect siblings. + """ + response_items = [] + for req_item in search_request.search_request: + response_items.append(self._handle_search_item(req_item)) + return SearchResponse( + transaction_id=search_request.transaction_id, + correlation_id=str(uuid.uuid4()), + search_response=response_items, + ) + + # ------------------------------------------------------------------ + # Per-item processing + # ------------------------------------------------------------------ + + def _handle_search_item(self, req_item) -> SearchResponseItem: + """Process one search request item — extract search_text, look up + the partner, build the response item.""" + timestamp = datetime.now(UTC) + try: + search_text = self._extract_search_text(req_item.search_criteria) + except ValueError as e: + return SearchResponseItem( + reference_id=req_item.reference_id, + timestamp=timestamp, + status="rjct", + status_reason_code=SearchStatusReasonCode.SEARCH_CRITERIA_INVALID.value, + status_reason_message=str(e), + locale=req_item.locale, + ) + + if not search_text: + return SearchResponseItem( + reference_id=req_item.reference_id, + timestamp=timestamp, + status="rjct", + status_reason_code=SearchStatusReasonCode.SEARCH_CRITERIA_INVALID.value, + status_reason_message="search_text is empty", + locale=req_item.locale, + ) + + partner = self._find_partner_by_identifier(search_text) + if not partner: + return SearchResponseItem( + reference_id=req_item.reference_id, + timestamp=timestamp, + status="rjct", + status_reason_code=REGISTER_NOT_FOUND_CODE, + status_reason_message=( + f"{REGISTER_NOT_FOUND_MESSAGE}: No registrant found for identifier '{search_text}'" + ), + locale=req_item.locale, + ) + + reg_record = self._build_reg_record(partner) + data = SearchResponseData( + reg_type=DR_REG_TYPE, + reg_record_type=DR_REG_RECORD_TYPE, + reg_records=[reg_record], + ) + return SearchResponseItem( + reference_id=req_item.reference_id, + timestamp=timestamp, + status="succ", + data=data, + locale=req_item.locale, + ) + + # ------------------------------------------------------------------ + # Query parsing + # ------------------------------------------------------------------ + + @staticmethod + def _extract_search_text(criteria) -> str | None: + """Pull the ``search_text`` value out of the criteria's query. + + Supports two shapes the SP-side may emit: + + 1. ``idtype-value`` query — the value is the identifier directly, + or ``{id_type, id_value}`` per upstream's flat shape. + + 2. ``expression`` query — the value is OpenG2P's nested shape + ``{expression: {query: {search_text: {$eq: }}}}``. + + Returns None for query types we cannot interpret. Raises + ValueError for malformed payloads where the shape is recognised + but the expected field is absent. + """ + query_type = criteria.query_type + query = criteria.query + # Compare against the enum string value; QueryType is StrEnum so + # equality with bare strings works. + if query_type == QueryType.IDTYPE_VALUE.value: + if isinstance(query, dict): + value = query.get("value") + if isinstance(value, dict): + id_value = value.get("id_value") + if id_value: + return str(id_value) + raise ValueError("idtype-value query missing 'id_value'") + if isinstance(value, str): + return value + return None + + if query_type == QueryType.EXPRESSION.value: + if not isinstance(query, dict): + return None + value = query.get("value") + if not isinstance(value, dict): + return None + # OpenG2P nested shape: value.expression.query.search_text.$eq + expression = value.get("expression") if isinstance(value, dict) else None + if isinstance(expression, dict): + inner_query = expression.get("query") + if isinstance(inner_query, dict): + search_text = inner_query.get("search_text") + if isinstance(search_text, dict): + eq = search_text.get("$eq") + if eq: + return str(eq) + raise ValueError("expression query missing 'search_text.$eq'") + if isinstance(search_text, str): + return search_text + return None + + # Unsupported query type — caller decides whether to surface as + # rjct or just skip. Return None to signal "no search_text found". + return None + + # ------------------------------------------------------------------ + # Partner lookup + # ------------------------------------------------------------------ + + def _find_partner_by_identifier(self, identifier_value: str): + """Return the first res.partner whose registry_id has the given value. + + Multiple partners may share an identifier in pathological data; + we deterministically pick the lowest partner.id so repeat queries + are stable. The disability data we return is a function of the + single matched partner. + + sudo() is intentional. The DCI FastAPI endpoint is configured to + run as base.public_user (per spp_dci_server's + fastapi_endpoint_data.xml), and the public user has no read + access on spp.registry.id / res.partner. The actual + authentication boundary is upstream — DCI signature + bearer + token verification in the middleware. Once the sender_id is + accepted, the service trusts the request and queries the data + model on its behalf. This mirrors the pattern in + spp_dci_server/routers/search.py where signing-key reads use + sudo() for the same reason. + """ + # Authorization context: the DCI envelope's sender_id was verified + # upstream by spp_dci_server's signature middleware (or explicitly + # bypassed in dev mode via dci.bypass_bearer_auth). Once the sender + # is accepted, this service trusts the request. res.partner / + # spp.registry.id are read-only here (search + browse + field reads + # via _build_reg_record); no write/unlink/create surface is exposed + # to the public user the FastAPI endpoint runs as. + regid_model = self.env["spp.registry.id"].sudo() # nosemgrep: odoo-sudo-without-context + reg_id = regid_model.search( + [("value", "=", identifier_value)], + order="partner_id asc", + limit=1, + ) + if reg_id: + return reg_id.partner_id.sudo() # nosemgrep: odoo-sudo-without-context,odoo-sudo-on-sensitive-models + Partner = self.env["res.partner"].sudo() # nosemgrep: odoo-sudo-without-context,odoo-sudo-on-sensitive-models + return Partner.browse() + + # ------------------------------------------------------------------ + # Reg-record construction + # ------------------------------------------------------------------ + + @staticmethod + def _build_reg_record(partner) -> dict: + """Produce the wire-format reg_record dict from a res.partner. + + Fields read from ``spp_disability_registry`` (the DR-side data + model on ``res.partner``): + + - ``has_disability`` — Boolean, related from the latest + approved assessment's ``has_disability`` field. + - ``disability_severity_id`` — Many2one to ``spp.vocabulary.code`` + (severity vocab); we project its ``code`` attribute. + - ``disability_review_category`` — Selection on the current + assessment (e.g., review cadence). + - ``disability_next_review`` — Date, next review. + + All reads use ``getattr`` with a default so the module remains + installable without ``spp_disability_registry`` (the deployment + would just return mostly-empty records — still SPDCI-valid). + """ + severity = getattr(partner, "disability_severity_id", None) + next_review = getattr(partner, "disability_next_review", None) + return { + "has_disability": bool(getattr(partner, "has_disability", False)), + "disability_severity_code": severity.code if severity else None, + "disability_review_category": getattr(partner, "disability_review_category", None), + "disability_next_review": next_review.isoformat() if next_review else None, + "partner_name": partner.name, + "partner_uid": partner.id, + } diff --git a/spp_dci_server_disability/static/description/index.html b/spp_dci_server_disability/static/description/index.html new file mode 100644 index 00000000..b7305dd9 --- /dev/null +++ b/spp_dci_server_disability/static/description/index.html @@ -0,0 +1,612 @@ + + + + + +OpenSPP DCI Server — Disability Registry + + + +
+

OpenSPP DCI Server — Disability Registry

+ + +

Alpha License: LGPL-3 OpenSPP/OpenSPP2

+

Server-side DCI Disability Registry implementation. Replaces the 501 +stub at /dci_api/v1/disability/registry/sync/search in +spp_dci_server with a real handler backed by +DisabilitySearchService, so SP-side OpenSPP instances (or any +DCI-compliant client) can query disability data from this OpenSPP-DR +instance.

+

This module turns an OpenSPP deployment into a DCI-compliant Disability +Registry. Install it on the registry instance only — not on SP instances +that act as DCI clients.

+
+

What this module ships

+ ++++ + + + + + + + + + + + + + + + + +
ComponentPurpose
routers/disability_router.pyReal +/dci_api/v1/disability/registry/sync/search +handler; signs and returns a DCI envelope
services/disability_search_service.pyParse SearchRequest → look up partner by reg_id +→ produce SearchResponse with disability fields
models/fastapi_endpoint_dr.pyInherits fastapi.endpoint to swap the +parent’s stub router for our real router on the +DCI app
+
+
+

Wire format returned

+

Each successful response item carries reg_records[0] as:

+
+{
+  "has_disability": true,
+  "disability_severity_code": "moderate",
+  "disability_review_category": "annual",
+  "disability_next_review": "2027-01-15",
+  "partner_name": "Maria Santos",
+  "partner_uid": 12345
+}
+
+

All fields come from the spp_disability_registry data model on +res.partner:

+
    +
  • has_disability — Boolean, related from the current approved +spp.disability.assessment.has_disability.
  • +
  • disability_severity_code — projected from +disability_severity_id.code (a vocabulary code).
  • +
  • disability_review_category — Selection (review cadence) from the +current assessment.
  • +
  • disability_next_review — Date ISO string from the current +assessment.
  • +
+

Each is read defensively via getattr with a default, so the module +remains installable in deployments that don’t have +spp_disability_registry (responses would just carry mostly-empty +records, still SPDCI-valid).

+
+
+

What this module does NOT ship

+
    +
  • SR data (use OpenG2P or spp_dci_server_social for Social Registry)
  • +
  • CRVS data (deferred to spp_dci_server_crvs)
  • +
  • Disability data write-back from external systems (this module exposes +data, doesn’t accept it)
  • +
+
+
+

See Also

+
    +
  • ADR-024 — federated demo topology
  • +
  • spp_dci_server — base server with the stub being replaced
  • +
  • spp_cel_dci_bridge — bridge that produces requests against this +endpoint
  • +
+
+

Important

+

This is an alpha version, the data model and design can change at any time without warning. +Only for development or testing purpose, do not use in production.

+
+

Table of contents

+ + +
+
+

After installing this module

+

The endpoint is live at +https://<base_url>/dci_api/v1/disability/registry/sync/search (the +/dci prefix comes from the FastAPI endpoint configuration on +spp_dci_server).

+
    +
  1. Verify the DCI FastAPI endpoint is active: Custom > Technical > +FastAPI > Endpoints, ensure the row with app=dci_api is +enabled.
  2. +
  3. Optionally seed test partners with disability data and a known reg_id +value so SP-side queries return matches.
  4. +
  5. Confirm the stub is gone: a POST to +/dci_api/v1/disability/registry/sync/search should now return +HTTP 200 with a real SearchResponse (not 501).
  6. +
+
+
+

Signing keys

+

The endpoint signs response envelopes using the active +spp.dci.signing.key. If no active key is configured, responses are +emitted unsigned — fine for the demo, not for production.

+
+
+

Identifier resolution

+

The service looks up partners by +spp.registry.id.value == search_text. Match the SP-side preset’s +identifier scheme so the same value is sent and recognised:

+
    +
  • OpenSPP-DR ships UIN (and any other spp.vocabulary.code your +registry uses)
  • +
  • SP-side prefers UIN first; if your data uses NATIONAL_ID, configure +IDENTIFIER_PRIORITY in the SP-side service accordingly.
  • +
+
+
+

Disability fields

+

The service reads from the spp_disability_registry data model on +res.partner. Each res.partner is the registrant; the disability +data is computed from its current approved +spp.disability.assessment:

+ ++++ + + + + + + + + + + + + + + + + + + + +
Local fieldWire-format key in reg_records[0]
has_disability (Boolean)has_disability
disability_severity_id.codedisability_severity_code
disability_review_categorydisability_review_category
disability_next_reviewdisability_next_review (ISO date)
+

Missing fields are returned as False / None rather than raising +— spp_disability_registry is a soft dependency. Without it, +responses carry has_disability=False and the other keys default to +None, which is still SPDCI-valid.

+
+
+

Authentication and ACLs

+

The DCI FastAPI endpoint runs as base.public_user, which has no +Registry access by default. The service uses sudo() when reading +spp.registry.id and res.partner. The actual authentication +boundary is upstream — DCI signature + bearer-token verification in the +middleware. Once the sender_id is accepted by those checks, the service +trusts the request.

+

For demo deployments where you want to bypass both signature and +bearer-token verification, set these system parameters on the DR’s +database (Settings → Technical → Parameters → System Parameters):

+ +++++ + + + + + + + + + + + + + + + + +
KeyValueEffect
dci.allow_unsigned_requeststrueSkips DCI signature +verification
dci.bypass_bearer_authtrueSkips Authorization-header +check
+

Both flags trigger a one-time CRITICAL warning in the DR log on the +first request after restart. Production deployments must leave both at +``false`` and register sender public keys via the DCI Sender Registry +instead.

+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • OpenSPP.org
  • +
+
+
+

Maintainers

+

Current maintainers:

+

jeremi gonzalesedwin1123

+

This module is part of the OpenSPP/OpenSPP2 project on GitHub.

+

You are welcome to contribute.

+
+
+
+
+ + diff --git a/spp_dci_server_disability/tests/__init__.py b/spp_dci_server_disability/tests/__init__.py new file mode 100644 index 00000000..635b3617 --- /dev/null +++ b/spp_dci_server_disability/tests/__init__.py @@ -0,0 +1 @@ +from . import test_disability_search_service diff --git a/spp_dci_server_disability/tests/test_disability_search_service.py b/spp_dci_server_disability/tests/test_disability_search_service.py new file mode 100644 index 00000000..d50d9432 --- /dev/null +++ b/spp_dci_server_disability/tests/test_disability_search_service.py @@ -0,0 +1,290 @@ +"""DisabilitySearchService unit tests. + +Locks in: + - Expression query (nested search_text shape) is parsed correctly + - idtype-value query (flat shape) is parsed correctly + - Unknown identifier produces REG-ERR-001 / REGISTER_NOT_FOUND + - Empty / malformed query produces SEARCH-ERR-002 / SEARCH_CRITERIA_INVALID + - Successful match returns disability data under the wire-format keys + - Multiple request items are processed independently +""" + +from datetime import UTC, datetime + +from odoo.tests.common import TransactionCase, tagged + +from odoo.addons.spp_dci.schemas.constants import ( + QueryType, + SearchStatusReasonCode, +) +from odoo.addons.spp_dci.schemas.search import ( + SearchCriteria, + SearchRequest, + SearchRequestItem, +) +from odoo.addons.spp_dci_server_disability.services.disability_search_service import ( + DR_REG_RECORD_TYPE, + DR_REG_TYPE, + REGISTER_NOT_FOUND_CODE, + DisabilitySearchService, +) + + +def _make_request(query_type, query, reference_id="r1"): + """Helper to build a one-item SearchRequest.""" + return SearchRequest( + transaction_id="txn-1", + search_request=[ + SearchRequestItem( + reference_id=reference_id, + timestamp=datetime.now(UTC), + search_criteria=SearchCriteria( + reg_type="DR", + query_type=query_type, + query=query, + ), + ) + ], + ) + + +def _expression_query(value): + """OpenG2P-style nested expression query.""" + return { + "type": "ns:org:QueryType:expression", + "value": { + "expression": { + "query": { + "search_text": {"$eq": value}, + }, + }, + }, + } + + +def _idtype_value_query(id_type, id_value): + """Upstream flat idtype-value query.""" + return { + "type": "idtype-value", + "value": {"id_type": id_type, "id_value": id_value}, + } + + +@tagged("post_install", "-at_install") +class TestDisabilitySearchService(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + # The UIN vocab code is seeded by data/dr_id_types.xml. Module + # data load is the only legitimate path for adding codes to a + # system vocabulary — runtime create is rejected with UserError. + cls.id_type_uin = cls.env.ref("spp_dci_server_disability.id_type_uin_dr") + + cls.partner_pwd = cls.env["res.partner"].create( + { + "name": "PWD Registrant", + "is_registrant": True, + "is_group": False, + } + ) + cls.env["spp.registry.id"].create( + { + "partner_id": cls.partner_pwd.id, + "id_type_id": cls.id_type_uin.id, + "value": "UIN-DR-1", + } + ) + # Stamp the disability flag if the field exists on res.partner. + # spp_disability_registry exposes it as a computed-stored Boolean + # derived from the current approved assessment; in test isolation + # (no assessment record), the field exists but is False. We bypass + # the related/computed write protection via SQL to set it for the + # test partner — simpler than constructing a full assessment. + partner_fields = cls.env["res.partner"]._fields + if "has_disability" in partner_fields: + cls.env.cr.execute( + "UPDATE res_partner SET has_disability = true WHERE id = %s", + (cls.partner_pwd.id,), + ) + cls.partner_pwd.invalidate_recordset(["has_disability"]) + + cls.partner_no_disability = cls.env["res.partner"].create( + { + "name": "Non-PWD Registrant", + "is_registrant": True, + "is_group": False, + } + ) + cls.env["spp.registry.id"].create( + { + "partner_id": cls.partner_no_disability.id, + "id_type_id": cls.id_type_uin.id, + "value": "UIN-DR-2", + } + ) + + # ------------------------------------------------------------------ + # Query parsing + # ------------------------------------------------------------------ + + def test_extracts_search_text_from_expression_query(self): + service = DisabilitySearchService(self.env) + request = _make_request(QueryType.EXPRESSION.value, _expression_query("UIN-DR-1")) + response = service.execute_search(request) + item = response.search_response[0] + self.assertEqual(item.status, "succ") + self.assertIsNotNone(item.data) + self.assertEqual(item.data.reg_records[0]["partner_uid"], self.partner_pwd.id) + + def test_extracts_search_text_from_idtype_value_query(self): + service = DisabilitySearchService(self.env) + request = _make_request(QueryType.IDTYPE_VALUE.value, _idtype_value_query("UIN", "UIN-DR-1")) + response = service.execute_search(request) + item = response.search_response[0] + self.assertEqual(item.status, "succ") + self.assertEqual(item.data.reg_records[0]["partner_uid"], self.partner_pwd.id) + + def test_idtype_value_with_string_value_is_accepted(self): + """Some clients send the value as a bare string rather than the + flat {id_type, id_value} dict. We accept both.""" + service = DisabilitySearchService(self.env) + request = _make_request( + QueryType.IDTYPE_VALUE.value, + {"type": "idtype-value", "value": "UIN-DR-1"}, + ) + response = service.execute_search(request) + item = response.search_response[0] + self.assertEqual(item.status, "succ") + + def test_idtype_value_query_without_id_value_is_rejected(self): + service = DisabilitySearchService(self.env) + request = _make_request( + QueryType.IDTYPE_VALUE.value, + {"type": "idtype-value", "value": {"id_type": "UIN"}}, + ) + response = service.execute_search(request) + item = response.search_response[0] + self.assertEqual(item.status, "rjct") + self.assertEqual( + item.status_reason_code, + SearchStatusReasonCode.SEARCH_CRITERIA_INVALID.value, + ) + + def test_expression_query_without_eq_is_rejected(self): + service = DisabilitySearchService(self.env) + request = _make_request( + QueryType.EXPRESSION.value, + { + "type": "ns:org:QueryType:expression", + "value": { + "expression": {"query": {"search_text": {"$ne": "x"}}}, + }, + }, + ) + response = service.execute_search(request) + item = response.search_response[0] + self.assertEqual(item.status, "rjct") + self.assertEqual( + item.status_reason_code, + SearchStatusReasonCode.SEARCH_CRITERIA_INVALID.value, + ) + + # ------------------------------------------------------------------ + # Partner lookup + # ------------------------------------------------------------------ + + def test_unknown_identifier_returns_register_not_found(self): + service = DisabilitySearchService(self.env) + request = _make_request(QueryType.EXPRESSION.value, _expression_query("UIN-UNKNOWN")) + response = service.execute_search(request) + item = response.search_response[0] + self.assertEqual(item.status, "rjct") + self.assertEqual(item.status_reason_code, REGISTER_NOT_FOUND_CODE) + self.assertIsNone(item.data) + + def test_partner_without_disability_field_returns_false(self): + """If has_disability is not set / not present, the wire format + key has_disability is reported as False — the SP side then + evaluates the variable as False rather than failing.""" + service = DisabilitySearchService(self.env) + request = _make_request(QueryType.EXPRESSION.value, _expression_query("UIN-DR-2")) + response = service.execute_search(request) + item = response.search_response[0] + self.assertEqual(item.status, "succ") + self.assertEqual(item.data.reg_records[0]["has_disability"], False) + + # ------------------------------------------------------------------ + # Response envelope shape + # ------------------------------------------------------------------ + + def test_response_envelope_carries_dr_reg_type_constants(self): + service = DisabilitySearchService(self.env) + request = _make_request(QueryType.EXPRESSION.value, _expression_query("UIN-DR-1")) + response = service.execute_search(request) + item = response.search_response[0] + self.assertEqual(item.data.reg_type, DR_REG_TYPE) + self.assertEqual(item.data.reg_record_type, DR_REG_RECORD_TYPE) + + def test_reg_record_carries_wire_format_keys(self): + """The reg_record is shaped for SP-side ``dci_attribute_path`` + lookups. Lock the contract: must contain ``has_disability`` and + optional disability metadata, must NOT include the local field + name ``is_person_with_disability`` (which never existed on the + model — old DRService legacy).""" + service = DisabilitySearchService(self.env) + request = _make_request(QueryType.EXPRESSION.value, _expression_query("UIN-DR-1")) + response = service.execute_search(request) + record = response.search_response[0].data.reg_records[0] + self.assertIn("has_disability", record) + self.assertIn("disability_severity_code", record) + self.assertIn("disability_review_category", record) + self.assertIn("disability_next_review", record) + self.assertNotIn("is_person_with_disability", record) + + # ------------------------------------------------------------------ + # Batch processing + # ------------------------------------------------------------------ + + def test_multiple_items_processed_independently(self): + """One item failing must not affect the others. Two requests: + one valid, one for an unknown identifier — both produce items + with distinct statuses and reference_ids.""" + service = DisabilitySearchService(self.env) + request = SearchRequest( + transaction_id="txn-batch", + search_request=[ + SearchRequestItem( + reference_id="r-ok", + timestamp=datetime.now(UTC), + search_criteria=SearchCriteria( + query_type=QueryType.EXPRESSION.value, + query=_expression_query("UIN-DR-1"), + ), + ), + SearchRequestItem( + reference_id="r-missing", + timestamp=datetime.now(UTC), + search_criteria=SearchCriteria( + query_type=QueryType.EXPRESSION.value, + query=_expression_query("UIN-UNKNOWN"), + ), + ), + ], + ) + response = service.execute_search(request) + self.assertEqual(len(response.search_response), 2) + by_ref = {item.reference_id: item for item in response.search_response} + self.assertEqual(by_ref["r-ok"].status, "succ") + self.assertEqual(by_ref["r-missing"].status, "rjct") + self.assertEqual( + by_ref["r-missing"].status_reason_code, + REGISTER_NOT_FOUND_CODE, + ) + + def test_correlation_id_is_set_on_response(self): + service = DisabilitySearchService(self.env) + request = _make_request(QueryType.EXPRESSION.value, _expression_query("UIN-DR-1")) + response = service.execute_search(request) + self.assertTrue(response.correlation_id) + self.assertEqual(response.transaction_id, "txn-1")