` accessor)
### CEL Expression Examples
```python
# Vital statistics verification
-crvs.dci.is_alive == true and crvs.dci.birth_verified == true
+r.dci.crvs.is_alive == true and r.dci.crvs.birth_verified == true
# Parameterized event check
-crvs.dci.has_event('death') == true
+r.dci.crvs.has_event('death') == true
# Disability-based eligibility
-dr.dci.has_disability == true and dr.dci.severity('Mobility') >= 3
+r.dci.dr.has_disability == true and r.dci.dr.severity('Mobility') >= 3
# Multi-registry combined criteria
-crvs.dci.is_alive == true and dr.dci.has_disability == true and age_years(me.birthdate) >= 18
+r.dci.crvs.is_alive == true and r.dci.dr.has_disability == true and age_years(me.birthdate) >= 18
```
### Dependencies
diff --git a/spp_dci_indicators/static/description/index.html b/spp_dci_indicators/static/description/index.html
index 55cdc2b9f..12912881d 100644
--- a/spp_dci_indicators/static/description/index.html
+++ b/spp_dci_indicators/static/description/index.html
@@ -383,13 +383,13 @@ Key Capabilities
Integration tab, making the provider “DCI-backed”
Fetch and cache registry values per registrant via the Sync DCI
Values action (or the disabled-by-default daily cron)
-Query Civil Registration via crvs.dci.is_alive,
-crvs.dci.birth_verified, and the parameterized
-crvs.dci.has_event('birth'|'death')
-Query the Disability Registry via dr.dci.has_disability,
-dr.dci.assessed,
-dr.dci.vision_severe/hearing_severe/mobility_severe, and
-the parameterized dr.dci.severity('Vision'|'Hearing'|'Mobility')
+Query Civil Registration via r.dci.crvs.is_alive,
+r.dci.crvs.birth_verified, and the parameterized
+r.dci.crvs.has_event('birth'|'death')
+Query the Disability Registry via r.dci.dr.has_disability,
+r.dci.dr.assessed,
+r.dci.dr.vision_severe/hearing_severe/mobility_severe, and
+the parameterized r.dci.dr.severity('Vision'|'Hearing'|'Mobility')
Parameterized methods cache one value per (registrant, argument),
keyed via params_hash; arguments come from a fixed, pre-synced set
@@ -458,23 +458,23 @@ Extension Points
(_dci_metric_handlers for simple metrics, DCI_METHOD_ACCESSORS
+ _compute_method_values for parameterized ones)
Create the matching spp.cel.variable record (external source type,
-ttl cache strategy, <registry>.dci.<metric> accessor)
+ttl cache strategy, r.dci.<registry>.<metric> accessor)
CEL Expression Examples
# Vital statistics verification
-crvs.dci.is_alive == true and crvs.dci.birth_verified == true
+r.dci.crvs.is_alive == true and r.dci.crvs.birth_verified == true
# Parameterized event check
-crvs.dci.has_event('death') == true
+r.dci.crvs.has_event('death') == true
# Disability-based eligibility
-dr.dci.has_disability == true and dr.dci.severity('Mobility') >= 3
+r.dci.dr.has_disability == true and r.dci.dr.severity('Mobility') >= 3
# Multi-registry combined criteria
-crvs.dci.is_alive == true and dr.dci.has_disability == true and age_years(me.birthdate) >= 18
+r.dci.crvs.is_alive == true and r.dci.dr.has_disability == true and age_years(me.birthdate) >= 18
diff --git a/spp_dci_indicators/tests/__init__.py b/spp_dci_indicators/tests/__init__.py
index 21faa2db5..15c858873 100644
--- a/spp_dci_indicators/tests/__init__.py
+++ b/spp_dci_indicators/tests/__init__.py
@@ -5,6 +5,7 @@
from . import test_data_provider_dci
from . import test_dci_cel_fetcher
from . import test_dci_cel_dr
+from . import test_dci_cel_sr
from . import test_dci_cel_end_to_end
from . import test_dci_cel_params
from . import test_dci_cel_methods
diff --git a/spp_dci_indicators/tests/test_dci_cel_dr.py b/spp_dci_indicators/tests/test_dci_cel_dr.py
index 8c6b8e556..3fae546e4 100644
--- a/spp_dci_indicators/tests/test_dci_cel_dr.py
+++ b/spp_dci_indicators/tests/test_dci_cel_dr.py
@@ -55,33 +55,33 @@ def _var(self, accessor):
)
def test_dr_has_disability_true(self):
- var = self._var("dr.dci.has_disability")
+ var = self._var("r.dci.dr.has_disability")
with patch(GET_STATUS, return_value={"has_disability": True, "functional_scores": {}}):
result = self.Fetcher.fetch_values(var, [self.partner.id])
self.assertEqual(result, {self.partner.id: True})
def test_dr_has_disability_false_when_no_record(self):
- var = self._var("dr.dci.has_disability")
+ var = self._var("r.dci.dr.has_disability")
with patch(GET_STATUS, return_value=None):
result = self.Fetcher.fetch_values(var, [self.partner.id])
self.assertEqual(result, {self.partner.id: False})
def test_dr_assessed(self):
- var = self._var("dr.dci.assessed")
+ var = self._var("r.dci.dr.assessed")
with patch(GET_STATUS, return_value={"assessment_date": "2024-11-15"}):
self.assertEqual(self.Fetcher.fetch_values(var, [self.partner.id]), {self.partner.id: True})
def test_dr_vision_severe_true_at_threshold(self):
- var = self._var("dr.dci.vision_severe")
+ var = self._var("r.dci.dr.vision_severe")
with patch(GET_STATUS, return_value={"functional_scores": {"Vision": 3}}):
self.assertEqual(self.Fetcher.fetch_values(var, [self.partner.id]), {self.partner.id: True})
def test_dr_vision_severe_false_below_threshold(self):
- var = self._var("dr.dci.vision_severe")
+ var = self._var("r.dci.dr.vision_severe")
with patch(GET_STATUS, return_value={"functional_scores": {"Vision": 2}}):
self.assertEqual(self.Fetcher.fetch_values(var, [self.partner.id]), {self.partner.id: False})
def test_dr_mobility_severe(self):
- var = self._var("dr.dci.mobility_severe")
+ var = self._var("r.dci.dr.mobility_severe")
with patch(GET_STATUS, return_value={"functional_scores": {"Mobility": 4}}):
self.assertEqual(self.Fetcher.fetch_values(var, [self.partner.id]), {self.partner.id: True})
diff --git a/spp_dci_indicators/tests/test_dci_cel_end_to_end.py b/spp_dci_indicators/tests/test_dci_cel_end_to_end.py
index 7c6d5fc4c..0d24d73ef 100644
--- a/spp_dci_indicators/tests/test_dci_cel_end_to_end.py
+++ b/spp_dci_indicators/tests/test_dci_cel_end_to_end.py
@@ -3,10 +3,10 @@
usable in a real CEL expression.
Chain exercised:
- seeded variable (crvs.dci.is_alive, external, DCI-backed provider)
+ seeded variable (r.dci.crvs.is_alive, external, DCI-backed provider)
-> precompute_variable -> fetcher -> CRVS check_death (mocked)
-> cached in spp.data.value (keyed by cel_accessor)
- -> compile_expression("crvs.dci.is_alive == true") filters partners.
+ -> compile_expression("r.dci.crvs.is_alive == true") filters partners.
This nails both the name-vs-accessor cache keying and the value encoding the
comparison SQL expects.
@@ -59,8 +59,8 @@ def _make_registrant(cls, name, id_value):
return partner
def test_variable_accessor_is_new_format(self):
- """Guard: the seeded variable uses the
.dci. accessor."""
- self.assertEqual(self.var.cel_accessor, "crvs.dci.is_alive")
+ """Guard: the seeded variable uses the r.dci.. accessor."""
+ self.assertEqual(self.var.cel_accessor, "r.dci.crvs.is_alive")
def test_fetch_caches_under_accessor_key(self):
"""precompute must write spp.data.value keyed by cel_accessor (not name)."""
@@ -68,7 +68,7 @@ def test_fetch_caches_under_accessor_key(self):
self.CacheMgr.precompute_variable("dci.crvs.is_alive", [self.alive.id])
cached = self.env["spp.data.value"].search(
- [("variable_name", "=", "crvs.dci.is_alive"), ("subject_id", "=", self.alive.id)]
+ [("variable_name", "=", "r.dci.crvs.is_alive"), ("subject_id", "=", self.alive.id)]
)
self.assertTrue(cached, "value should be cached under the cel_accessor key")
# Booleans are stored as 1/0 so the metric comparison SQL can cast them.
@@ -85,7 +85,7 @@ def test_cel_expression_filters_on_cached_dci_value(self):
self.CacheMgr.precompute_variable("dci.crvs.is_alive", [self.dead.id])
result = self.CelService.compile_expression(
- "crvs.dci.is_alive == true",
+ "r.dci.crvs.is_alive == true",
profile="registry_individuals",
base_domain=[("id", "in", [self.alive.id, self.dead.id])],
limit=0,
@@ -94,5 +94,5 @@ def test_cel_expression_filters_on_cached_dci_value(self):
self.assertTrue(result.get("valid"), result.get("error"))
matched = self.env["res.partner"].search(result["domain"])
- self.assertIn(self.alive, matched, "alive person should match crvs.dci.is_alive == true")
+ self.assertIn(self.alive, matched, "alive person should match r.dci.crvs.is_alive == true")
self.assertNotIn(self.dead, matched, "dead person should not match")
diff --git a/spp_dci_indicators/tests/test_dci_cel_fetcher.py b/spp_dci_indicators/tests/test_dci_cel_fetcher.py
index 86c81226d..422bdfa70 100644
--- a/spp_dci_indicators/tests/test_dci_cel_fetcher.py
+++ b/spp_dci_indicators/tests/test_dci_cel_fetcher.py
@@ -47,7 +47,7 @@ def setUpClass(cls):
{
"name": "zz_test.crvs.is_alive",
"label": "DCI: Is Alive",
- "cel_accessor": "crvs.dci.is_alive",
+ "cel_accessor": "r.dci.crvs.is_alive",
"source_type": "external",
"value_type": "boolean",
"external_provider_id": cls.provider.id,
@@ -79,7 +79,7 @@ def test_fetch_birth_verified(self):
{
"name": "zz_test.crvs.birth_verified",
"label": "DCI: Birth Verified",
- "cel_accessor": "crvs.dci.birth_verified",
+ "cel_accessor": "r.dci.crvs.birth_verified",
"source_type": "external",
"value_type": "boolean",
"external_provider_id": self.provider.id,
@@ -101,7 +101,7 @@ def test_fetch_unknown_accessor_returns_empty(self):
{
"name": "zz_test.crvs.unknown",
"label": "Unknown",
- "cel_accessor": "crvs.dci.not_a_metric",
+ "cel_accessor": "r.dci.crvs.not_a_metric",
"source_type": "external",
"value_type": "boolean",
"external_provider_id": self.provider.id,
@@ -143,7 +143,7 @@ def test_sync_for_partners_caches_values(self):
count = self.Fetcher.sync_for_partners([self.partner.id], variables=self.var_is_alive)
self.assertGreaterEqual(count, 1)
cached = self.env["spp.data.value"].search(
- [("variable_name", "=", "crvs.dci.is_alive"), ("subject_id", "=", self.partner.id)]
+ [("variable_name", "=", "r.dci.crvs.is_alive"), ("subject_id", "=", self.partner.id)]
)
self.assertTrue(cached)
self.assertEqual(cached.value_json, {"value": 1})
diff --git a/spp_dci_indicators/tests/test_dci_cel_fetcher_errors.py b/spp_dci_indicators/tests/test_dci_cel_fetcher_errors.py
index 58e21b0ec..b8e89fa1d 100644
--- a/spp_dci_indicators/tests/test_dci_cel_fetcher_errors.py
+++ b/spp_dci_indicators/tests/test_dci_cel_fetcher_errors.py
@@ -74,7 +74,7 @@ def test_fetch_values_handler_returns_none_skips_subject(self):
{
"name": "zz_test_edge.handler_none",
"label": "Handler None Test",
- "cel_accessor": "dr.dci.has_disability",
+ "cel_accessor": "r.dci.dr.has_disability",
"source_type": "external",
"value_type": "boolean",
"external_provider_id": self.provider.id,
@@ -100,7 +100,7 @@ def test_fetch_values_no_data_source_returns_empty(self):
{
"name": "zz_test_edge.no_ds_var",
"label": "No DS var",
- "cel_accessor": "dr.dci.assessed",
+ "cel_accessor": "r.dci.dr.assessed",
"source_type": "external",
"value_type": "boolean",
"external_provider_id": provider_no_ds.id,
@@ -248,20 +248,20 @@ def test_unknown_event_arg_is_skipped(self):
"""
from odoo.addons.spp_dci_indicators.models.dci_cel_fetcher import DCI_METHOD_ACCESSORS
- original_args = list(DCI_METHOD_ACCESSORS["crvs.dci.has_event"]["args"])
- DCI_METHOD_ACCESSORS["crvs.dci.has_event"]["args"] = ["birth", "unknown_event", "death"]
+ original_args = list(DCI_METHOD_ACCESSORS["r.dci.crvs.has_event"]["args"])
+ DCI_METHOD_ACCESSORS["r.dci.crvs.has_event"]["args"] = ["birth", "unknown_event", "death"]
try:
with patch(VERIFY_BIRTH, return_value={"x": 1}), patch(CHECK_DEATH, return_value=False):
partner = self.env["res.partner"].browse([])
pairs = self.Fetcher._compute_method_values(
- "crvs.dci.has_event",
+ "r.dci.crvs.has_event",
self.dci_source,
partner,
"NID",
"VAL-EDGE",
)
finally:
- DCI_METHOD_ACCESSORS["crvs.dci.has_event"]["args"] = original_args
+ DCI_METHOD_ACCESSORS["r.dci.crvs.has_event"]["args"] = original_args
arg_keys = [p[0]["arg"] for p in pairs]
self.assertIn("birth", arg_keys)
@@ -300,7 +300,7 @@ def setUpClass(cls):
{
"name": "zz_cron_edge.crvs.is_alive",
"label": "DCI: Is Alive (cron edge)",
- "cel_accessor": "crvs.dci.is_alive",
+ "cel_accessor": "r.dci.crvs.is_alive",
"source_type": "external",
"value_type": "boolean",
"external_provider_id": cls.provider.id,
@@ -330,7 +330,7 @@ def test_cron_syncs_registrants_in_loop(self):
# Use a very large batch_size so the loop runs exactly once.
self.Fetcher.cron_sync_all_registrants(batch_size=10000)
cached = self.env["spp.data.value"].search(
- [("variable_name", "=", "crvs.dci.is_alive"), ("subject_id", "=", self.partner.id)]
+ [("variable_name", "=", "r.dci.crvs.is_alive"), ("subject_id", "=", self.partner.id)]
)
self.assertTrue(cached)
@@ -362,13 +362,13 @@ def setUpClass(cls):
"dci_data_source_id": cls.dci_source.id,
}
)
- # crvs.dci.is_alive is NOT in DCI_METHOD_ACCESSORS, so it goes through
+ # r.dci.crvs.is_alive is NOT in DCI_METHOD_ACCESSORS, so it goes through
# the precompute_variable path in sync_for_partners.
cls.var_is_alive = cls.env["spp.cel.variable"].create(
{
"name": "zz_precomp_edge.crvs.is_alive",
"label": "DCI: Is Alive (precompute edge)",
- "cel_accessor": "crvs.dci.is_alive",
+ "cel_accessor": "r.dci.crvs.is_alive",
"source_type": "external",
"value_type": "boolean",
"external_provider_id": cls.provider.id,
diff --git a/spp_dci_indicators/tests/test_dci_cel_methods.py b/spp_dci_indicators/tests/test_dci_cel_methods.py
index 3a2fa9f61..5f02e55bf 100644
--- a/spp_dci_indicators/tests/test_dci_cel_methods.py
+++ b/spp_dci_indicators/tests/test_dci_cel_methods.py
@@ -60,12 +60,12 @@ def _provider(name, code, registry_type):
# ── resolver rewrite ─────────────────────────────────────────────────────
def test_resolver_rewrites_severity_call(self):
- out = self.Resolver.expand_expression("dr.dci.severity('Vision') >= 3", context_type="individual")
- self.assertIn("metric('dr.dci.severity', me, arg='Vision')", out["expression"])
+ out = self.Resolver.expand_expression("r.dci.dr.severity('Vision') >= 3", context_type="individual")
+ self.assertIn("metric('r.dci.dr.severity', me, arg='Vision')", out["expression"])
def test_resolver_rewrites_has_event_call(self):
- out = self.Resolver.expand_expression("crvs.dci.has_event('death') == true", context_type="individual")
- self.assertIn("metric('crvs.dci.has_event', me, arg='death')", out["expression"])
+ out = self.Resolver.expand_expression("r.dci.crvs.has_event('death') == true", context_type="individual")
+ self.assertIn("metric('r.dci.crvs.has_event', me, arg='death')", out["expression"])
# ── materialization ──────────────────────────────────────────────────────
@@ -73,7 +73,7 @@ def test_materialize_severity_one_row_per_type(self):
with patch(GET_STATUS, return_value={"functional_scores": {"Vision": 4, "Hearing": 1}}):
n = self.Fetcher.sync_for_partners([self.partner.id], variables=self.sev_var)
self.assertEqual(n, 3) # Vision, Hearing, Mobility
- rows = self.DV.search([("variable_name", "=", "dr.dci.severity"), ("subject_id", "=", self.partner.id)])
+ rows = self.DV.search([("variable_name", "=", "r.dci.dr.severity"), ("subject_id", "=", self.partner.id)])
self.assertEqual(len(rows), 3)
def test_materialize_has_event_one_row_per_event(self):
@@ -97,11 +97,11 @@ def _match(self, expr):
def test_e2e_severity_discriminates_by_arg(self):
with patch(GET_STATUS, return_value={"functional_scores": {"Vision": 4, "Hearing": 1}}):
self.Fetcher.sync_for_partners([self.partner.id], variables=self.sev_var)
- self.assertIn(self.partner, self._match("dr.dci.severity('Vision') >= 3"))
- self.assertNotIn(self.partner, self._match("dr.dci.severity('Hearing') >= 3"))
+ self.assertIn(self.partner, self._match("r.dci.dr.severity('Vision') >= 3"))
+ self.assertNotIn(self.partner, self._match("r.dci.dr.severity('Hearing') >= 3"))
def test_e2e_has_event_death(self):
with patch(VERIFY_BIRTH, return_value=None), patch(CHECK_DEATH, return_value=True):
self.Fetcher.sync_for_partners([self.partner.id], variables=self.event_var)
- self.assertIn(self.partner, self._match("crvs.dci.has_event('death') == true"))
- self.assertNotIn(self.partner, self._match("crvs.dci.has_event('birth') == true"))
+ self.assertIn(self.partner, self._match("r.dci.crvs.has_event('death') == true"))
+ self.assertNotIn(self.partner, self._match("r.dci.crvs.has_event('birth') == true"))
diff --git a/spp_dci_indicators/tests/test_dci_cel_sr.py b/spp_dci_indicators/tests/test_dci_cel_sr.py
new file mode 100644
index 000000000..9eb58f507
--- /dev/null
+++ b/spp_dci_indicators/tests/test_dci_cel_sr.py
@@ -0,0 +1,135 @@
+# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
+"""Tests for the SR (Social Registry) DCI fetch handlers.
+
+All six r.dci.sr.* metrics derive from a single person record returned by
+SRService.search_person (identifiers -> registration, enrolled_programs,
+household_info). The service is mocked here.
+
+Semantics: every metric returns a value for every queried person - the CEL
+SQL fast path requires a complete cache (a row per candidate). A person not
+found in the SR yields the semantic defaults: not registered, 0 programs,
+household size 0, not a head, not a large household.
+"""
+
+from unittest.mock import patch
+
+from odoo.tests import TransactionCase, tagged
+
+from odoo.addons.spp_dci.schemas.constants import RegistryType
+
+SEARCH_PERSON = "odoo.addons.spp_dci_client_sr.services.sr_service.SRService.search_person"
+
+
+@tagged("post_install", "-at_install")
+class TestDCICelSRHandlers(TransactionCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.Fetcher = cls.env["spp.dci.cel.fetcher"]
+ cls.sr_source = cls.env["spp.dci.data.source"].create(
+ {
+ "name": "National SR",
+ "code": "national_sr_t",
+ "base_url": "https://sr.example.org/api",
+ "registry_type": RegistryType.SOCIAL_REGISTRY.value,
+ "our_sender_id": "openspp.test",
+ "auth_type": "none",
+ "state": "active",
+ }
+ )
+ cls.provider = cls.env["spp.data.provider"].create(
+ {"name": "SR", "code": "national_sr_prov", "dci_data_source_id": cls.sr_source.id}
+ )
+ cls.id_code = cls.env.ref("spp_vocabulary.code_id_type_national_id")
+ cls.partner = cls.env["res.partner"].create({"name": "SR Person", "is_registrant": True, "is_group": False})
+ cls.env["spp.registry.id"].create(
+ {"partner_id": cls.partner.id, "id_type_id": cls.id_code.id, "value": "NID-SR-1"}
+ )
+
+ def _var(self, accessor, value_type="boolean"):
+ # One variable per accessor: spp.cel.variable enforces UNIQUE
+ # (cel_accessor, applies_to), so repeat fetches reuse the record.
+ if not hasattr(self, "_vars"):
+ self._vars = {}
+ if accessor not in self._vars:
+ self._vars[accessor] = self.env["spp.cel.variable"].create(
+ {
+ "name": f"zz_{accessor}",
+ "label": accessor,
+ "cel_accessor": accessor,
+ "source_type": "external",
+ "value_type": value_type,
+ "external_provider_id": self.provider.id,
+ "cache_strategy": "ttl",
+ }
+ )
+ return self._vars[accessor]
+
+ def _fetch(self, accessor, person, value_type="boolean"):
+ var = self._var(accessor, value_type=value_type)
+ with patch(SEARCH_PERSON, return_value=person):
+ return self.Fetcher.fetch_values(var, [self.partner.id])
+
+ # -- is_registered ---------------------------------------------------------
+
+ def test_sr_is_registered_true(self):
+ result = self._fetch("r.dci.sr.is_registered", {"id": "EXT-1", "name": "SR Person"})
+ self.assertEqual(result, {self.partner.id: True})
+
+ def test_sr_is_registered_false_when_not_found(self):
+ result = self._fetch("r.dci.sr.is_registered", None)
+ self.assertEqual(result, {self.partner.id: False})
+
+ # -- programmes ------------------------------------------------------------
+
+ def test_sr_program_count(self):
+ person = {"enrolled_programs": [{"programme_name": "A"}, {"programme_name": "B"}]}
+ result = self._fetch("r.dci.sr.program_count", person, value_type="number")
+ self.assertEqual(result, {self.partner.id: 2})
+
+ def test_sr_program_count_zero_without_enrollments(self):
+ result = self._fetch("r.dci.sr.program_count", {"id": "EXT-1"}, value_type="number")
+ self.assertEqual(result, {self.partner.id: 0})
+
+ def test_sr_program_count_zero_when_not_found(self):
+ """Not found in SR -> 0 programs (a value, so the cache stays complete)."""
+ result = self._fetch("r.dci.sr.program_count", None, value_type="number")
+ self.assertEqual(result, {self.partner.id: 0})
+
+ def test_sr_has_programs(self):
+ person = {"enrolled_programs": [{"programme_name": "A"}]}
+ self.assertEqual(self._fetch("r.dci.sr.has_programs", person), {self.partner.id: True})
+ self.assertEqual(self._fetch("r.dci.sr.has_programs", {"id": "X"}), {self.partner.id: False})
+
+ # -- household -------------------------------------------------------------
+
+ def test_sr_household_size(self):
+ person = {"household_info": {"household_size": 4, "is_household_head": False}}
+ result = self._fetch("r.dci.sr.household_size", person, value_type="number")
+ self.assertEqual(result, {self.partner.id: 4})
+
+ def test_sr_household_size_zero_without_household(self):
+ """Registered but household-less -> size 0 (complete cache)."""
+ result = self._fetch("r.dci.sr.household_size", {"id": "EXT-1"}, value_type="number")
+ self.assertEqual(result, {self.partner.id: 0})
+
+ def test_sr_is_head_of_household(self):
+ person = {"household_info": {"household_size": 3, "is_household_head": True}}
+ self.assertEqual(self._fetch("r.dci.sr.is_head_of_household", person), {self.partner.id: True})
+
+ def test_sr_is_head_false_without_household(self):
+ result = self._fetch("r.dci.sr.is_head_of_household", {"id": "EXT-1"})
+ self.assertEqual(result, {self.partner.id: False})
+
+ def test_sr_large_household_above_threshold(self):
+ person = {"household_info": {"household_size": 6}}
+ self.assertEqual(self._fetch("r.dci.sr.large_household", person), {self.partner.id: True})
+
+ def test_sr_large_household_at_threshold_is_false(self):
+ # The seeded variable documents "more than 5 members"
+ person = {"household_info": {"household_size": 5}}
+ self.assertEqual(self._fetch("r.dci.sr.large_household", person), {self.partner.id: False})
+
+ def test_sr_large_household_false_without_household(self):
+ result = self._fetch("r.dci.sr.large_household", {"id": "EXT-1"})
+ self.assertEqual(result, {self.partner.id: False})
diff --git a/spp_dci_indicators/tests/test_indicator_data.py b/spp_dci_indicators/tests/test_indicator_data.py
index 00784fe77..e9d968b5c 100644
--- a/spp_dci_indicators/tests/test_indicator_data.py
+++ b/spp_dci_indicators/tests/test_indicator_data.py
@@ -31,7 +31,7 @@ def test_dr_variables_exist(self):
self.assertTrue(var, "dci.dr.has_disability should exist")
self.assertEqual(var.value_type, "boolean")
self.assertEqual(var.source_type, "external")
- self.assertEqual(var.cel_accessor, "dr.dci.has_disability")
+ self.assertEqual(var.cel_accessor, "r.dci.dr.has_disability")
self.assertEqual(var.applies_to, "individual")
self.assertTrue(var.is_system)
@@ -77,7 +77,7 @@ def test_all_dci_variables_count(self):
variables = Variable.search([("category_id", "=", category.id)])
# 5 DR + 3 CRVS + 3 IBR + 6 SR + 2 parameterized methods
- # (dr.dci.severity, crvs.dci.has_event) = 19 variables
+ # (r.dci.dr.severity, r.dci.crvs.has_event) = 19 variables
self.assertEqual(len(variables), 19, "Should have 19 DCI variables")
def test_dci_variables_have_labels(self):
diff --git a/spp_dci_server/README.rst b/spp_dci_server/README.rst
index 73c4acb61..bc1b589d5 100644
--- a/spp_dci_server/README.rst
+++ b/spp_dci_server/README.rst
@@ -35,6 +35,10 @@ Key Capabilities
``/dci_api/v1`` with automatic OpenAPI documentation
- **HTTP Signature Verification**: Validates inbound requests using
Ed25519/RSA signatures against sender public keys
+- **Bearer Authentication**: Accepts either a static token (system
+ parameter ``dci.api_tokens``) or an OAuth2 client-credentials access
+ token (a JWT issued by ``spp_api_v2`` at ``POST /api_v2/oauth/token``,
+ validated against an active ``spp.api.client``)
- **Async Transaction Processing**: Queues search, subscribe, and
unsubscribe operations for background processing with automatic
callbacks
@@ -86,6 +90,23 @@ Server signing keys are automatically generated and activated on
installation. To manage keys manually, use the technical interface for
``spp.dci.server.key``.
+**Endpoint user (required for Social Registry search):** The DCI FastAPI
+endpoint ships configured to run as the **public user**. Serving Social
+Registry searches additionally requires the endpoint's user to hold the
+``spp_registry.group_registry_viewer`` group (the search is
+access-gated), so with the default public user SR searches are rejected
+with an access error. Assign a dedicated service user before going live:
+
+5. Create a user (e.g. *DCI Endpoint Service*) and grant it **Registry:
+ Viewer** (``spp_registry.group_registry_viewer``).
+6. Open the DCI ``fastapi.endpoint`` record (the ``dci_api`` app, root
+ path ``/dci_api/v1``) and set its **User** to that service account.
+
+Authentication of the *caller* is independent of this: requests are
+authenticated with a static bearer token (``dci.api_tokens``) or an
+OAuth2 access token. The endpoint user only sets the Odoo permission
+context the search executes under.
+
UI Location
~~~~~~~~~~~
diff --git a/spp_dci_server/__manifest__.py b/spp_dci_server/__manifest__.py
index be4388f1e..ebfd938c2 100644
--- a/spp_dci_server/__manifest__.py
+++ b/spp_dci_server/__manifest__.py
@@ -1,7 +1,7 @@
{ # pylint: disable=pointless-statement
"name": "OpenSPP DCI Server",
"summary": "DCI API server infrastructure with FastAPI routers",
- "version": "19.0.2.0.1",
+ "version": "19.0.2.0.3",
"category": "OpenSPP/Integration",
"author": "OpenSPP.org",
"website": "https://github.com/OpenSPP/OpenSPP2",
@@ -24,6 +24,7 @@
"views/sender_registry_views.xml",
"views/transaction_views.xml",
"views/subscription_views.xml",
+ "views/res_config_settings_views.xml",
],
"installable": True,
"application": False,
diff --git a/spp_dci_server/middleware/signature.py b/spp_dci_server/middleware/signature.py
index ee780e97f..54a6bb048 100644
--- a/spp_dci_server/middleware/signature.py
+++ b/spp_dci_server/middleware/signature.py
@@ -246,6 +246,40 @@ async def verify_dci_signature(
_empty_tokens_warning_logged = False
+def _validate_oauth2_jwt(env: Environment, token: str) -> str | None:
+ """Return the client_id when ``token`` is a valid OAuth2 access token.
+
+ Lets a DCI caller authenticate with an OAuth2 client-credentials JWT issued
+ by spp_api_v2 (``POST /api_v2/oauth/token``) instead of a static bearer
+ token. The JWT is validated with spp_api_v2's own verifier (HS256 signature,
+ ``iss``/``aud``, expiry), then the ``client_id`` claim must resolve to an
+ active ``spp.api.client``.
+
+ Returns the client_id on success, or ``None`` on any failure (bad signature,
+ expired, wrong issuer/audience, secret unset, unknown/inactive client, or a
+ non-JWT opaque token) so the caller can fall back to other auth paths.
+ """
+ try:
+ from odoo.addons.spp_api_v2.middleware.auth import _validate_jwt_token
+ except ImportError:
+ return None
+ try:
+ payload = _validate_jwt_token(env, token)
+ except Exception:
+ # Not a valid spp_api_v2 JWT — fall back to static-token handling.
+ return None
+ client_id = payload.get("client_id")
+ if not client_id:
+ return None
+ # nosemgrep: odoo-sudo-without-context
+ client = env["spp.api.client"].sudo().search([("client_id", "=", client_id), ("active", "=", True)], limit=1)
+ if not client:
+ _logger.warning("OAuth2 JWT names unknown/inactive client_id: %s", client_id)
+ return None
+ _logger.debug("DCI request authenticated via OAuth2 JWT for client %s", client_id)
+ return client_id
+
+
async def verify_bearer_token(
env: Annotated[Environment, Depends(odoo_env)],
authorization: Annotated[str | None, Header()] = None,
@@ -332,15 +366,22 @@ async def verify_bearer_token(
for candidate in accepted_tokens:
if hmac.compare_digest(token, candidate):
matched = True
- if not matched:
- _logger.warning("DCI request has invalid Bearer token")
- raise DCIHTTPException(
- status_code=status.HTTP_401_UNAUTHORIZED,
- error_message="Invalid Bearer token",
- error_code="err.auth.invalid_token",
- headers={"WWW-Authenticate": "Bearer"},
- )
- _logger.debug("Bearer token validated against configured tokens")
+ if matched:
+ _logger.debug("Bearer token validated against configured tokens")
+ return token
+ # Not a configured static token — it may be an OAuth2 access token.
+ if _validate_oauth2_jwt(env, token):
+ return token
+ _logger.warning("DCI request has invalid Bearer token")
+ raise DCIHTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ error_message="Invalid Bearer token",
+ error_code="err.auth.invalid_token",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+
+ # No static tokens configured: still accept a valid OAuth2 access token.
+ if _validate_oauth2_jwt(env, token):
return token
# nosemgrep: odoo-timing-attack-password # not a token compare; matches a config flag value
diff --git a/spp_dci_server/models/__init__.py b/spp_dci_server/models/__init__.py
index c3993853e..b27361272 100644
--- a/spp_dci_server/models/__init__.py
+++ b/spp_dci_server/models/__init__.py
@@ -1,5 +1,6 @@
from . import (
fastapi_endpoint_dci,
+ res_config_settings,
sender_registry,
server_key,
subscription,
diff --git a/spp_dci_server/models/fastapi_endpoint_dci.py b/spp_dci_server/models/fastapi_endpoint_dci.py
index 0bc8d41ad..62b5e214a 100644
--- a/spp_dci_server/models/fastapi_endpoint_dci.py
+++ b/spp_dci_server/models/fastapi_endpoint_dci.py
@@ -118,6 +118,7 @@ def _get_fastapi_routers(self) -> list[APIRouter]:
from ..routers.bulk_upload import dci_bulk_upload_router
from ..routers.callbacks import dci_callback_router
from ..routers.jwks import jwks_router
+ from ..routers.ping import dci_ping_router
from ..routers.receipt import dci_receipt_router
from ..routers.registry_aliases import (
crvs_router,
@@ -129,13 +130,27 @@ def _get_fastapi_routers(self) -> list[APIRouter]:
# JWKS at root level (/.well-known/jwks.json)
routers.append(jwks_router)
- # Registry operations under /social/registry prefix (SPDCI-compliant)
- social_router = APIRouter(prefix="/social/registry", tags=["Social Registry"])
+ # SPDCI-compliant mount: the spec defines /registry/* relative to
+ # the deployment base URL; the registry type is discriminated by
+ # the message's reg_type, not by a URL segment.
+ registry_router = APIRouter(prefix="/registry", tags=["DCI Registry"])
+ registry_router.include_router(dci_search_router)
+ registry_router.include_router(dci_async_router)
+ registry_router.include_router(dci_callback_router)
+ registry_router.include_router(dci_bulk_upload_router)
+ registry_router.include_router(dci_receipt_router)
+ registry_router.include_router(dci_ping_router)
+ routers.append(registry_router)
+
+ # Legacy long-form mount kept for existing deployments that
+ # configured base URLs ending in /social.
+ social_router = APIRouter(prefix="/social/registry", tags=["Social Registry (legacy path)"])
social_router.include_router(dci_search_router)
social_router.include_router(dci_async_router)
social_router.include_router(dci_callback_router)
social_router.include_router(dci_bulk_upload_router)
social_router.include_router(dci_receipt_router)
+ social_router.include_router(dci_ping_router)
routers.append(social_router)
# Add SPDCI-compliant registry type endpoints (stub implementations)
diff --git a/spp_dci_server/models/res_config_settings.py b/spp_dci_server/models/res_config_settings.py
new file mode 100644
index 000000000..0dac3b0c9
--- /dev/null
+++ b/spp_dci_server/models/res_config_settings.py
@@ -0,0 +1,85 @@
+# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models
+
+
+class ResConfigSettings(models.TransientModel):
+ _inherit = "res.config.settings"
+
+ # The DCI auth middleware stores every boolean flag as the literal string
+ # "true"/"false" (see middleware/signature.py:_read_security_flag) and falls
+ # back to an in-code default when the parameter is missing. Odoo's
+ # ``config_parameter`` boolean machinery is incompatible with that: it
+ # *deletes* the parameter when the field is False (so a default-true flag can
+ # never be turned off) and reads back ``bool("false")`` as True. We therefore
+ # manage these flags explicitly in get_values/set_values instead.
+ _DCI_FLAG_PARAMS = {
+ "dci_api_tokens_required": ("dci.api_tokens_required", True),
+ "dci_allow_unsigned_requests": ("dci.allow_unsigned_requests", False),
+ "dci_bypass_bearer_auth": ("dci.bypass_bearer_auth", False),
+ "dci_allow_http_callbacks": ("dci.allow_http_callbacks", False),
+ "dci_allow_internal_callback_ips": ("dci.allow_internal_callback_ips", False),
+ }
+
+ # --- API authentication ---
+ dci_api_tokens = fields.Char(
+ string="DCI API Bearer Tokens",
+ config_parameter="dci.api_tokens",
+ help="Accepted bearer tokens for incoming DCI requests. "
+ "Comma-separated for multiple clients. Each token must match the "
+ "Bearer Token configured on the calling client's data source.",
+ )
+ dci_sender_id = fields.Char(
+ string="DCI Server Sender ID",
+ config_parameter="dci.sender_id",
+ default="openspp",
+ help="This server's own DCI sender id, stamped on outgoing envelopes.",
+ )
+ dci_api_tokens_required = fields.Boolean(
+ string="Require DCI API Tokens",
+ default=True,
+ help="When enabled and no tokens are configured, every request is "
+ "rejected (fail-closed). Disable only for development.",
+ )
+
+ # --- Development / insecure options (never enable in production) ---
+ dci_allow_unsigned_requests = fields.Boolean(
+ string="Allow Unsigned Requests",
+ default=False,
+ help="Development only. Accept DCI envelopes that carry no signature, "
+ "skipping signature verification. Never enable in production.",
+ )
+ dci_bypass_bearer_auth = fields.Boolean(
+ string="Bypass Bearer Authentication",
+ default=False,
+ help="Development only. Skip the bearer-token check entirely. Never enable in production.",
+ )
+ dci_allow_http_callbacks = fields.Boolean(
+ string="Allow HTTP Callbacks",
+ default=False,
+ help="Development only. Permit plain-http (non-TLS) callback URLs. Never enable in production.",
+ )
+ dci_allow_internal_callback_ips = fields.Boolean(
+ string="Allow Internal Callback IPs",
+ default=False,
+ help="Development only. Permit callbacks to internal/private IP addresses. Never enable in production.",
+ )
+
+ @api.model
+ def get_values(self):
+ res = super().get_values()
+ # System parameters require sudo; this view is gated to base.group_system.
+ # nosemgrep: odoo-sudo-without-context
+ icp = self.env["ir.config_parameter"].sudo()
+ for field_name, (param, default) in self._DCI_FLAG_PARAMS.items():
+ default_str = "true" if default else "false"
+ res[field_name] = icp.get_param(param, default_str).lower() == "true"
+ return res
+
+ def set_values(self):
+ super().set_values()
+ # System parameters require sudo; this view is gated to base.group_system.
+ # nosemgrep: odoo-sudo-without-context
+ icp = self.env["ir.config_parameter"].sudo()
+ for field_name, (param, _default) in self._DCI_FLAG_PARAMS.items():
+ icp.set_param(param, "true" if self[field_name] else "false")
diff --git a/spp_dci_server/readme/DESCRIPTION.md b/spp_dci_server/readme/DESCRIPTION.md
index ca469ea94..9a8932c8c 100644
--- a/spp_dci_server/readme/DESCRIPTION.md
+++ b/spp_dci_server/readme/DESCRIPTION.md
@@ -4,6 +4,7 @@ DCI API server infrastructure for receiving and processing Digital Convergence I
- **FastAPI Endpoints**: Exposes DCI-compliant REST API at `/dci_api/v1` with automatic OpenAPI documentation
- **HTTP Signature Verification**: Validates inbound requests using Ed25519/RSA signatures against sender public keys
+- **Bearer Authentication**: Accepts either a static token (system parameter `dci.api_tokens`) or an OAuth2 client-credentials access token (a JWT issued by `spp_api_v2` at `POST /api_v2/oauth/token`, validated against an active `spp.api.client`)
- **Async Transaction Processing**: Queues search, subscribe, and unsubscribe operations for background processing with automatic callbacks
- **Event Subscriptions**: Manages external system subscriptions to registry events (registration, update, delete) with notification delivery
- **JWKS Distribution**: Publishes server public keys at `/.well-known/jwks.json` for signature verification by clients
@@ -31,6 +32,13 @@ After installing:
Server signing keys are automatically generated and activated on installation. To manage keys manually, use the technical interface for `spp.dci.server.key`.
+**Endpoint user (required for Social Registry search):** The DCI FastAPI endpoint ships configured to run as the **public user**. Serving Social Registry searches additionally requires the endpoint's user to hold the `spp_registry.group_registry_viewer` group (the search is access-gated), so with the default public user SR searches are rejected with an access error. Assign a dedicated service user before going live:
+
+5. Create a user (e.g. *DCI Endpoint Service*) and grant it **Registry: Viewer** (`spp_registry.group_registry_viewer`).
+6. Open the DCI `fastapi.endpoint` record (the `dci_api` app, root path `/dci_api/v1`) and set its **User** to that service account.
+
+Authentication of the *caller* is independent of this: requests are authenticated with a static bearer token (`dci.api_tokens`) or an OAuth2 access token. The endpoint user only sets the Odoo permission context the search executes under.
+
### UI Location
- **Menu**: Settings > DCI > Configuration > Sender Registry
diff --git a/spp_dci_server/routers/ping.py b/spp_dci_server/routers/ping.py
new file mode 100644
index 000000000..d042d8335
--- /dev/null
+++ b/spp_dci_server/routers/ping.py
@@ -0,0 +1,42 @@
+# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
+"""DCI authenticated ping endpoint.
+
+A lightweight endpoint clients can call to verify both reachability *and*
+their bearer-token configuration in one request. Unlike the search routes it
+requires only a valid bearer token (no signed DCI envelope), so a plain GET is
+enough — which is exactly what the client's "Test Connection" needs.
+"""
+
+import logging
+from typing import Annotated
+
+from odoo.api import Environment
+
+from odoo.addons.fastapi.dependencies import odoo_env
+
+from fastapi import APIRouter, Depends
+
+from ..middleware.signature import verify_bearer_token
+
+_logger = logging.getLogger(__name__)
+
+dci_ping_router = APIRouter(tags=["DCI Ping"])
+
+
+@dci_ping_router.get("/ping")
+async def ping(
+ env: Annotated[Environment, Depends(odoo_env)],
+ _bearer_token: Annotated[str, Depends(verify_bearer_token)],
+):
+ """Authenticated liveness/auth check.
+
+ Returns 200 with the server's sender id when the bearer token is accepted.
+ The ``verify_bearer_token`` dependency raises 401 when the token is missing
+ or not in the configured ``dci.api_tokens`` allow-list, so a client can
+ distinguish a reachable-but-misconfigured server from a working one.
+
+ **Authentication**: Bearer token only (no DCI signature required).
+ """
+ # nosemgrep: odoo-sudo-without-context
+ sender_id = env["ir.config_parameter"].sudo().get_param("dci.sender_id", "openspp")
+ return {"status": "ok", "sender_id": sender_id}
diff --git a/spp_dci_server/routers/search.py b/spp_dci_server/routers/search.py
index 9081efda9..1c031fb29 100644
--- a/spp_dci_server/routers/search.py
+++ b/spp_dci_server/routers/search.py
@@ -105,7 +105,13 @@ async def search_registry(
DCISocialSearchService,
)
- search_service = DCISocialSearchService(env)
+ # Hand the verified sender to the service so consent filtering
+ # engages - the consent adapter disengages when sender is None.
+ # sudo: technical lookup of an already-verified sender id; the
+ # endpoint user (often public) has no read access to the registry.
+ SenderRegistry = env["spp.dci.sender.registry"].sudo() # nosemgrep: odoo-sudo-without-context
+ sender_registry = SenderRegistry.get_by_sender_id(verified_sender_id)
+ search_service = DCISocialSearchService(env, sender_registry=sender_registry or None)
search_response = search_service.execute_search(search_request)
_logger.info(
"DCI search completed - transaction_id: %s, items: %d",
diff --git a/spp_dci_server/static/description/index.html b/spp_dci_server/static/description/index.html
index 960c9c7e3..980e54294 100644
--- a/spp_dci_server/static/description/index.html
+++ b/spp_dci_server/static/description/index.html
@@ -382,6 +382,10 @@ Key Capabilities
/dci_api/v1 with automatic OpenAPI documentation
HTTP Signature Verification: Validates inbound requests using
Ed25519/RSA signatures against sender public keys
+Bearer Authentication: Accepts either a static token (system
+parameter dci.api_tokens) or an OAuth2 client-credentials access
+token (a JWT issued by spp_api_v2 at POST /api_v2/oauth/token,
+validated against an active spp.api.client)
Async Transaction Processing: Queues search, subscribe, and
unsubscribe operations for background processing with automatic
callbacks
@@ -446,6 +450,22 @@ Configuration
Server signing keys are automatically generated and activated on
installation. To manage keys manually, use the technical interface for
spp.dci.server.key.
+Endpoint user (required for Social Registry search): The DCI FastAPI
+endpoint ships configured to run as the public user. Serving Social
+Registry searches additionally requires the endpoint’s user to hold the
+spp_registry.group_registry_viewer group (the search is
+access-gated), so with the default public user SR searches are rejected
+with an access error. Assign a dedicated service user before going live:
+
+- Create a user (e.g. DCI Endpoint Service) and grant it Registry:
+Viewer (spp_registry.group_registry_viewer).
+- Open the DCI fastapi.endpoint record (the dci_api app, root
+path /dci_api/v1) and set its User to that service account.
+
+Authentication of the caller is independent of this: requests are
+authenticated with a static bearer token (dci.api_tokens) or an
+OAuth2 access token. The endpoint user only sets the Odoo permission
+context the search executes under.
UI Location
diff --git a/spp_dci_server/tests/__init__.py b/spp_dci_server/tests/__init__.py
index 7cfe173af..d5076d779 100644
--- a/spp_dci_server/tests/__init__.py
+++ b/spp_dci_server/tests/__init__.py
@@ -9,9 +9,11 @@
from . import test_consent_adapter
from . import test_fastapi_endpoint_dci
from . import test_jwks_router
+from . import test_ping_router
from . import test_rate_limit_middleware
from . import test_receipt
from . import test_receipt_router
+from . import test_res_config_settings
from . import test_search_router
from . import test_sender_registry
from . import test_server_key
diff --git a/spp_dci_server/tests/test_bearer_middleware.py b/spp_dci_server/tests/test_bearer_middleware.py
index 0596e75c3..ecccfc9c3 100644
--- a/spp_dci_server/tests/test_bearer_middleware.py
+++ b/spp_dci_server/tests/test_bearer_middleware.py
@@ -9,6 +9,8 @@
"""
import asyncio
+import os
+from datetime import datetime, timedelta
from odoo.tests import tagged
@@ -16,6 +18,10 @@
from .common import DCIServerCommon
+# 48-char high-entropy secret so spp_api_v2's _validate_jwt_secret_strength
+# (>=32 chars, entropy >= 3.0) accepts it.
+_TEST_JWT_SECRET = "Zx9Kq2Lm7Pw4Rt6Yv1Nb8Hc3Jd5Fg0SaUeWiOqTzXyMnBvCr"
+
def _run(coro):
loop = asyncio.new_event_loop()
@@ -191,3 +197,97 @@ def test_security_flags_default_to_fail_closed(self):
safe_value,
f"{key} must default to {safe_value!r} (fail-closed)",
)
+
+
+@tagged("post_install", "-at_install")
+class TestOAuth2BearerToken(DCIServerCommon):
+ """The bearer dependency also accepts OAuth2 access tokens (spp_api_v2
+ JWTs) so DCI callers can authenticate with client-credentials, not only
+ static tokens."""
+
+ def setUp(self):
+ super().setUp()
+ from odoo.addons.spp_dci_server.middleware import signature as sig_module
+
+ self.verify_bearer_token = sig_module.verify_bearer_token
+ sig_module._bearer_bypass_warning_logged = False
+ sig_module._empty_tokens_warning_logged = False
+ self.ICP = self.env["ir.config_parameter"].sudo()
+ # Sign with the secret the verifier will use (env var wins over param).
+ self.secret = os.environ.get("OPENSPP_JWT_SECRET") or _TEST_JWT_SECRET
+ if not os.environ.get("OPENSPP_JWT_SECRET"):
+ self.ICP.set_param("spp_api_v2.jwt_secret", self.secret)
+ self.client = self.env["spp.api.client"].create(
+ {
+ "name": "DCI OAuth Test Client",
+ "partner_id": self.test_partner.id,
+ "organization_type_id": self.org_type_government.id,
+ }
+ )
+
+ def _call(self, authorization=None):
+ return _run(self.verify_bearer_token(self.env, authorization))
+
+ def _mint_jwt(self, client_id=None, expires_in_hours=1, secret=None):
+ import jwt
+
+ now = datetime.utcnow()
+ payload = {
+ "iss": "openspp-api-v2",
+ "aud": "openspp",
+ "sub": client_id or self.client.client_id,
+ "client_id": client_id or self.client.client_id,
+ "iat": now,
+ "exp": now + timedelta(hours=expires_in_hours),
+ "scopes": [],
+ }
+ return jwt.encode(payload, secret or self.secret, algorithm="HS256")
+
+ def test_valid_oauth2_jwt_accepted(self):
+ """A valid OAuth2 JWT is accepted even with no static tokens and
+ dci.api_tokens_required=true."""
+ self.ICP.set_param("dci.api_tokens", "")
+ self.ICP.set_param("dci.api_tokens_required", "true")
+ token = self._mint_jwt()
+ self.assertEqual(self._call(f"Bearer {token}"), token)
+
+ def test_oauth2_jwt_accepted_alongside_nonmatching_static(self):
+ """A valid OAuth2 JWT is accepted even when a (non-matching) static
+ token list is configured."""
+ self.ICP.set_param("dci.api_tokens", "some-other-static-token")
+ token = self._mint_jwt()
+ self.assertEqual(self._call(f"Bearer {token}"), token)
+
+ def test_static_token_still_accepted(self):
+ """Regression: configured static tokens keep working."""
+ self.ICP.set_param("dci.api_tokens", "static-abc")
+ self.assertEqual(self._call("Bearer static-abc"), "static-abc")
+
+ def test_expired_oauth2_jwt_rejected(self):
+ self.ICP.set_param("dci.api_tokens", "")
+ token = self._mint_jwt(expires_in_hours=-1)
+ with self.assertRaises(HTTPException) as ctx:
+ self._call(f"Bearer {token}")
+ self.assertEqual(ctx.exception.status_code, 401)
+
+ def test_invalid_signature_jwt_rejected(self):
+ self.ICP.set_param("dci.api_tokens", "")
+ token = self._mint_jwt(secret="wrong-but-long-enough-secret-aB3dE6fH9jK2mN5pQ8rT1v")
+ with self.assertRaises(HTTPException) as ctx:
+ self._call(f"Bearer {token}")
+ self.assertEqual(ctx.exception.status_code, 401)
+
+ def test_oauth2_jwt_for_inactive_client_rejected(self):
+ self.ICP.set_param("dci.api_tokens", "")
+ self.client.active = False
+ token = self._mint_jwt()
+ with self.assertRaises(HTTPException) as ctx:
+ self._call(f"Bearer {token}")
+ self.assertEqual(ctx.exception.status_code, 401)
+
+ def test_oauth2_jwt_unknown_client_rejected(self):
+ self.ICP.set_param("dci.api_tokens", "")
+ token = self._mint_jwt(client_id="no-such-client-id")
+ with self.assertRaises(HTTPException) as ctx:
+ self._call(f"Bearer {token}")
+ self.assertEqual(ctx.exception.status_code, 401)
diff --git a/spp_dci_server/tests/test_fastapi_endpoint_dci.py b/spp_dci_server/tests/test_fastapi_endpoint_dci.py
index 9047b1b9d..e03590600 100644
--- a/spp_dci_server/tests/test_fastapi_endpoint_dci.py
+++ b/spp_dci_server/tests/test_fastapi_endpoint_dci.py
@@ -135,6 +135,27 @@ def test_dci_api_app_choice_is_registered(self):
keys = [k for k, _ in field.selection]
self.assertIn("dci_api", keys)
+ def test_registry_routes_mounted_at_spec_path(self):
+ """SPDCI defines /registry/sync/search relative to the deployment
+ base URL - the registry type travels in the message reg_type, not in
+ the URL. The non-spec /social/registry mount stays for backward
+ compatibility, but the spec path must exist."""
+ endpoint = self.Endpoint.create(
+ {
+ "name": "test-dci-spec-paths",
+ "app": "dci_api",
+ "root_path": "/test-dci-spec",
+ }
+ )
+ all_paths = []
+ for router in endpoint._get_fastapi_routers():
+ if isinstance(router, APIRouter):
+ for route in router.routes:
+ all_paths.append(getattr(route, "path", ""))
+ self.assertIn("/registry/sync/search", all_paths, f"spec path missing: {all_paths}")
+ # Backward-compatible long form is still served
+ self.assertIn("/social/registry/sync/search", all_paths)
+
def test_get_fastapi_routers_returns_dci_routers(self):
"""A DCI endpoint must include the JWKS and registry-aliases routers."""
endpoint = self.Endpoint.create(
diff --git a/spp_dci_server/tests/test_ping_router.py b/spp_dci_server/tests/test_ping_router.py
new file mode 100644
index 000000000..662ffb0c0
--- /dev/null
+++ b/spp_dci_server/tests/test_ping_router.py
@@ -0,0 +1,40 @@
+# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
+"""Tests for the authenticated DCI ping endpoint."""
+
+import asyncio
+
+from odoo.tests import tagged
+
+from odoo.addons.spp_dci_server.middleware.signature import verify_bearer_token
+from odoo.addons.spp_dci_server.routers.ping import dci_ping_router, ping
+
+from .common import DCIServerCommon
+
+
+def _run(coro):
+ loop = asyncio.new_event_loop()
+ try:
+ return loop.run_until_complete(coro)
+ finally:
+ loop.close()
+
+
+@tagged("post_install", "-at_install")
+class TestPingRouter(DCIServerCommon):
+ def test_ping_returns_ok_with_sender_id(self):
+ """A reachable, authenticated ping returns the server's sender id."""
+ self.env["ir.config_parameter"].sudo().set_param("dci.sender_id", "openspp.test.server")
+ result = _run(ping(self.env, "a-valid-token"))
+ self.assertEqual(result["status"], "ok")
+ self.assertEqual(result["sender_id"], "openspp.test.server")
+
+ def test_ping_requires_bearer_token(self):
+ """The route must be gated by verify_bearer_token so a bad/missing
+ token yields 401 rather than a misleading success."""
+ route = next(r for r in dci_ping_router.routes if getattr(r, "path", None) == "/ping")
+ dependency_calls = [dep.call for dep in route.dependant.dependencies]
+ self.assertIn(
+ verify_bearer_token,
+ dependency_calls,
+ "ping endpoint must depend on verify_bearer_token",
+ )
diff --git a/spp_dci_server/tests/test_res_config_settings.py b/spp_dci_server/tests/test_res_config_settings.py
new file mode 100644
index 000000000..368256c4d
--- /dev/null
+++ b/spp_dci_server/tests/test_res_config_settings.py
@@ -0,0 +1,55 @@
+# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
+
+from odoo.tests import TransactionCase, tagged
+
+from odoo.addons.spp_dci_server.middleware.signature import _read_security_flag
+
+
+@tagged("post_install", "-at_install")
+class TestDCIServerConfigSettings(TransactionCase):
+ """The DCI Server settings page must round-trip to ir.config_parameter
+ and feed the values the auth middleware reads."""
+
+ def setUp(self):
+ super().setUp()
+ self.Settings = self.env["res.config.settings"]
+ self.Param = self.env["ir.config_parameter"].sudo()
+
+ def _save(self, values):
+ settings = self.Settings.create(values)
+ settings.execute()
+ return settings
+
+ def test_api_tokens_round_trip(self):
+ """Bearer tokens entered in settings persist to dci.api_tokens."""
+ self._save({"dci_api_tokens": "alpha,beta"})
+ self.assertEqual(self.Param.get_param("dci.api_tokens"), "alpha,beta")
+
+ # And reading settings back reflects the stored value.
+ reloaded = self.Settings.create({})
+ self.assertEqual(reloaded.dci_api_tokens, "alpha,beta")
+
+ def test_sender_id_round_trip(self):
+ self._save({"dci_sender_id": "openspp.test.server"})
+ self.assertEqual(self.Param.get_param("dci.sender_id"), "openspp.test.server")
+
+ def test_security_flags_feed_middleware(self):
+ """Toggling the dev flags is visible to the middleware's flag reader."""
+ self._save(
+ {
+ "dci_allow_unsigned_requests": True,
+ "dci_api_tokens_required": False,
+ }
+ )
+ self.assertEqual(_read_security_flag(self.env, "dci.allow_unsigned_requests"), "true")
+ self.assertEqual(_read_security_flag(self.env, "dci.api_tokens_required"), "false")
+
+ # Flipping them back is honoured too.
+ self._save(
+ {
+ "dci_allow_unsigned_requests": False,
+ "dci_api_tokens_required": True,
+ }
+ )
+ self.assertEqual(_read_security_flag(self.env, "dci.allow_unsigned_requests"), "false")
+ self.assertEqual(_read_security_flag(self.env, "dci.api_tokens_required"), "true")
diff --git a/spp_dci_server/tests/test_search_router.py b/spp_dci_server/tests/test_search_router.py
index 60a0f4cee..c4ba7e523 100644
--- a/spp_dci_server/tests/test_search_router.py
+++ b/spp_dci_server/tests/test_search_router.py
@@ -156,6 +156,62 @@ def test_invalid_search_request_returns_400(self):
)
self.assertEqual(ctx.exception.status_code, 400)
+ # --- consent wiring -------------------------------------------------------
+
+ def test_sync_search_passes_verified_sender_to_service(self):
+ """The sync path must resolve the verified sender registry entry and
+ hand it to the search service - otherwise the consent adapter sees no
+ sender and silently disengages consent filtering."""
+ envelope = self._build_envelope()
+ response = self._build_response(statuses=("succ",))
+ with patch("odoo.addons.spp_dci_server_social.services.search_service.DCISocialSearchService") as mock_cls:
+ mock_cls.return_value.execute_search.return_value = response
+ _run(
+ self.search_registry(
+ envelope,
+ self.env,
+ _bearer_token="t",
+ verified_sender_id=self.test_sender.sender_id,
+ _rate_limit_check=None,
+ )
+ )
+ args, kwargs = mock_cls.call_args
+ passed_sender = kwargs.get("sender_registry")
+ if passed_sender is None and len(args) > 1:
+ passed_sender = args[1]
+ self.assertEqual(
+ passed_sender,
+ self.test_sender,
+ "verified sender was not passed to the search service (consent bypass)",
+ )
+
+ def test_sync_search_sender_lookup_survives_low_privilege_endpoint_user(self):
+ """The endpoint commonly runs as a low-privilege user (e.g. public)
+ with no read access to the sender registry - the verified-sender
+ lookup must not raise AccessError (live smoke test regression)."""
+ low_priv = self.env["res.users"].create(
+ {
+ "name": "DCI Endpoint Smoke",
+ "login": "dci_endpoint_smoke",
+ "group_ids": [(6, 0, [self.env.ref("base.group_public").id])],
+ }
+ )
+ env_low = self.env(user=low_priv)
+ envelope = self._build_envelope()
+ response = self._build_response(statuses=("succ",))
+ with patch("odoo.addons.spp_dci_server_social.services.search_service.DCISocialSearchService") as mock_cls:
+ mock_cls.return_value.execute_search.return_value = response
+ result = _run(
+ self.search_registry(
+ envelope,
+ env_low,
+ _bearer_token="t",
+ verified_sender_id=self.test_sender.sender_id,
+ _rate_limit_check=None,
+ )
+ )
+ self.assertEqual(result.header.status, "succ")
+
# --- service errors -------------------------------------------------------
def test_search_service_exception_rejects_all_items(self):
diff --git a/spp_dci_server/views/res_config_settings_views.xml b/spp_dci_server/views/res_config_settings_views.xml
new file mode 100644
index 000000000..59eda5c3c
--- /dev/null
+++ b/spp_dci_server/views/res_config_settings_views.xml
@@ -0,0 +1,91 @@
+
+
+
+
+ res.config.settings.view.form.inherit.dci.server
+
+ res.config.settings
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ These options weaken or
+ disable DCI security checks. Enable them only in
+ development — never in production.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Settings
+ ir.actions.act_window
+ res.config.settings
+
+ form
+ current
+ {'module': 'spp_dci_server'}
+
+
+
+
diff --git a/spp_dci_server_social/README.rst b/spp_dci_server_social/README.rst
index b969e8a83..158b31f42 100644
--- a/spp_dci_server_social/README.rst
+++ b/spp_dci_server_social/README.rst
@@ -98,6 +98,26 @@ After installing:
3. Ensure the DCI queue_job cron is active under **Settings > Technical
> Scheduled Actions**
+Deployment Prerequisites (read before exposing the server)
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+- **Endpoint user groups**: the Odoo user configured on the DCI
+ ``fastapi.endpoint`` must belong to
+ ``spp_registry.group_registry_viewer`` - every search fails with an
+ access error otherwise.
+- **queue_job worker**: event notifications are delivered through
+ delayed jobs on channels ``root.dci`` and ``dci``. A running queue_job
+ worker with those channels configured is a hard requirement - without
+ it, notifications are enqueued and silently never sent.
+- **Inbound auth**: searches go through ``spp_dci_server``'s
+ authenticated route (bearer token per ``dci.api_tokens`` plus DCI
+ envelope signature). This module deliberately ships no unauthenticated
+ routes.
+- **Client base URL**: DCI clients post to
+ ``{base_url}/registry/sync/search``; point their data source base URL
+ at ``.../api/v1/social`` so requests land on this server's
+ ``/social/registry/sync/search`` mount.
+
UI Location
~~~~~~~~~~~
@@ -107,11 +127,18 @@ Notifications triggered automatically on registrant changes.
Security
~~~~~~~~
-No new access rules. Search requires
+No new access rules. Search requires the
``spp_registry.group_registry_viewer`` group (enforced in
``DCISocialSearchService._process_search_item()``). Inherits access
control from ``spp_registry`` and ``spp_dci_server``.
+Because the search runs under the DCI FastAPI endpoint's user, that user
+must hold ``spp_registry.group_registry_viewer``. The endpoint ships as
+the public user, which lacks it, so Social Registry searches are
+rejected until a registry-viewer service user is assigned to the
+endpoint — see **Endpoint user (required for Social Registry search)**
+in the ``spp_dci_server`` documentation.
+
Extension Points
~~~~~~~~~~~~~~~~
diff --git a/spp_dci_server_social/__init__.py b/spp_dci_server_social/__init__.py
index d2f529606..896e80446 100644
--- a/spp_dci_server_social/__init__.py
+++ b/spp_dci_server_social/__init__.py
@@ -1,5 +1,4 @@
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
from . import models
-from . import routers
from . import services
diff --git a/spp_dci_server_social/__manifest__.py b/spp_dci_server_social/__manifest__.py
index 62c60c036..387414e5c 100644
--- a/spp_dci_server_social/__manifest__.py
+++ b/spp_dci_server_social/__manifest__.py
@@ -2,13 +2,13 @@
{
"name": "OpenSPP DCI Server - Social Registry",
"summary": "Expose Social Registry beneficiaries via DCI API",
- "version": "19.0.1.0.0",
+ "version": "19.0.1.0.1",
"category": "OpenSPP/Integration",
"author": "OpenSPP.org",
"website": "https://github.com/OpenSPP/OpenSPP2",
"license": "LGPL-3",
"development_status": "Alpha",
- "depends": ["spp_dci_server", "spp_registry", "spp_cel_domain"],
+ "depends": ["spp_dci_server", "spp_registry", "spp_cel_domain", "spp_programs"],
"data": [
"security/ir.model.access.csv",
],
diff --git a/spp_dci_server_social/models/res_partner_dci_notify.py b/spp_dci_server_social/models/res_partner_dci_notify.py
index 455a42e8f..702732912 100644
--- a/spp_dci_server_social/models/res_partner_dci_notify.py
+++ b/spp_dci_server_social/models/res_partner_dci_notify.py
@@ -80,18 +80,42 @@ def write(self, vals):
def unlink(self):
"""Override unlink to trigger DCI delete notifications."""
- # Capture registrant IDs before deletion
- registrant_ids = self.filtered(lambda r: r.is_registrant).ids
+ # Snapshot identifiers before deletion - the records are gone by the
+ # time the notification job runs, and subscribers must receive
+ # external identifiers, never raw database ids.
+ registrants = self.filtered(lambda r: r.is_registrant)
+ registrant_ids = registrants.ids
+ delete_payloads = registrants._dci_delete_payloads()
result = super().unlink()
- # Queue delete notification (IDs only since records are gone)
if registrant_ids:
- self._schedule_dci_notification("delete", registrant_ids)
+ self._schedule_dci_notification("delete", registrant_ids, payloads=delete_payloads)
return result
- def _schedule_dci_notification(self, event_type, partner_ids):
+ def _dci_delete_payloads(self):
+ """Snapshot external identifiers for delete notifications.
+
+ Returns one payload dict per registrant in ``self`` containing the
+ registrant's external identifiers (namespace URI preferred, falling
+ back to the vocabulary code). Raw database ids are deliberately not
+ included (api-design principle: never expose DB IDs).
+ """
+ payloads = []
+ for partner in self:
+ identifiers = [
+ {
+ "identifier_type": reg_id.id_type_id.namespace_uri or reg_id.id_type_id.code,
+ "identifier_value": reg_id.value,
+ }
+ for reg_id in partner.reg_ids
+ if reg_id.value and reg_id.id_type_id
+ ]
+ payloads.append({"identifiers": identifiers})
+ return payloads
+
+ def _schedule_dci_notification(self, event_type, partner_ids, payloads=None):
"""Schedule DCI notification via post-commit hook.
Uses post-commit to ensure notification only fires after
@@ -112,7 +136,7 @@ def _schedule_dci_notification(self, event_type, partner_ids):
# Use post-commit hook to defer notification until transaction commits
# This ensures we don't notify about rolled-back changes
def notify_on_commit():
- self._queue_dci_notification_job(event_type, partner_ids)
+ self._queue_dci_notification_job(event_type, partner_ids, payloads=payloads)
# Register post-commit callback
self.env.cr.postcommit.add(notify_on_commit)
@@ -134,7 +158,7 @@ def _dci_notifications_enabled(self):
enabled = config.get_param("dci.notifications_enabled", "true").lower() == "true"
return enabled
- def _queue_dci_notification_job(self, event_type, partner_ids):
+ def _queue_dci_notification_job(self, event_type, partner_ids, payloads=None):
"""Queue the actual notification job with deduplication.
Uses queue_job with identity_key to deduplicate multiple
@@ -157,7 +181,7 @@ def _queue_dci_notification_job(self, event_type, partner_ids):
channel="root.dci",
description=f"DCI {event_type} notification ({len(partner_ids)} records)",
identity_key=identity_key,
- )._execute_dci_notification(event_type, partner_ids)
+ )._execute_dci_notification(event_type, partner_ids, payloads=payloads)
_logger.debug(
"Queued DCI %s notification job for partner IDs: %s",
@@ -186,7 +210,7 @@ def _get_notification_identity_key(self, event_type, partner_ids):
# Use hash to keep key length manageable
return hashlib.sha256(key_data.encode()).hexdigest()[:32]
- def _execute_dci_notification(self, event_type, partner_ids):
+ def _execute_dci_notification(self, event_type, partner_ids, payloads=None):
"""Execute the DCI notification (called by queue_job).
Builds notification payload and calls subscription.notify_event().
@@ -194,6 +218,8 @@ def _execute_dci_notification(self, event_type, partner_ids):
Args:
event_type: One of 'registration', 'update', 'delete'
partner_ids: List of partner IDs to notify about
+ payloads: For delete events, identifier payloads snapshotted
+ before the records were removed
"""
if not partner_ids:
return
@@ -213,10 +239,12 @@ def _execute_dci_notification(self, event_type, partner_ids):
return
Subscription = self.env["spp.dci.subscription"]
- # For delete events, we only have IDs (records are gone)
+ # For delete events the records are gone - use the identifier
+ # payloads snapshotted in unlink(). Jobs queued before this field
+ # existed carry no payloads; emit empty identifier lists rather
+ # than leaking raw database ids.
if event_type == "delete":
- # Build minimal records with just identifiers
- records = [{"id": pid} for pid in partner_ids]
+ records = payloads if payloads is not None else [{"identifiers": []} for _ in partner_ids]
Subscription.notify_event(event_type, records, "SOCIAL_REGISTRY")
return
diff --git a/spp_dci_server_social/readme/DESCRIPTION.md b/spp_dci_server_social/readme/DESCRIPTION.md
index 9d96e21fc..ded0baae5 100644
--- a/spp_dci_server_social/readme/DESCRIPTION.md
+++ b/spp_dci_server_social/readme/DESCRIPTION.md
@@ -42,13 +42,31 @@ After installing:
2. Configure sender registries in **Social Protection > DCI > Sender Registries**
3. Ensure the DCI queue_job cron is active under **Settings > Technical > Scheduled Actions**
+### Deployment Prerequisites (read before exposing the server)
+
+- **Endpoint user groups**: the Odoo user configured on the DCI
+ `fastapi.endpoint` must belong to `spp_registry.group_registry_viewer` -
+ every search fails with an access error otherwise.
+- **queue_job worker**: event notifications are delivered through delayed
+ jobs on channels `root.dci` and `dci`. A running queue_job worker with
+ those channels configured is a hard requirement - without it,
+ notifications are enqueued and silently never sent.
+- **Inbound auth**: searches go through `spp_dci_server`'s authenticated
+ route (bearer token per `dci.api_tokens` plus DCI envelope signature).
+ This module deliberately ships no unauthenticated routes.
+- **Client base URL**: DCI clients post to `{base_url}/registry/sync/search`;
+ point their data source base URL at `.../api/v1/social` so requests land
+ on this server's `/social/registry/sync/search` mount.
+
### UI Location
No standalone UI. Search functionality accessed via DCI API endpoints. Notifications triggered automatically on registrant changes.
### Security
-No new access rules. Search requires `spp_registry.group_registry_viewer` group (enforced in `DCISocialSearchService._process_search_item()`). Inherits access control from `spp_registry` and `spp_dci_server`.
+No new access rules. Search requires the `spp_registry.group_registry_viewer` group (enforced in `DCISocialSearchService._process_search_item()`). Inherits access control from `spp_registry` and `spp_dci_server`.
+
+Because the search runs under the DCI FastAPI endpoint's user, that user must hold `spp_registry.group_registry_viewer`. The endpoint ships as the public user, which lacks it, so Social Registry searches are rejected until a registry-viewer service user is assigned to the endpoint — see **Endpoint user (required for Social Registry search)** in the `spp_dci_server` documentation.
### Extension Points
diff --git a/spp_dci_server_social/routers/__init__.py b/spp_dci_server_social/routers/__init__.py
deleted file mode 100644
index 9f69202d4..000000000
--- a/spp_dci_server_social/routers/__init__.py
+++ /dev/null
@@ -1,4 +0,0 @@
-# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
-
-from . import social_search
-from . import sr_alias
diff --git a/spp_dci_server_social/routers/social_search.py b/spp_dci_server_social/routers/social_search.py
deleted file mode 100644
index 0ac9c0eee..000000000
--- a/spp_dci_server_social/routers/social_search.py
+++ /dev/null
@@ -1,85 +0,0 @@
-# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
-"""DCI Social Registry search endpoints"""
-
-import logging
-from datetime import datetime
-from typing import Annotated
-
-from odoo.api import Environment
-
-from odoo.addons.fastapi.dependencies import odoo_env
-from odoo.addons.spp_dci.schemas.search import SearchRequest, SearchResponse
-
-from fastapi import APIRouter, Depends, HTTPException, status
-
-_logger = logging.getLogger(__name__)
-
-social_search_router = APIRouter(tags=["DCI Social Registry"], prefix="/registry/social")
-
-
-@social_search_router.post(
- "/sync/search",
- response_model=SearchResponse,
- response_model_exclude_none=True,
-)
-async def sync_search(
- request: SearchRequest,
- env: Annotated[Environment, Depends(odoo_env)],
-):
- """
- Synchronous search for Social Registry persons/groups.
-
- Implements DCI sync search pattern - returns immediate results.
-
- Request body contains:
- - transaction_id: Unique transaction identifier
- - search_request: List of search request items with criteria
-
- Each search criteria supports:
- - query_type: "idtype-value" or "expression"
- - reg_type: Should be "SOCIAL_REGISTRY"
- - pagination: page_size, page_number
- - sort: Optional sort specifications
-
- Returns SearchResponse with matching Person/Group records.
- """
- from ..services.search_service import DCISocialSearchService
-
- service = DCISocialSearchService(env)
-
- try:
- response = service.execute_search(request)
- return response
- except Exception as e:
- _logger.exception("Error executing DCI social registry search: %s", str(e))
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail=f"Search failed: {str(e)}",
- ) from e
-
-
-@social_search_router.post(
- "/sync/notify",
- status_code=status.HTTP_501_NOT_IMPLEMENTED,
-)
-async def sync_notify(
- env: Annotated[Environment, Depends(odoo_env)],
-):
- """
- Receive notification from external DCI client.
-
- This endpoint is not yet implemented. In a full implementation, this would:
- 1. Validate the notification signature
- 2. Parse the notification payload
- 3. Process the event (create/update/delete records)
- 4. Return acknowledgment
-
- Returns 501 Not Implemented with DCI rejection response.
- """
- _logger.info("DCI Social Registry notification endpoint called but not implemented")
- return {
- "status": "rjct",
- "status_reason_code": "rjct.search_criteria.invalid",
- "status_reason_message": "Notification endpoint is not yet implemented",
- "timestamp": datetime.utcnow().isoformat(),
- }
diff --git a/spp_dci_server_social/routers/sr_alias.py b/spp_dci_server_social/routers/sr_alias.py
deleted file mode 100644
index bbcfed77c..000000000
--- a/spp_dci_server_social/routers/sr_alias.py
+++ /dev/null
@@ -1,53 +0,0 @@
-# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
-"""SPDCI-compliant short-form Social Registry endpoint aliases (/sr/*).
-
-This module provides thin redirects from SPDCI short-form paths to our
-main implementation paths. No business logic duplication.
-
-Endpoint redirects:
-- /sr/sync/search -> uses social_search.sync_search
-- /sr/search -> uses async_router.async_search
-- /sr/subscribe -> uses async_router.subscribe
-- /sr/unsubscribe -> uses async_router.unsubscribe
-- /sr/txn/status -> uses async_router.txn_status
-- /sr/sync/txn/status -> uses async_router.txn_status (sync mode)
-"""
-
-import logging
-from typing import Annotated
-
-from odoo.api import Environment
-
-from odoo.addons.fastapi.dependencies import odoo_env
-from odoo.addons.spp_dci.schemas import (
- SearchRequest,
- SearchResponse,
-)
-
-from fastapi import APIRouter, Depends
-
-_logger = logging.getLogger(__name__)
-
-# Social Registry short-form router with /sr prefix (SPDCI compliance)
-sr_alias_router = APIRouter(tags=["SPDCI Social Registry Aliases"], prefix="/sr")
-
-
-@sr_alias_router.post(
- "/sync/search",
- response_model=SearchResponse,
- response_model_exclude_none=True,
-)
-async def sr_sync_search(
- request: SearchRequest,
- env: Annotated[Environment, Depends(odoo_env)],
-):
- """Alias for /registry/social/sync/search."""
- from .social_search import sync_search
-
- return await sync_search(request, env)
-
-
-# Note: For async endpoints (/sr/search, /sr/subscribe, etc.), these are
-# handled by the base spp_dci_server async_router which already provides
-# the correct paths. If you need /sr/* aliases for those, add them here
-# by importing and calling the corresponding functions from async_router.
diff --git a/spp_dci_server_social/services/search_service.py b/spp_dci_server_social/services/search_service.py
index ef76fc905..456e63e2d 100644
--- a/spp_dci_server_social/services/search_service.py
+++ b/spp_dci_server_social/services/search_service.py
@@ -13,7 +13,7 @@
from odoo.addons.spp_dci.schemas.common import Address, Identifier, Name
from odoo.addons.spp_dci.schemas.constants import SearchStatusReasonCode
from odoo.addons.spp_dci.schemas.group import Group, Member
-from odoo.addons.spp_dci.schemas.person import Person
+from odoo.addons.spp_dci.schemas.person import HouseholdInfo, Person, ProgramEnrollment
from odoo.addons.spp_dci.schemas.search import (
Pagination,
SearchRequest,
@@ -26,6 +26,14 @@
_logger = logging.getLogger(__name__)
+# Accepted spellings of the Social Registry type (compared lowercase): the
+# SPDCI namespaced value sent by DCI clients, plus legacy bare forms.
+_ACCEPTED_SOCIAL_REG_TYPES = {
+ "ns:org:registrytype:social",
+ "social_registry",
+ "social",
+}
+
class DCISocialSearchService:
"""Service for DCI Social Registry search operations.
@@ -138,11 +146,11 @@ def _process_search_item(self, search_req: SearchRequestItem) -> SearchResponseI
"""
criteria = search_req.search_criteria
- # Default reg_type to SOCIAL_REGISTRY if not provided (optional per SPDCI spec)
- reg_type = criteria.reg_type or "SOCIAL_REGISTRY"
-
- # Validate registry type
- if reg_type != "SOCIAL_REGISTRY":
+ # reg_type is optional per the SPDCI spec (defaults to social);
+ # accept both the namespaced spelling DCI clients send
+ # (ns:org:RegistryType:Social) and the legacy bare form.
+ reg_type = criteria.reg_type
+ if reg_type and str(reg_type).strip().lower() not in _ACCEPTED_SOCIAL_REG_TYPES:
return SearchResponseItem(
reference_id=search_req.reference_id,
timestamp=datetime.now(UTC),
@@ -289,11 +297,17 @@ def _build_domain(self, criteria) -> list:
id_type = query.type
id_value = query.value
- # Match by identifier type and value
- # Note: Assuming id_type is a namespace URI
+ if not id_type or not id_value:
+ raise ValueError("idtype-value query requires both 'type' and 'value'")
+
+ # Match the identifier type by namespace URI or by the short
+ # vocabulary code - DCI clients resolve identifiers from their
+ # local registrant IDs as short codes (UIN, NATIONAL_ID, ...).
domain.extend(
[
+ "|",
("reg_ids.id_type_id.namespace_uri", "=", id_type),
+ ("reg_ids.id_type_id.code", "=", id_type),
("reg_ids.value", "=", id_value),
]
)
@@ -583,6 +597,19 @@ def _to_dci_person(self, partner) -> Person:
if partner.email:
emails.append(partner.email)
+ # Active programme enrollments (SPDCI social profile). Paused
+ # memberships are still enrollments; draft/exited/not_eligible/
+ # duplicated are not.
+ enrollments = [
+ ProgramEnrollment(
+ programme_name=membership.program_id.name,
+ enrolment_status=membership.state,
+ enrolment_date=membership.enrollment_date.date() if membership.enrollment_date else None,
+ )
+ for membership in partner.program_membership_ids
+ if membership.state in ("enrolled", "paused") and membership.program_id
+ ]
+
return Person(
identifier=identifiers,
name=name,
@@ -594,6 +621,29 @@ def _to_dci_person(self, partner) -> Person:
email=emails if emails else None,
registration_date=partner.create_date if partner.create_date else None,
last_updated=partner.write_date if partner.write_date else None,
+ enrolled_programs=enrollments if enrollments else None,
+ household_info=self._build_household_info(partner),
+ )
+
+ def _build_household_info(self, partner) -> HouseholdInfo | None:
+ """Summarize the person's household (OpenSPP extension).
+
+ Uses the person's first active group membership. Returns None when
+ the person belongs to no active group, so the field is omitted from
+ the wire record entirely.
+ """
+ active = partner.individual_membership_ids.filtered(lambda m: not m.is_ended)
+ if not active:
+ return None
+ membership = active[0]
+ group_members = membership.group.group_membership_ids.filtered(lambda m: not m.is_ended)
+
+ head_code = self.env["spp.vocabulary.code"].get_code("urn:openspp:vocab:group-membership-type", "head")
+ is_head = bool(head_code) and head_code.id in membership.membership_type_ids.ids
+
+ return HouseholdInfo(
+ household_size=len(group_members) or None,
+ is_household_head=is_head,
)
def _to_dci_group(self, partner) -> Group:
diff --git a/spp_dci_server_social/static/description/index.html b/spp_dci_server_social/static/description/index.html
index 4ffc96675..b95d0a517 100644
--- a/spp_dci_server_social/static/description/index.html
+++ b/spp_dci_server_social/static/description/index.html
@@ -456,6 +456,27 @@
Configuration
> Scheduled Actions
+
+
Deployment Prerequisites (read before exposing the server)
+
+- Endpoint user groups: the Odoo user configured on the DCI
+fastapi.endpoint must belong to
+spp_registry.group_registry_viewer - every search fails with an
+access error otherwise.
+- queue_job worker: event notifications are delivered through
+delayed jobs on channels root.dci and dci. A running queue_job
+worker with those channels configured is a hard requirement - without
+it, notifications are enqueued and silently never sent.
+- Inbound auth: searches go through spp_dci_server’s
+authenticated route (bearer token per dci.api_tokens plus DCI
+envelope signature). This module deliberately ships no unauthenticated
+routes.
+- Client base URL: DCI clients post to
+{base_url}/registry/sync/search; point their data source base URL
+at .../api/v1/social so requests land on this server’s
+/social/registry/sync/search mount.
+
+
UI Location
No standalone UI. Search functionality accessed via DCI API endpoints.
@@ -463,10 +484,16 @@
UI Location
Security
-
No new access rules. Search requires
+
No new access rules. Search requires the
spp_registry.group_registry_viewer group (enforced in
DCISocialSearchService._process_search_item()). Inherits access
control from spp_registry and spp_dci_server.
+
Because the search runs under the DCI FastAPI endpoint’s user, that user
+must hold spp_registry.group_registry_viewer. The endpoint ships as
+the public user, which lacks it, so Social Registry searches are
+rejected until a registry-viewer service user is assigned to the
+endpoint — see Endpoint user (required for Social Registry search)
+in the spp_dci_server documentation.
Extension Points
diff --git a/spp_dci_server_social/tests/__init__.py b/spp_dci_server_social/tests/__init__.py
index ce9eaa984..28df45b14 100644
--- a/spp_dci_server_social/tests/__init__.py
+++ b/spp_dci_server_social/tests/__init__.py
@@ -3,5 +3,4 @@
from . import common
from . import test_search_service
from . import test_dci_notifications
-from . import test_social_routers
from . import test_search_service_internals
diff --git a/spp_dci_server_social/tests/test_dci_notifications.py b/spp_dci_server_social/tests/test_dci_notifications.py
index f65519afa..2507a58e6 100644
--- a/spp_dci_server_social/tests/test_dci_notifications.py
+++ b/spp_dci_server_social/tests/test_dci_notifications.py
@@ -131,6 +131,50 @@ def test_unlink_triggers_delete_notification(self):
self.assertEqual(args[0], "delete")
self.assertIn(individual_id, args[1])
+ def test_delete_notification_payload_has_no_db_ids(self):
+ """Delete notifications must carry external identifiers, never raw
+ Odoo database ids (api-design principle: never expose DB IDs)."""
+ individual = self._create_test_individual(
+ {
+ "family_name": "DeleteLeak",
+ "given_name": "NoDbId",
+ },
+ )
+ self.env.cr.postcommit.clear()
+
+ Subscription = type(self.env["spp.dci.subscription"])
+ Partner = self.Partner.__class__
+ with (
+ patch.object(Subscription, "notify_event") as mock_notify,
+ patch.object(Partner, "with_delay", lambda records, **kw: records),
+ ):
+ individual.unlink()
+ self.env.cr.postcommit.run()
+
+ mock_notify.assert_called_once()
+ event_type, records, reg_type = mock_notify.call_args[0]
+ self.assertEqual(event_type, "delete")
+ for record in records:
+ self.assertNotIn("id", record, f"delete payload leaks raw DB id: {record}")
+ self.assertIn("identifiers", record)
+
+ def test_delete_payload_snapshots_identifiers(self):
+ """The identifier snapshot helper captures external identifiers from a
+ live registrant (before unlink), keyed the way subscribers expect."""
+ individual = self._create_test_individual(
+ {
+ "family_name": "DeleteSnap",
+ "given_name": "WithIds",
+ },
+ identifier_value="DEL-SNAP-001",
+ )
+ payloads = individual._dci_delete_payloads()
+ self.assertEqual(len(payloads), 1)
+ identifiers = payloads[0]["identifiers"]
+ self.assertTrue(identifiers, "no identifiers captured from reg_ids")
+ self.assertIn("DEL-SNAP-001", [i["identifier_value"] for i in identifiers])
+ self.assertNotIn("id", payloads[0])
+
def test_non_registrant_no_notification(self):
"""Test that changes to non-registrants don't trigger notifications."""
# Create a non-registrant partner
@@ -197,17 +241,18 @@ def test_execute_notification_calls_subscription(self):
self.assertEqual(args[2], "SOCIAL_REGISTRY") # reg_type
def test_execute_notification_delete_with_ids_only(self):
- """Test that delete notifications work with just IDs (no records)."""
+ """A legacy delete job queued without identifier payloads must still
+ notify - with an empty identifier list, never the raw DB id."""
with patch.object(self.env["spp.dci.subscription"].__class__, "notify_event") as mock_notify:
partner = self.Partner.browse(self.individual_1.id)
- # Simulate delete notification where records no longer exist
+ # Simulate a legacy queued job (no payloads argument serialized)
partner._execute_dci_notification("delete", [99999]) # Non-existent ID
# Verify notify_event was still called
mock_notify.assert_called_once()
args = mock_notify.call_args[0]
self.assertEqual(args[0], "delete")
- self.assertEqual(args[1], [{"id": 99999}])
+ self.assertEqual(args[1], [{"identifiers": []}])
def test_multiple_writes_same_transaction(self):
"""Test that multiple writes in same transaction are handled."""
diff --git a/spp_dci_server_social/tests/test_search_service.py b/spp_dci_server_social/tests/test_search_service.py
index a304234da..e8d8d7d6a 100644
--- a/spp_dci_server_social/tests/test_search_service.py
+++ b/spp_dci_server_social/tests/test_search_service.py
@@ -8,6 +8,7 @@
from odoo.exceptions import AccessError, ValidationError
from odoo.tests import tagged
+from odoo.addons.spp_dci.schemas.constants import SearchStatusReasonCode
from odoo.addons.spp_dci.schemas.search import (
PaginationRequest,
SearchCriteria,
@@ -48,6 +49,191 @@ def test_set_sender(self):
service.set_sender(self.test_sender)
self.assertEqual(service.sender, self.test_sender)
+ def test_search_accepts_namespaced_reg_type(self):
+ """The DCI client sends the namespaced RegistryType value from its data
+ source (ns:org:RegistryType:Social) - the service must accept it, not
+ only the bare legacy form."""
+ criteria = SearchCriteria(
+ reg_type="ns:org:RegistryType:Social",
+ query_type="idtype-value",
+ query={"type": self.test_id_type.namespace_uri, "value": "NAT-001"},
+ )
+ search_req = SearchRequestItem(
+ reference_id="test-ref-ns",
+ timestamp=datetime.now(UTC),
+ search_criteria=criteria,
+ )
+ request = SearchRequest(transaction_id="test-txn-ns", search_request=[search_req])
+ self.env.user.write({"group_ids": [(4, self.env.ref("spp_registry.group_registry_viewer").id)]})
+
+ response = self.search_service.execute_search(request)
+
+ response_item = response.search_response[0]
+ self.assertEqual(
+ response_item.status,
+ "succ",
+ f"namespaced reg_type rejected: {response_item.status_reason_message}",
+ )
+
+ def test_search_by_identifier_short_code(self):
+ """idtype-value must also match the vocabulary code, not only the
+ namespace URI - DCI clients resolve identifiers from their local
+ registrant IDs as short codes (UIN, NATIONAL_ID, ...)."""
+ criteria = SearchCriteria(
+ reg_type="SOCIAL_REGISTRY",
+ query_type="idtype-value",
+ query={"type": self.test_id_type.code, "value": "NAT-001"},
+ )
+ search_req = SearchRequestItem(
+ reference_id="test-ref-code",
+ timestamp=datetime.now(UTC),
+ search_criteria=criteria,
+ )
+ request = SearchRequest(transaction_id="test-txn-code", search_request=[search_req])
+ self.env.user.write({"group_ids": [(4, self.env.ref("spp_registry.group_registry_viewer").id)]})
+
+ response = self.search_service.execute_search(request)
+
+ response_item = response.search_response[0]
+ self.assertEqual(response_item.status, "succ")
+ self.assertEqual(
+ len(response_item.data.reg_records),
+ 1,
+ "short-code identifier type did not match any record",
+ )
+
+ def test_search_by_identifier_missing_value_rejected(self):
+ """A malformed idtype-value query (missing value) must be rejected
+ with FILTER_INVALID instead of silently matching nothing."""
+ criteria = SearchCriteria(
+ reg_type="SOCIAL_REGISTRY",
+ query_type="idtype-value",
+ query={"type": self.test_id_type.namespace_uri},
+ )
+ search_req = SearchRequestItem(
+ reference_id="test-ref-noval",
+ timestamp=datetime.now(UTC),
+ search_criteria=criteria,
+ )
+ request = SearchRequest(transaction_id="test-txn-noval", search_request=[search_req])
+ self.env.user.write({"group_ids": [(4, self.env.ref("spp_registry.group_registry_viewer").id)]})
+
+ response = self.search_service.execute_search(request)
+
+ response_item = response.search_response[0]
+ self.assertEqual(response_item.status, "rjct")
+ self.assertEqual(
+ response_item.status_reason_code,
+ SearchStatusReasonCode.FILTER_INVALID.value,
+ )
+
+ def _search_by_nat_001(self, reference_id="test-ref-prog"):
+ """Run an idtype-value search for the NAT-001 fixture individual."""
+ criteria = SearchCriteria(
+ reg_type="SOCIAL_REGISTRY",
+ query_type="idtype-value",
+ query={"type": self.test_id_type.namespace_uri, "value": "NAT-001"},
+ )
+ search_req = SearchRequestItem(
+ reference_id=reference_id,
+ timestamp=datetime.now(UTC),
+ search_criteria=criteria,
+ )
+ request = SearchRequest(transaction_id=f"txn-{reference_id}", search_request=[search_req])
+ self.env.user.write({"group_ids": [(4, self.env.ref("spp_registry.group_registry_viewer").id)]})
+ return self.search_service.execute_search(request).search_response[0]
+
+ def test_person_record_includes_enrolled_programs(self):
+ """The served person record must list active programme enrollments -
+ DCI clients derive sr.dci.program_count / has_programs from it."""
+ program = self.env["spp.program"].create({"name": "Cash Transfer Test", "target_type": "individual"})
+ self.env["spp.program.membership"].create(
+ {
+ "partner_id": self.individual_1.id,
+ "program_id": program.id,
+ "state": "enrolled",
+ }
+ )
+
+ response_item = self._search_by_nat_001()
+
+ self.assertEqual(response_item.status, "succ")
+ record = response_item.data.reg_records[0]
+ self.assertIn("enrolled_programs", record, "person record lacks enrolled_programs")
+ programs = record["enrolled_programs"]
+ self.assertEqual(len(programs), 1)
+ self.assertEqual(programs[0]["programme_name"], "Cash Transfer Test")
+ self.assertEqual(programs[0]["enrolment_status"], "enrolled")
+
+ def test_person_record_excludes_inactive_enrollments(self):
+ """Draft/exited memberships are not enrollments - they must not
+ appear in the served record."""
+ program = self.env["spp.program"].create({"name": "Exited Program Test", "target_type": "individual"})
+ self.env["spp.program.membership"].create(
+ {
+ "partner_id": self.individual_1.id,
+ "program_id": program.id,
+ "state": "exited",
+ }
+ )
+
+ response_item = self._search_by_nat_001(reference_id="test-ref-noprog")
+
+ self.assertEqual(response_item.status, "succ")
+ record = response_item.data.reg_records[0]
+ self.assertFalse(
+ record.get("enrolled_programs"),
+ f"inactive enrollment leaked into the record: {record.get('enrolled_programs')}",
+ )
+
+ def test_person_record_includes_household_info(self):
+ """The served person record must carry a household summary - DCI
+ clients derive sr.dci.household_size / is_head_of_household /
+ large_household from it (one search covers all variables)."""
+ head_code = self.env["spp.vocabulary.code"].get_code("urn:openspp:vocab:group-membership-type", "head")
+ self.assertTrue(head_code, "seeded head membership-type code missing")
+ membership = self.env["spp.group.membership"].search(
+ [("individual", "=", self.individual_1.id), ("group", "=", self.group_1.id)],
+ limit=1,
+ )
+ self.assertTrue(membership, "fixture membership missing")
+ membership.write({"membership_type_ids": [(4, head_code.id)]})
+
+ response_item = self._search_by_nat_001(reference_id="test-ref-hh")
+
+ self.assertEqual(response_item.status, "succ")
+ record = response_item.data.reg_records[0]
+ self.assertIn("household_info", record, "person record lacks household_info")
+ info = record["household_info"]
+ self.assertEqual(info["household_size"], 2)
+ self.assertTrue(info["is_household_head"])
+
+ def test_person_without_group_has_no_household_info(self):
+ """A person with no active group membership carries no household
+ summary (field omitted, not zeroed)."""
+ self._create_test_individual(
+ {"family_name": "Solo", "given_name": "NoGroup"},
+ identifier_value="NAT-SOLO-1",
+ )
+ criteria = SearchCriteria(
+ reg_type="SOCIAL_REGISTRY",
+ query_type="idtype-value",
+ query={"type": self.test_id_type.namespace_uri, "value": "NAT-SOLO-1"},
+ )
+ search_req = SearchRequestItem(
+ reference_id="test-ref-solo",
+ timestamp=datetime.now(UTC),
+ search_criteria=criteria,
+ )
+ request = SearchRequest(transaction_id="txn-solo", search_request=[search_req])
+ self.env.user.write({"group_ids": [(4, self.env.ref("spp_registry.group_registry_viewer").id)]})
+
+ response_item = self.search_service.execute_search(request).search_response[0]
+
+ self.assertEqual(response_item.status, "succ")
+ record = response_item.data.reg_records[0]
+ self.assertNotIn("household_info", record)
+
def test_search_by_identifier_success(self):
"""Test searching by identifier type and value."""
# Create search request
diff --git a/spp_dci_server_social/tests/test_social_routers.py b/spp_dci_server_social/tests/test_social_routers.py
deleted file mode 100644
index 89c4aa809..000000000
--- a/spp_dci_server_social/tests/test_social_routers.py
+++ /dev/null
@@ -1,65 +0,0 @@
-# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
-"""Tests for the social-search and SR-alias routers."""
-
-import asyncio
-from unittest.mock import MagicMock, patch
-
-from odoo.tests import tagged
-
-from fastapi import HTTPException
-
-from .common import DCISocialServerCommon
-
-
-def _run(coro):
- loop = asyncio.new_event_loop()
- try:
- return loop.run_until_complete(coro)
- finally:
- loop.close()
-
-
-SERVICE = "odoo.addons.spp_dci_server_social.services.search_service.DCISocialSearchService"
-
-
-@tagged("post_install", "-at_install")
-class TestSocialSearchRouter(DCISocialServerCommon):
- def setUp(self):
- super().setUp()
- from odoo.addons.spp_dci_server_social.routers import social_search, sr_alias
-
- self.social_search = social_search
- self.sr_alias = sr_alias
-
- def _request(self):
- # The router only forwards the request object to the service, which
- # we mock; a bare sentinel is sufficient.
- return MagicMock(name="SearchRequest")
-
- def test_sync_search_returns_service_response(self):
- sentinel = MagicMock(name="SearchResponse")
- with patch(SERVICE) as svc:
- svc.return_value.execute_search.return_value = sentinel
- result = _run(self.social_search.sync_search(self._request(), self.env))
- self.assertIs(result, sentinel)
-
- def test_sync_search_wraps_errors_as_500(self):
- with patch(SERVICE) as svc:
- svc.return_value.execute_search.side_effect = RuntimeError("kaboom")
- with self.assertRaises(HTTPException) as ctx:
- _run(self.social_search.sync_search(self._request(), self.env))
- self.assertEqual(ctx.exception.status_code, 500)
- self.assertIn("kaboom", ctx.exception.detail)
-
- def test_sync_notify_returns_not_implemented_payload(self):
- result = _run(self.social_search.sync_notify(self.env))
- self.assertEqual(result["status"], "rjct")
- self.assertIn("not yet implemented", result["status_reason_message"])
- self.assertIn("T", result["timestamp"])
-
- def test_sr_alias_delegates_to_sync_search(self):
- sentinel = MagicMock(name="SearchResponse")
- with patch(SERVICE) as svc:
- svc.return_value.execute_search.return_value = sentinel
- result = _run(self.sr_alias.sr_sync_search(self._request(), self.env))
- self.assertIs(result, sentinel)