From 4a6b5849006b0a948f362808f3e9c6e8aef571c4 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Wed, 13 May 2026 15:33:50 +0800 Subject: [PATCH 01/62] feat(spp_cel_dci_bridge): scaffold registry-agnostic CEL <-> DCI bridge Empty module skeleton for the CEL <-> DCI external-fetch bridge described in ADR-023. Installs cleanly; subsequent commits add schema extensions, dispatcher, registry-type handlers, cache-manager override, and tests. --- spp_cel_dci_bridge/__init__.py | 1 + spp_cel_dci_bridge/__manifest__.py | 23 +++++++++++++++++++ spp_cel_dci_bridge/models/__init__.py | 0 .../security/ir.model.access.csv | 1 + spp_cel_dci_bridge/tests/__init__.py | 0 5 files changed, 25 insertions(+) create mode 100644 spp_cel_dci_bridge/__init__.py create mode 100644 spp_cel_dci_bridge/__manifest__.py create mode 100644 spp_cel_dci_bridge/models/__init__.py create mode 100644 spp_cel_dci_bridge/security/ir.model.access.csv create mode 100644 spp_cel_dci_bridge/tests/__init__.py diff --git a/spp_cel_dci_bridge/__init__.py b/spp_cel_dci_bridge/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/spp_cel_dci_bridge/__init__.py @@ -0,0 +1 @@ +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..aee9bf6a --- /dev/null +++ b/spp_cel_dci_bridge/__manifest__.py @@ -0,0 +1,23 @@ +# 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", + ], + "external_dependencies": {"python": []}, + "data": [ + "security/ir.model.access.csv", + ], + "installable": True, + "application": False, + "auto_install": False, +} diff --git a/spp_cel_dci_bridge/models/__init__.py b/spp_cel_dci_bridge/models/__init__.py new file mode 100644 index 00000000..e69de29b 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..97dd8b91 --- /dev/null +++ b/spp_cel_dci_bridge/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_cel_dci_bridge/tests/__init__.py b/spp_cel_dci_bridge/tests/__init__.py new file mode 100644 index 00000000..e69de29b From b959025ae2894e9de5330276e16757ed4c364704 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Wed, 13 May 2026 15:59:48 +0800 Subject: [PATCH 02/62] feat(spp_cel_dci_bridge): extend data.provider and cel.variable for DCI Add dci_data_source_id + is_dci_backed on spp.data.provider, and dci_attribute_path + external_failure_policy on spp.cel.variable. Constraint ensures DCI-backed externals declare an attribute path. Inherit form/list views to expose the new fields. Validates ADR-023 schema additions (additive only, no removals). --- spp_cel_dci_bridge/__manifest__.py | 3 + spp_cel_dci_bridge/models/__init__.py | 2 + spp_cel_dci_bridge/models/cel_variable.py | 52 +++++++ spp_cel_dci_bridge/models/data_provider.py | 27 ++++ spp_cel_dci_bridge/tests/__init__.py | 1 + .../tests/test_schema_extensions.py | 144 ++++++++++++++++++ .../views/cel_variable_views.xml | 25 +++ .../views/data_provider_views.xml | 27 ++++ 8 files changed, 281 insertions(+) create mode 100644 spp_cel_dci_bridge/models/cel_variable.py create mode 100644 spp_cel_dci_bridge/models/data_provider.py create mode 100644 spp_cel_dci_bridge/tests/test_schema_extensions.py create mode 100644 spp_cel_dci_bridge/views/cel_variable_views.xml create mode 100644 spp_cel_dci_bridge/views/data_provider_views.xml diff --git a/spp_cel_dci_bridge/__manifest__.py b/spp_cel_dci_bridge/__manifest__.py index aee9bf6a..3b9b0820 100644 --- a/spp_cel_dci_bridge/__manifest__.py +++ b/spp_cel_dci_bridge/__manifest__.py @@ -12,10 +12,13 @@ "depends": [ "spp_cel_domain", "spp_dci_client", + "spp_studio", ], "external_dependencies": {"python": []}, "data": [ "security/ir.model.access.csv", + "views/data_provider_views.xml", + "views/cel_variable_views.xml", ], "installable": True, "application": False, diff --git a/spp_cel_dci_bridge/models/__init__.py b/spp_cel_dci_bridge/models/__init__.py index e69de29b..9976ed72 100644 --- a/spp_cel_dci_bridge/models/__init__.py +++ b/spp_cel_dci_bridge/models/__init__.py @@ -0,0 +1,2 @@ +from . import data_provider +from . import cel_variable 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..e84e85db --- /dev/null +++ b/spp_cel_dci_bridge/models/cel_variable.py @@ -0,0 +1,52 @@ +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class CELVariable(models.Model): + _inherit = "spp.cel.variable" + + 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", + string="External Failure Policy", + 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_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/tests/__init__.py b/spp_cel_dci_bridge/tests/__init__.py index e69de29b..ea30d4b7 100644 --- a/spp_cel_dci_bridge/tests/__init__.py +++ b/spp_cel_dci_bridge/tests/__init__.py @@ -0,0 +1 @@ +from . import test_schema_extensions 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..a1176721 --- /dev/null +++ b/spp_cel_dci_bridge/tests/test_schema_extensions.py @@ -0,0 +1,144 @@ +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..618a74be --- /dev/null +++ b/spp_cel_dci_bridge/views/cel_variable_views.xml @@ -0,0 +1,25 @@ + + + + + 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..b982f3b6 --- /dev/null +++ b/spp_cel_dci_bridge/views/data_provider_views.xml @@ -0,0 +1,27 @@ + + + + + spp.data.provider.form.dci + spp.data.provider + + + + + + + + + + + spp.data.provider.tree.dci + spp.data.provider + + + + + + + + + From 85d36b8ba1001fcba96cd9a6053d5a880cc2cfca Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Wed, 13 May 2026 16:01:26 +0800 Subject: [PATCH 03/62] feat(spp_cel_dci_bridge): add DCI dispatcher skeleton spp.cel.dci.dispatcher routes fetch_values_for_variable() by the DCI data source's registry_type to per-type handlers (DR, CRVS, IBR, SR, FR). Handlers return {} in this step; subsequent commits implement DR (step 4), CRVS and IBR (steps 9-10). Tests verify routing logic, graceful empty returns for missing/inactive setup, UserError on unknown registry types, and the nested-attribute path extraction helper used by handlers. --- spp_cel_dci_bridge/models/__init__.py | 1 + spp_cel_dci_bridge/models/dci_dispatcher.py | 138 ++++++++++++++++++++ spp_cel_dci_bridge/tests/__init__.py | 1 + spp_cel_dci_bridge/tests/test_dispatcher.py | 110 ++++++++++++++++ 4 files changed, 250 insertions(+) create mode 100644 spp_cel_dci_bridge/models/dci_dispatcher.py create mode 100644 spp_cel_dci_bridge/tests/test_dispatcher.py diff --git a/spp_cel_dci_bridge/models/__init__.py b/spp_cel_dci_bridge/models/__init__.py index 9976ed72..ea0c9cb0 100644 --- a/spp_cel_dci_bridge/models/__init__.py +++ b/spp_cel_dci_bridge/models/__init__.py @@ -1,2 +1,3 @@ from . import data_provider from . import cel_variable +from . import dci_dispatcher 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..0fb0319b --- /dev/null +++ b/spp_cel_dci_bridge/models/dci_dispatcher.py @@ -0,0 +1,138 @@ +import logging + +from odoo import _, api, models +from odoo.exceptions import UserError + +_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", + } + + @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 {} + + registry_type = source.registry_type + handler_name = self._HANDLERS.get(registry_type) + if not handler_name: + raise UserError( + _( + "No DCI handler for registry_type=%(reg)s on variable " + "%(var)s", + reg=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): + """Skeleton; filled in by step 4.""" + return {} + + def _handler_crvs(self, variable, source, subject_ids, period_key): + """Skeleton; filled in by step 9.""" + return {} + + def _handler_ibr(self, variable, source, subject_ids, period_key): + """Skeleton; filled in by step 10.""" + return {} + + def _handler_sr(self, variable, source, subject_ids, period_key): + """Social Registry handler; not implemented in v1.""" + _logger.info( + "SR handler not implemented; returning empty for variable %s", + variable.name, + ) + return {} + + def _handler_fr(self, variable, source, subject_ids, period_key): + """Functional Registry handler; not implemented in v1.""" + _logger.info( + "FR handler not implemented; returning empty for variable %s", + variable.name, + ) + return {} + + # ------------------------------------------------------------------ + # 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/tests/__init__.py b/spp_cel_dci_bridge/tests/__init__.py index ea30d4b7..460d6b5b 100644 --- a/spp_cel_dci_bridge/tests/__init__.py +++ b/spp_cel_dci_bridge/tests/__init__.py @@ -1 +1,2 @@ from . import test_schema_extensions +from . import test_dispatcher 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..8ebedfc8 --- /dev/null +++ b/spp_cel_dci_bridge/tests/test_dispatcher.py @@ -0,0 +1,110 @@ +from odoo.exceptions import UserError +from odoo.tests.common import TransactionCase, tagged + + +@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(self): + var, source, _ = self._make_variable("DR", "unknown") + # Bypass the registry_type constraint by writing raw + self.env.cr.execute( + "UPDATE spp_dci_data_source SET registry_type = 'XX' WHERE id = %s", + (source.id,), + ) + source.invalidate_recordset() + with self.assertRaises(UserError): + self.dispatcher.fetch_values_for_variable(var, [1], "current") + + 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) From 066ee8b2d9a1d67178e2e20b1d6a9c4e3f469af7 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Wed, 13 May 2026 16:04:00 +0800 Subject: [PATCH 04/62] feat(spp_cel_dci_bridge): implement DR registry-type handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _handler_dr instantiates DRService per subject, extracts the configured dci_attribute_path from the response payload, and returns {subject_id: value}. Subjects without DR records, without resolvable identifiers, or that error during the fetch are omitted (not raised) so a single bad subject can't fail the batch. Failure policy (step 6) decides what null means. Tests cover happy path, false values, nested attribute extraction, empty responses, missing identifiers, multi-subject batches, and per-subject error tolerance. Uses MagicMock to patch DCIClient — matching the established mocking pattern in spp_dci_client_dr/tests. spp_dci_client_dr is declared as a hard dependency for v1; the runtime ImportError guard remains so future deployments without DR can refactor. --- spp_cel_dci_bridge/__manifest__.py | 1 + spp_cel_dci_bridge/models/dci_dispatcher.py | 44 +++++- spp_cel_dci_bridge/tests/__init__.py | 1 + spp_cel_dci_bridge/tests/common.py | 157 ++++++++++++++++++++ spp_cel_dci_bridge/tests/test_dr_handler.py | 132 ++++++++++++++++ 5 files changed, 333 insertions(+), 2 deletions(-) create mode 100644 spp_cel_dci_bridge/tests/common.py create mode 100644 spp_cel_dci_bridge/tests/test_dr_handler.py diff --git a/spp_cel_dci_bridge/__manifest__.py b/spp_cel_dci_bridge/__manifest__.py index 3b9b0820..4283429e 100644 --- a/spp_cel_dci_bridge/__manifest__.py +++ b/spp_cel_dci_bridge/__manifest__.py @@ -12,6 +12,7 @@ "depends": [ "spp_cel_domain", "spp_dci_client", + "spp_dci_client_dr", "spp_studio", ], "external_dependencies": {"python": []}, diff --git a/spp_cel_dci_bridge/models/dci_dispatcher.py b/spp_cel_dci_bridge/models/dci_dispatcher.py index 0fb0319b..2878e072 100644 --- a/spp_cel_dci_bridge/models/dci_dispatcher.py +++ b/spp_cel_dci_bridge/models/dci_dispatcher.py @@ -88,8 +88,48 @@ def fetch_values_for_variable(self, variable, subject_ids, period_key): # ------------------------------------------------------------------ def _handler_dr(self, variable, source, subject_ids, period_key): - """Skeleton; filled in by step 4.""" - return {} + """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. + """ + try: + from odoo.addons.spp_dci_client_dr.services.dr_service import ( + DRService, + ) + except ImportError: + _logger.warning( + "spp_dci_client_dr is not installed; cannot fetch variable " + "%s. Install spp_dci_client_dr or remove the variable.", + variable.name, + ) + return {} + + 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: + try: + payload = service.get_disability_status(partner) + except Exception as e: + _logger.warning( + "DR fetch failed for partner %d (var=%s): %s", + partner.id, + variable.name, + e, + ) + continue + + value = self._extract_by_path(payload, path) + if value is not None: + result[partner.id] = value + + return result def _handler_crvs(self, variable, source, subject_ids, period_key): """Skeleton; filled in by step 9.""" diff --git a/spp_cel_dci_bridge/tests/__init__.py b/spp_cel_dci_bridge/tests/__init__.py index 460d6b5b..55809059 100644 --- a/spp_cel_dci_bridge/tests/__init__.py +++ b/spp_cel_dci_bridge/tests/__init__.py @@ -1,2 +1,3 @@ from . import test_schema_extensions from . import test_dispatcher +from . import test_dr_handler diff --git a/spp_cel_dci_bridge/tests/common.py b/spp_cel_dci_bridge/tests/common.py new file mode 100644 index 00000000..a69e807b --- /dev/null +++ b/spp_cel_dci_bridge/tests/common.py @@ -0,0 +1,157 @@ +"""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_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}) From db6c7468ee504936b699f8bc40aa63a17daaa522 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Wed, 13 May 2026 16:17:12 +0800 Subject: [PATCH 05/62] feat(spp_cel_dci_bridge): override cache manager to route DCI externals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inherit spp.data.cache.manager and override _compute_variable_values: when source_type='external' and provider.is_dci_backed, call the dispatcher; otherwise super(). This fills the documented blind spot in spp_cel_domain/models/data_evaluator.py:108-116 ("Values must be pushed via API or scoring run") for the DCI case. The cycle pre-fetch path (cycle_manager_base._precompute_cycle_cached_variables) flows through this override unchanged — the rest of the eligibility plumbing requires no edits. Tests verify routing, super-fallthrough for non-DCI externals, non-interference with source_type='field', the end-to-end precompute path writes to spp.data.value, and dispatcher errors yield {} not raise. --- spp_cel_dci_bridge/models/__init__.py | 1 + .../models/data_cache_manager.py | 55 ++++++++ spp_cel_dci_bridge/tests/__init__.py | 1 + .../tests/test_cache_manager_override.py | 121 ++++++++++++++++++ 4 files changed, 178 insertions(+) create mode 100644 spp_cel_dci_bridge/models/data_cache_manager.py create mode 100644 spp_cel_dci_bridge/tests/test_cache_manager_override.py diff --git a/spp_cel_dci_bridge/models/__init__.py b/spp_cel_dci_bridge/models/__init__.py index ea0c9cb0..8fc21625 100644 --- a/spp_cel_dci_bridge/models/__init__.py +++ b/spp_cel_dci_bridge/models/__init__.py @@ -1,3 +1,4 @@ from . import data_provider from . import cel_variable from . import dci_dispatcher +from . import data_cache_manager 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..34cc6898 --- /dev/null +++ b/spp_cel_dci_bridge/models/data_cache_manager.py @@ -0,0 +1,55 @@ +import logging + +from odoo import models + +_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 external values via the dispatcher. + + v1: failure policy is implicitly 'null' — exceptions and missing + subjects produce no entry in the result dict; the cache manager + records the absence. Step 6 will add explicit policy handling + for 'last_known' and 'fail'. + """ + dispatcher = self.env["spp.cel.dci.dispatcher"] + try: + return dispatcher.fetch_values_for_variable( + variable, subject_ids, period_key + ) + except Exception as e: + _logger.error( + "DCI fetch failed for variable %s: %s", + variable.name, + e, + exc_info=True, + ) + return {} diff --git a/spp_cel_dci_bridge/tests/__init__.py b/spp_cel_dci_bridge/tests/__init__.py index 55809059..17ca80c1 100644 --- a/spp_cel_dci_bridge/tests/__init__.py +++ b/spp_cel_dci_bridge/tests/__init__.py @@ -1,3 +1,4 @@ from . import test_schema_extensions from . import test_dispatcher from . import test_dr_handler +from . import test_cache_manager_override 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..5e14b5cd --- /dev/null +++ b/spp_cel_dci_bridge/tests/test_cache_manager_override.py @@ -0,0 +1,121 @@ +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_empty_not_raise(self, mock_client_class): + """An unhandled exception inside the dispatcher must not crash the + cache manager — it returns {} and logs the error.""" + 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; nothing is returned. + self.assertEqual(result, {}) From 3f67ffbf96b20f3e890e076b33b8656f6647c862 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Wed, 13 May 2026 16:19:33 +0800 Subject: [PATCH 06/62] feat(spp_cel_dci_bridge): implement external_failure_policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three policies as defined in ADR-023 §8: - null (default): swallow errors; missing entries leave CEL evaluating against null - last_known: surface most recent non-null spp.data.value row, regardless of expiry - fail: propagate exception as UserError; eligibility check aborts last_known queries spp.data.value sorted by recorded_at desc, takes the first non-null payload per subject, and logs a warning per fallback so operators see what's degraded. Per-subject errors inside the handler loop continue to fall under whichever policy applies; only the wholesale dispatcher exception triggers the fail re-raise in v1. Tests cover all three policies for both wholesale and partial failures, plus the partial-success case where some subjects get live values and others get last-known fallbacks. --- .../models/data_cache_manager.py | 72 +++++- spp_cel_dci_bridge/tests/__init__.py | 1 + .../tests/test_failure_policy.py | 234 ++++++++++++++++++ 3 files changed, 296 insertions(+), 11 deletions(-) create mode 100644 spp_cel_dci_bridge/tests/test_failure_policy.py diff --git a/spp_cel_dci_bridge/models/data_cache_manager.py b/spp_cel_dci_bridge/models/data_cache_manager.py index 34cc6898..896c9381 100644 --- a/spp_cel_dci_bridge/models/data_cache_manager.py +++ b/spp_cel_dci_bridge/models/data_cache_manager.py @@ -1,6 +1,7 @@ import logging -from odoo import models +from odoo import _, models +from odoo.exceptions import UserError _logger = logging.getLogger(__name__) @@ -33,23 +34,72 @@ def _compute_variable_values(self, variable, subject_ids, period_key, program_id ) def _compute_dci_values(self, variable, subject_ids, period_key, program_id): - """Fetch DCI-backed external values via the dispatcher. - - v1: failure policy is implicitly 'null' — exceptions and missing - subjects produce no entry in the result dict; the cache manager - records the absence. Step 6 will add explicit policy handling - for 'last_known' and 'fail'. - """ + """Fetch DCI-backed values, then apply the variable's failure policy.""" dispatcher = self.env["spp.cel.dci.dispatcher"] + policy = variable.external_failure_policy or "null" + try: - return dispatcher.fetch_values_for_variable( + values = dispatcher.fetch_values_for_variable( variable, subject_ids, period_key ) except Exception as e: _logger.error( - "DCI fetch failed for variable %s: %s", + "DCI fetch failed for variable %s (policy=%s): %s", variable.name, + policy, e, exc_info=True, ) - return {} + 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 + ) + + 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. + """ + DataValue = self.env["spp.data.value"] + rows = DataValue.search( + [ + ("variable_name", "=", variable.name), + ("subject_id", "in", list(missing_subject_ids)), + ("subject_model", "=", "res.partner"), + ], + order="recorded_at desc", + ) + + filled = dict(values) + seen = set() + for row in rows: + if row.subject_id in seen: + continue + payload = row.value_json + if isinstance(payload, dict) and payload.get("value") is not None: + filled[row.subject_id] = payload["value"] + seen.add(row.subject_id) + _logger.warning( + "Variable %s: using last-known value for subject %d " + "(recorded_at=%s) due to fetch failure", + variable.name, + row.subject_id, + row.recorded_at, + ) + return filled diff --git a/spp_cel_dci_bridge/tests/__init__.py b/spp_cel_dci_bridge/tests/__init__.py index 17ca80c1..4baa015e 100644 --- a/spp_cel_dci_bridge/tests/__init__.py +++ b/spp_cel_dci_bridge/tests/__init__.py @@ -2,3 +2,4 @@ from . import test_dispatcher from . import test_dr_handler from . import test_cache_manager_override +from . import test_failure_policy 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..794d056f --- /dev/null +++ b/spp_cel_dci_bridge/tests/test_failure_policy.py @@ -0,0 +1,234 @@ +from unittest.mock import MagicMock, patch + +from odoo.exceptions import UserError +from odoo.tests.common import tagged + +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 has no entry (CEL sees null/false) + self.assertEqual(result, {self.partner_a.id: True}) + + @patch("odoo.addons.spp_dci_client_dr.services.dr_service.DCIClient") + def test_null_policy_returns_empty_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, + ) + + self.assertEqual(result, {}) + + # -------------------------------------------------------------- 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_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_empty(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, so result remains empty + self.assertEqual(result, {}) + + @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" + self.assertEqual(result, {}) + + @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}, + ) From 9162a53232681e0c714c8bf58b415a7263259aea Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Wed, 13 May 2026 16:33:19 +0800 Subject: [PATCH 07/62] feat(spp_cel_dci_bridge): add per-subject DCI fetch audit log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New lightweight model spp.dci.fetch.audit records one row per subject per fetch attempt: provider, data source, registry type, variable, subject, outcome (ok/not_found/error), error message, elapsed ms, user. Decided in ADR-023 §6.4 to ship a dedicated model rather than reuse spp.audit.log, which is CRUD-shaped and would require synthetic audit rules to record non-CRUD events. DR handler now wraps each subject fetch with start/stop timing and calls _record_audit with the appropriate result. Audit writes go through sudo so background workers can record regardless of user context; audit failures are caught so they can't poison a fetch. Read access granted to all internal users; write to spp admin only. --- spp_cel_dci_bridge/__manifest__.py | 1 + spp_cel_dci_bridge/models/__init__.py | 1 + spp_cel_dci_bridge/models/dci_dispatcher.py | 56 +++++++++- spp_cel_dci_bridge/models/dci_fetch_audit.py | 45 ++++++++ .../security/ir.model.access.csv | 2 + spp_cel_dci_bridge/tests/__init__.py | 1 + .../tests/test_audit_logging.py | 103 ++++++++++++++++++ .../views/dci_fetch_audit_views.xml | 48 ++++++++ 8 files changed, 255 insertions(+), 2 deletions(-) create mode 100644 spp_cel_dci_bridge/models/dci_fetch_audit.py create mode 100644 spp_cel_dci_bridge/tests/test_audit_logging.py create mode 100644 spp_cel_dci_bridge/views/dci_fetch_audit_views.xml diff --git a/spp_cel_dci_bridge/__manifest__.py b/spp_cel_dci_bridge/__manifest__.py index 4283429e..7d4841ca 100644 --- a/spp_cel_dci_bridge/__manifest__.py +++ b/spp_cel_dci_bridge/__manifest__.py @@ -20,6 +20,7 @@ "security/ir.model.access.csv", "views/data_provider_views.xml", "views/cel_variable_views.xml", + "views/dci_fetch_audit_views.xml", ], "installable": True, "application": False, diff --git a/spp_cel_dci_bridge/models/__init__.py b/spp_cel_dci_bridge/models/__init__.py index 8fc21625..aa90f11b 100644 --- a/spp_cel_dci_bridge/models/__init__.py +++ b/spp_cel_dci_bridge/models/__init__.py @@ -1,4 +1,5 @@ from . import data_provider from . import cel_variable +from . import dci_fetch_audit from . import dci_dispatcher from . import data_cache_manager diff --git a/spp_cel_dci_bridge/models/dci_dispatcher.py b/spp_cel_dci_bridge/models/dci_dispatcher.py index 2878e072..65d5f913 100644 --- a/spp_cel_dci_bridge/models/dci_dispatcher.py +++ b/spp_cel_dci_bridge/models/dci_dispatcher.py @@ -1,4 +1,5 @@ import logging +import time from odoo import _, api, models from odoo.exceptions import UserError @@ -94,6 +95,8 @@ def _handler_dr(self, variable, source, subject_ids, period_key): `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 ( @@ -114,9 +117,14 @@ def _handler_dr(self, variable, source, subject_ids, period_key): 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, @@ -125,12 +133,56 @@ def _handler_dr(self, variable, source, subject_ids, period_key): ) 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 not None: - result[partner.id] = value + 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. + + Always uses sudo so background workers without per-user write rights + can record. Audit reading is restricted via ACL. + """ + try: + elapsed_ms = int((time.monotonic() - started_at) * 1000) + self.env["spp.dci.fetch.audit"].sudo().create( + { + "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): """Skeleton; filled in by step 9.""" return {} 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..0e21cfff --- /dev/null +++ b/spp_cel_dci_bridge/models/dci_fetch_audit.py @@ -0,0 +1,45 @@ +from odoo import 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") + subject_id = fields.Integer(index=True) + + 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/security/ir.model.access.csv b/spp_cel_dci_bridge/security/ir.model.access.csv index 97dd8b91..43a39314 100644 --- a/spp_cel_dci_bridge/security/ir.model.access.csv +++ b/spp_cel_dci_bridge/security/ir.model.access.csv @@ -1 +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/tests/__init__.py b/spp_cel_dci_bridge/tests/__init__.py index 4baa015e..f924a149 100644 --- a/spp_cel_dci_bridge/tests/__init__.py +++ b/spp_cel_dci_bridge/tests/__init__.py @@ -3,3 +3,4 @@ from . import test_dr_handler from . import test_cache_manager_override from . import test_failure_policy +from . import test_audit_logging 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..5eaf6167 --- /dev/null +++ b/spp_cel_dci_bridge/tests/test_audit_logging.py @@ -0,0 +1,103 @@ +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_audit_records_user_id(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(rows.user_id, self.env.user) 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..66296340 --- /dev/null +++ b/spp_cel_dci_bridge/views/dci_fetch_audit_views.xml @@ -0,0 +1,48 @@ + + + + + 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 + + + + From 2466eba083ad6b28a9723c641760d8c5a8835f84 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Wed, 13 May 2026 16:47:00 +0800 Subject: [PATCH 08/62] fix(spp_cel_domain): handle boolean RHS in metric SQL fast path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `isinstance(True, int)` is True in Python — bool is a subclass of int. The previous _metric_cmp_supported and _metric_inselect_sql checked int|float first, routing boolean rhs through the numeric SQL path. That generates: AND (CASE WHEN jsonb_typeof(...) = 'object' THEN (...)::numeric ... END) = true which postgres rejects with "operator does not exist: numeric = boolean". Fix: check isinstance(rhs, bool) BEFORE the int|float branch in both methods, and emit ::boolean casts plus a boolean rhs comparison. Affects any cached variable with value_type=boolean queried via `var == true/false` in CEL — including the new DCI-backed has_disability variable used by spp_cel_dci_bridge. --- spp_cel_domain/models/cel_executor.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/spp_cel_domain/models/cel_executor.py b/spp_cel_domain/models/cel_executor.py index 8e75979a..411ca204 100644 --- a/spp_cel_domain/models/cel_executor.py +++ b/spp_cel_domain/models/cel_executor.py @@ -1248,6 +1248,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 +1371,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 From 73e50920d917e360ceff15856614f836fa9b86ff Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Wed, 13 May 2026 17:00:36 +0800 Subject: [PATCH 09/62] feat(spp_cel_dci_bridge): end-to-end test of the demo flow Exercises the full chain: precompute_cached_variables() -> cache manager override -> dispatcher -> DR handler -> mocked DCIClient -> spp.data.value rows -> CEL service.compile_expression() -> SQL fast path against spp_data_value -> domain that filters to the right partners. The cache manager override now fills missing subjects with explicit None after policy is applied. This keeps the cache complete across the queried cohort, which is what the executor needs to use the metric SQL fast path (have == base) instead of falling back to Python evaluation that requires spp.indicator. CEL semantics line up: a subject with value=null fails `has_disability == true` filtering, which is the right answer when the external registry returned no data. Tests cover the happy path (all subjects have DR records) and the partial-results case (only some subjects matched). Several earlier failure-policy tests had to be updated for the new contract: "missing subject" now appears in the result as None rather than being absent. --- .../models/data_cache_manager.py | 18 ++- spp_cel_dci_bridge/tests/__init__.py | 1 + .../tests/test_cache_manager_override.py | 11 +- spp_cel_dci_bridge/tests/test_end_to_end.py | 153 ++++++++++++++++++ .../tests/test_failure_policy.py | 24 +-- 5 files changed, 193 insertions(+), 14 deletions(-) create mode 100644 spp_cel_dci_bridge/tests/test_end_to_end.py diff --git a/spp_cel_dci_bridge/models/data_cache_manager.py b/spp_cel_dci_bridge/models/data_cache_manager.py index 896c9381..ddba8e07 100644 --- a/spp_cel_dci_bridge/models/data_cache_manager.py +++ b/spp_cel_dci_bridge/models/data_cache_manager.py @@ -34,7 +34,15 @@ def _compute_variable_values(self, 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.""" + """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" @@ -67,6 +75,14 @@ def _compute_dci_values(self, variable, subject_ids, period_key, program_id): 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): diff --git a/spp_cel_dci_bridge/tests/__init__.py b/spp_cel_dci_bridge/tests/__init__.py index f924a149..368b7cf3 100644 --- a/spp_cel_dci_bridge/tests/__init__.py +++ b/spp_cel_dci_bridge/tests/__init__.py @@ -4,3 +4,4 @@ from . import test_cache_manager_override from . import test_failure_policy from . import test_audit_logging +from . import test_end_to_end diff --git a/spp_cel_dci_bridge/tests/test_cache_manager_override.py b/spp_cel_dci_bridge/tests/test_cache_manager_override.py index 5e14b5cd..bea6360c 100644 --- a/spp_cel_dci_bridge/tests/test_cache_manager_override.py +++ b/spp_cel_dci_bridge/tests/test_cache_manager_override.py @@ -104,9 +104,11 @@ def test_precompute_writes_to_spp_data_value(self, mock_client_class): self.assertEqual(rows.value_json, {"value": True}) @patch("odoo.addons.spp_dci_client_dr.services.dr_service.DCIClient") - def test_dispatcher_exception_yields_empty_not_raise(self, mock_client_class): + def test_dispatcher_exception_yields_null_not_raise(self, mock_client_class): """An unhandled exception inside the dispatcher must not crash the - cache manager — it returns {} and logs the error.""" + 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 @@ -117,5 +119,6 @@ def test_dispatcher_exception_yields_empty_not_raise(self, mock_client_class): self.variable, [self.partner_a.id], "current", program_id=None ) - # Per-subject error is swallowed by the handler; nothing is returned. - self.assertEqual(result, {}) + # 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_end_to_end.py b/spp_cel_dci_bridge/tests/test_end_to_end.py new file mode 100644 index 00000000..8b62b19a --- /dev/null +++ b/spp_cel_dci_bridge/tests/test_end_to_end.py @@ -0,0 +1,153 @@ +"""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) + + # 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 index 794d056f..552ac31e 100644 --- a/spp_cel_dci_bridge/tests/test_failure_policy.py +++ b/spp_cel_dci_bridge/tests/test_failure_policy.py @@ -43,11 +43,15 @@ def side_effect(**_): program_id=None, ) - # null policy: errored subject has no entry (CEL sees null/false) - self.assertEqual(result, {self.partner_a.id: True}) + # 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_empty_on_wholesale_failure(self, mock_client_class): + 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__, @@ -63,7 +67,8 @@ def test_null_policy_returns_empty_on_wholesale_failure(self, mock_client_class) program_id=None, ) - self.assertEqual(result, {}) + # Wholesale failure under null policy: subject filled with None + self.assertEqual(result, {self.partner_a.id: None}) # -------------------------------------------------------------- fail @@ -139,7 +144,7 @@ def test_last_known_policy_uses_prior_cached_value(self, mock_client_class): 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_empty(self, mock_client_class): + 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", @@ -154,8 +159,8 @@ def test_last_known_policy_no_prior_value_yields_empty(self, mock_client_class): program_id=None, ) - # No prior cached value, so result remains empty - self.assertEqual(result, {}) + # 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): @@ -187,8 +192,9 @@ def test_last_known_skips_null_prior_values(self, mock_client_class): program_id=None, ) - # Null prior values are not surfaced as "last known" - self.assertEqual(result, {}) + # 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): From cb6aa2c30fb656de0c86e9ebe4d6784266069009 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Thu, 14 May 2026 00:15:15 +0800 Subject: [PATCH 10/62] feat(spp_cel_dci_bridge): add CRVS and IBR handlers + registry_type normalizer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CRVS and IBR handlers follow the DR pattern: loop subjects, call the service, extract via dci_attribute_path, record audit. Each tolerates its respective DCI client module being uninstalled via try/ImportError. CRVS's verify_birth(id_type, id_value) needs the partner's identifier resolved first; added _first_identifier helper that reads from reg_ids. IBR's check_duplication(partner) takes a partner directly; IBRService also has a different constructor signature ((data_source, env) instead of (env, data_source_code=...)) — handled in the handler. The three DCI services use inconsistent registry_type strings: DR -> "DR" CRVS -> "ns:org:RegistryType:Civil" IBR -> "ibr" Dispatcher now normalizes via _REGISTRY_TYPE_ALIASES before key lookup so a deployment's source value (URI or short code, any of the legacy shapes) routes to the right handler. Upstream cleanup of the field is tracked separately. --- spp_cel_dci_bridge/__manifest__.py | 2 + spp_cel_dci_bridge/models/dci_dispatcher.py | 173 +++++++++++++++++- spp_cel_dci_bridge/tests/__init__.py | 1 + .../tests/test_crvs_ibr_handlers.py | 118 ++++++++++++ 4 files changed, 287 insertions(+), 7 deletions(-) create mode 100644 spp_cel_dci_bridge/tests/test_crvs_ibr_handlers.py diff --git a/spp_cel_dci_bridge/__manifest__.py b/spp_cel_dci_bridge/__manifest__.py index 7d4841ca..3831d953 100644 --- a/spp_cel_dci_bridge/__manifest__.py +++ b/spp_cel_dci_bridge/__manifest__.py @@ -13,6 +13,8 @@ "spp_cel_domain", "spp_dci_client", "spp_dci_client_dr", + "spp_dci_client_crvs", + "spp_dci_client_ibr", "spp_studio", ], "external_dependencies": {"python": []}, diff --git a/spp_cel_dci_bridge/models/dci_dispatcher.py b/spp_cel_dci_bridge/models/dci_dispatcher.py index 65d5f913..07241b59 100644 --- a/spp_cel_dci_bridge/models/dci_dispatcher.py +++ b/spp_cel_dci_bridge/models/dci_dispatcher.py @@ -32,6 +32,31 @@ class DCIDispatcher(models.AbstractModel): "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. @@ -63,14 +88,14 @@ def fetch_values_for_variable(self, variable, subject_ids, period_key): ) return {} - registry_type = source.registry_type - handler_name = self._HANDLERS.get(registry_type) + canonical = self._REGISTRY_TYPE_ALIASES.get(source.registry_type) + handler_name = self._HANDLERS.get(canonical) if canonical else None if not handler_name: raise UserError( _( "No DCI handler for registry_type=%(reg)s on variable " "%(var)s", - reg=registry_type, + reg=source.registry_type, var=variable.name, ) ) @@ -184,12 +209,146 @@ def _record_audit( _logger.error("Failed to write DCI fetch audit row: %s", e) def _handler_crvs(self, variable, source, subject_ids, period_key): - """Skeleton; filled in by step 9.""" - return {} + """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: + _logger.warning( + "spp_dci_client_crvs is not installed; cannot fetch variable " + "%s.", + variable.name, + ) + return {} + + 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): - """Skeleton; filled in by step 10.""" - return {} + """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: + _logger.warning( + "spp_dci_client_ibr is not installed; cannot fetch variable " + "%s.", + variable.name, + ) + return {} + + # 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.""" diff --git a/spp_cel_dci_bridge/tests/__init__.py b/spp_cel_dci_bridge/tests/__init__.py index 368b7cf3..b56ff4a5 100644 --- a/spp_cel_dci_bridge/tests/__init__.py +++ b/spp_cel_dci_bridge/tests/__init__.py @@ -5,3 +5,4 @@ from . import test_failure_policy from . import test_audit_logging from . import test_end_to_end +from . import test_crvs_ibr_handlers 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..a8a6f790 --- /dev/null +++ b/spp_cel_dci_bridge/tests/test_crvs_ibr_handlers.py @@ -0,0 +1,118 @@ +"""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, {}) + + +@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}) From 24f1ed6771752a600ea58873a3a437a8171801ce Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Thu, 14 May 2026 00:54:21 +0800 Subject: [PATCH 11/62] feat(spp_dci_openg2p): permanent OpenG2P DCI preset (config-only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three data/ records ship the OpenG2P wiring: - spp.dci.data.source 'openg2p_dr' (DR registry, base_url placeholder, auth_type='none' — admins configure OAuth2 after install so no secrets land in source control) - spp.data.provider 'openg2p_dr' linked to the source - In-place override of spp_studio.var_has_disability: switches the semantic 'has_disability' CEL accessor from source_type='field' (local res.partner.is_person_with_disability) to source_type='external' routed through the OpenG2P DCI provider, cache_strategy=ttl, ttl=300s for demo visibility, failure_policy=null Installing the preset declaratively states "OpenG2P is the authority for disability status in this deployment." Existing CEL rules that reference `has_disability == true` continue to work — they now evaluate against the cached DCI value instead of the local field. The CEL accessor stays semantic (vendor-neutral per ADR-023 §1a). The OpenG2P-ness lives only in the data-source and provider records. Repointing at a different DCI Disability Registry is a configuration change on the data source, never a CEL change. Smoke tests confirm the three records exist, are correctly linked, and the accessor names contain no vendor strings. --- spp_dci_openg2p/__init__.py | 0 spp_dci_openg2p/__manifest__.py | 28 +++++++++++ .../data/openg2p_cel_variables.xml | 39 +++++++++++++++ .../data/openg2p_data_provider.xml | 19 +++++++ spp_dci_openg2p/data/openg2p_data_source.xml | 38 ++++++++++++++ spp_dci_openg2p/security/ir.model.access.csv | 1 + spp_dci_openg2p/tests/__init__.py | 1 + spp_dci_openg2p/tests/test_install.py | 50 +++++++++++++++++++ 8 files changed, 176 insertions(+) create mode 100644 spp_dci_openg2p/__init__.py create mode 100644 spp_dci_openg2p/__manifest__.py create mode 100644 spp_dci_openg2p/data/openg2p_cel_variables.xml create mode 100644 spp_dci_openg2p/data/openg2p_data_provider.xml create mode 100644 spp_dci_openg2p/data/openg2p_data_source.xml create mode 100644 spp_dci_openg2p/security/ir.model.access.csv create mode 100644 spp_dci_openg2p/tests/__init__.py create mode 100644 spp_dci_openg2p/tests/test_install.py diff --git a/spp_dci_openg2p/__init__.py b/spp_dci_openg2p/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/spp_dci_openg2p/__manifest__.py b/spp_dci_openg2p/__manifest__.py new file mode 100644 index 00000000..75c470f1 --- /dev/null +++ b/spp_dci_openg2p/__manifest__.py @@ -0,0 +1,28 @@ +# 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_dci_client_dr", + ], + "external_dependencies": {"python": []}, + "data": [ + "data/openg2p_data_source.xml", + "data/openg2p_data_provider.xml", + "data/openg2p_cel_variables.xml", + ], + "installable": True, + "application": False, + "auto_install": False, +} 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..754dd72b --- /dev/null +++ b/spp_dci_openg2p/data/openg2p_cel_variables.xml @@ -0,0 +1,39 @@ + + + + + + external + res.partner + + + has_disability + ttl + 300 + null + + + 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..7e4f87f0 --- /dev/null +++ b/spp_dci_openg2p/data/openg2p_data_provider.xml @@ -0,0 +1,19 @@ + + + + + + OpenG2P Disability 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..99554e61 --- /dev/null +++ b/spp_dci_openg2p/data/openg2p_data_source.xml @@ -0,0 +1,38 @@ + + + + + + OpenG2P Disability Registry + openg2p_dr + DR + https://openg2p.example.org/api/v1 + + none + + 30 + + draft + OpenG2P preset. Configure auth credentials before use. + + + 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..97dd8b91 --- /dev/null +++ b/spp_dci_openg2p/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_openg2p/tests/__init__.py b/spp_dci_openg2p/tests/__init__.py new file mode 100644 index 00000000..b6486773 --- /dev/null +++ b/spp_dci_openg2p/tests/__init__.py @@ -0,0 +1 @@ +from . import test_install diff --git a/spp_dci_openg2p/tests/test_install.py b/spp_dci_openg2p/tests/test_install.py new file mode 100644 index 00000000..44c4f195 --- /dev/null +++ b/spp_dci_openg2p/tests/test_install.py @@ -0,0 +1,50 @@ +from odoo.tests.common import TransactionCase, tagged + + +@tagged("post_install", "-at_install") +class TestOpenG2PPresetInstall(TransactionCase): + """Smoke test: the three preset records exist after install and are linked correctly.""" + + def test_data_source_present(self): + source = self.env.ref("spp_dci_openg2p.openg2p_dr_source") + self.assertEqual(source.code, "openg2p_dr") + self.assertEqual(source.registry_type, "DR") + 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) + + def test_cel_variable_rewired_to_dci_provider(self): + """The preset overrides spp_studio.var_has_disability so the + semantic `has_disability` CEL accessor sources from OpenG2P over + DCI instead of from the local res.partner field.""" + variable = self.env.ref("spp_studio.var_has_disability") + provider = self.env.ref("spp_dci_openg2p.openg2p_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") + # Local field source is cleared so the resolver does not also + # try to expand to r.is_person_with_disability. + self.assertFalse(variable.source_field) + + def test_cel_accessor_is_semantic_not_vendor_named(self): + """ADR-023 §1a: CEL accessors must be vendor-neutral. + + The OpenG2P-ness lives only in the data-source/provider records, + never in the CEL surface. This test asserts the convention. + """ + variable = self.env.ref("spp_studio.var_has_disability") + for forbidden in ("openg2p", "g2p", "vendor"): + self.assertNotIn(forbidden, variable.cel_accessor.lower()) + self.assertNotIn(forbidden, variable.name.lower()) From ac35f1c210d08bb523ac3925f56260ca821d6301 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Thu, 14 May 2026 00:59:52 +0800 Subject: [PATCH 12/62] docs(spp_cel_dci_bridge,spp_dci_openg2p): readmes + lint fixes - DESCRIPTION/USAGE/CONFIGURE markdown fragments for both modules - Auto-generated README.rst + pyproject.toml + static/description (OCA hook) - ruff-format applied to all changed Python - Add # nosemgrep justification on the audit sudo() call (background workers without spp admin rights must still write audit rows) --- spp_cel_dci_bridge/README.rst | 237 ++++++++ spp_cel_dci_bridge/models/cel_variable.py | 11 +- .../models/data_cache_manager.py | 19 +- spp_cel_dci_bridge/models/dci_dispatcher.py | 110 +++- spp_cel_dci_bridge/pyproject.toml | 3 + spp_cel_dci_bridge/readme/DESCRIPTION.md | 60 ++ spp_cel_dci_bridge/readme/USAGE.md | 39 ++ spp_cel_dci_bridge/tests/common.py | 4 +- .../tests/test_audit_logging.py | 16 +- .../tests/test_cache_manager_override.py | 16 +- spp_cel_dci_bridge/tests/test_dispatcher.py | 12 +- .../tests/test_failure_policy.py | 4 +- .../tests/test_schema_extensions.py | 8 +- .../views/cel_variable_views.xml | 2 - .../views/data_provider_views.xml | 2 - .../views/dci_fetch_audit_views.xml | 40 +- spp_dci_openg2p/README.rst | 184 ++++++ spp_dci_openg2p/__manifest__.py | 5 +- .../data/openg2p_cel_variables.xml | 2 - .../data/openg2p_data_provider.xml | 2 - spp_dci_openg2p/data/openg2p_data_source.xml | 6 +- spp_dci_openg2p/pyproject.toml | 3 + spp_dci_openg2p/readme/CONFIGURE.md | 24 + spp_dci_openg2p/readme/DESCRIPTION.md | 38 ++ spp_dci_openg2p/static/description/index.html | 537 ++++++++++++++++++ 25 files changed, 1265 insertions(+), 119 deletions(-) create mode 100644 spp_cel_dci_bridge/README.rst create mode 100644 spp_cel_dci_bridge/pyproject.toml create mode 100644 spp_cel_dci_bridge/readme/DESCRIPTION.md create mode 100644 spp_cel_dci_bridge/readme/USAGE.md create mode 100644 spp_dci_openg2p/README.rst create mode 100644 spp_dci_openg2p/pyproject.toml create mode 100644 spp_dci_openg2p/readme/CONFIGURE.md create mode 100644 spp_dci_openg2p/readme/DESCRIPTION.md create mode 100644 spp_dci_openg2p/static/description/index.html diff --git a/spp_cel_dci_bridge/README.rst b/spp_cel_dci_bridge/README.rst new file mode 100644 index 00000000..5bf1cd74 --- /dev/null +++ b/spp_cel_dci_bridge/README.rst @@ -0,0 +1,237 @@ +========================== +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 | ++-----------------------+-----------------------------+----------------------------------+ + +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. + +.. code:: cel + + has_disability == true and 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. Create a ``spp.data.provider`` and set ``dci_data_source_id`` to the + source above. +3. 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`` instead of +doing the above by hand — it ships a permanent preset. + +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/models/cel_variable.py b/spp_cel_dci_bridge/models/cel_variable.py index e84e85db..e99144f1 100644 --- a/spp_cel_dci_bridge/models/cel_variable.py +++ b/spp_cel_dci_bridge/models/cel_variable.py @@ -33,9 +33,7 @@ class CELVariable(models.Model): ), ) - @api.constrains( - "source_type", "external_provider_id", "dci_attribute_path" - ) + @api.constrains("source_type", "external_provider_id", "dci_attribute_path") def _check_dci_attribute_path(self): for rec in self: if ( @@ -44,9 +42,4 @@ def _check_dci_attribute_path(self): 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." - ) - ) + 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 index ddba8e07..3f791ada 100644 --- a/spp_cel_dci_bridge/models/data_cache_manager.py +++ b/spp_cel_dci_bridge/models/data_cache_manager.py @@ -26,12 +26,8 @@ def _compute_variable_values(self, variable, subject_ids, period_key, program_id 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 - ) + 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. @@ -47,9 +43,7 @@ def _compute_dci_values(self, variable, subject_ids, period_key, program_id): policy = variable.external_failure_policy or "null" try: - values = dispatcher.fetch_values_for_variable( - variable, subject_ids, period_key - ) + values = dispatcher.fetch_values_for_variable(variable, subject_ids, period_key) except Exception as e: _logger.error( "DCI fetch failed for variable %s (policy=%s): %s", @@ -71,9 +65,7 @@ def _compute_dci_values(self, variable, subject_ids, period_key, program_id): if policy == "last_known": missing = set(subject_ids) - set(values.keys()) if missing: - values = self._augment_with_last_known( - variable, values, 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 @@ -112,8 +104,7 @@ def _augment_with_last_known(self, variable, values, missing_subject_ids): filled[row.subject_id] = payload["value"] seen.add(row.subject_id) _logger.warning( - "Variable %s: using last-known value for subject %d " - "(recorded_at=%s) due to fetch failure", + "Variable %s: using last-known value for subject %d (recorded_at=%s) due to fetch failure", variable.name, row.subject_id, row.recorded_at, diff --git a/spp_cel_dci_bridge/models/dci_dispatcher.py b/spp_cel_dci_bridge/models/dci_dispatcher.py index 07241b59..8d88260b 100644 --- a/spp_cel_dci_bridge/models/dci_dispatcher.py +++ b/spp_cel_dci_bridge/models/dci_dispatcher.py @@ -93,8 +93,7 @@ def fetch_values_for_variable(self, variable, subject_ids, period_key): if not handler_name: raise UserError( _( - "No DCI handler for registry_type=%(reg)s on variable " - "%(var)s", + "No DCI handler for registry_type=%(reg)s on variable %(var)s", reg=source.registry_type, var=variable.name, ) @@ -147,8 +146,12 @@ def _handler_dr(self, variable, source, subject_ids, period_key): payload = service.get_disability_status(partner) except Exception as e: self._record_audit( - variable, source, partner.id, "error", - started, error_message=str(e), + variable, + source, + partner.id, + "error", + started, + error_message=str(e), ) _logger.warning( "DR fetch failed for partner %d (var=%s): %s", @@ -160,20 +163,32 @@ def _handler_dr(self, variable, source, subject_ids, period_key): if payload is None: self._record_audit( - variable, source, partner.id, "not_found", started, + 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, + variable, + source, + partner.id, + "not_found", + started, ) continue result[partner.id] = value self._record_audit( - variable, source, partner.id, "ok", started, + variable, + source, + partner.id, + "ok", + started, ) return result @@ -182,9 +197,7 @@ def _handler_dr(self, variable, source, subject_ids, period_key): # Audit logging # ------------------------------------------------------------------ - def _record_audit( - self, variable, source, subject_id, result, started_at, error_message=None - ): + def _record_audit(self, variable, source, subject_id, result, started_at, error_message=None): """Write one spp.dci.fetch.audit row. Always uses sudo so background workers without per-user write rights @@ -192,7 +205,10 @@ def _record_audit( """ try: elapsed_ms = int((time.monotonic() - started_at) * 1000) - self.env["spp.dci.fetch.audit"].sudo().create( + # sudo() is intentional: background workers (precompute job) may + # not have spp_admin rights but every fetch must produce an audit + # row for compliance. Reading the audit log is still ACL-gated. + self.env["spp.dci.fetch.audit"].sudo().create( # nosemgrep: odoo-sudo-without-context { "provider_code": variable.external_provider_id.code, "data_source_code": source.code, @@ -222,8 +238,7 @@ def _handler_crvs(self, variable, source, subject_ids, period_key): ) except ImportError: _logger.warning( - "spp_dci_client_crvs is not installed; cannot fetch variable " - "%s.", + "spp_dci_client_crvs is not installed; cannot fetch variable %s.", variable.name, ) return {} @@ -239,7 +254,11 @@ def _handler_crvs(self, variable, source, subject_ids, period_key): identifier = self._first_identifier(partner) if identifier is None: self._record_audit( - variable, source, partner.id, "not_found", started, + variable, + source, + partner.id, + "not_found", + started, error_message="no identifier", ) continue @@ -249,31 +268,49 @@ def _handler_crvs(self, variable, source, subject_ids, period_key): 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), + 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, + partner.id, + variable.name, + e, ) continue if payload is None: self._record_audit( - variable, source, partner.id, "not_found", started, + 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, + variable, + source, + partner.id, + "not_found", + started, ) continue result[partner.id] = value self._record_audit( - variable, source, partner.id, "ok", started, + variable, + source, + partner.id, + "ok", + started, ) return result @@ -291,8 +328,7 @@ def _handler_ibr(self, variable, source, subject_ids, period_key): ) except ImportError: _logger.warning( - "spp_dci_client_ibr is not installed; cannot fetch variable " - "%s.", + "spp_dci_client_ibr is not installed; cannot fetch variable %s.", variable.name, ) return {} @@ -310,31 +346,49 @@ def _handler_ibr(self, variable, source, subject_ids, period_key): payload = service.check_duplication(partner) except Exception as e: self._record_audit( - variable, source, partner.id, "error", - started, error_message=str(e), + 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, + partner.id, + variable.name, + e, ) continue if payload is None: self._record_audit( - variable, source, partner.id, "not_found", started, + 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, + variable, + source, + partner.id, + "not_found", + started, ) continue result[partner.id] = value self._record_audit( - variable, source, partner.id, "ok", started, + variable, + source, + partner.id, + "ok", + started, ) return result 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..19b04752 --- /dev/null +++ b/spp_cel_dci_bridge/readme/DESCRIPTION.md @@ -0,0 +1,60 @@ +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 | + +### 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..170738f9 --- /dev/null +++ b/spp_cel_dci_bridge/readme/USAGE.md @@ -0,0 +1,39 @@ +### 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. + +```cel +has_disability == true and 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. Create a `spp.data.provider` and set `dci_data_source_id` to the source above. +3. 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` instead of doing the above by hand — it ships a permanent preset. + +### 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/tests/common.py b/spp_cel_dci_bridge/tests/common.py index a69e807b..85780818 100644 --- a/spp_cel_dci_bridge/tests/common.py +++ b/spp_cel_dci_bridge/tests/common.py @@ -63,9 +63,7 @@ def setUpClass(cls): 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 - ) + 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( { diff --git a/spp_cel_dci_bridge/tests/test_audit_logging.py b/spp_cel_dci_bridge/tests/test_audit_logging.py index 5eaf6167..e057a2d8 100644 --- a/spp_cel_dci_bridge/tests/test_audit_logging.py +++ b/spp_cel_dci_bridge/tests/test_audit_logging.py @@ -27,9 +27,7 @@ def test_audit_row_on_success(self, mock_client_class): 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" - ) + 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) @@ -49,9 +47,7 @@ def test_audit_row_on_not_found(self, mock_client_class): 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" - ) + 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) @@ -63,9 +59,7 @@ def test_audit_row_on_error(self, mock_client_class): 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" - ) + 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) @@ -95,9 +89,7 @@ def test_audit_records_user_id(self, mock_client_class): 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" - ) + self.env["spp.cel.dci.dispatcher"].fetch_values_for_variable(self.variable, [self.partner_a.id], "current") rows = self._audits_for_variable() self.assertEqual(rows.user_id, self.env.user) diff --git a/spp_cel_dci_bridge/tests/test_cache_manager_override.py b/spp_cel_dci_bridge/tests/test_cache_manager_override.py index bea6360c..6f092d71 100644 --- a/spp_cel_dci_bridge/tests/test_cache_manager_override.py +++ b/spp_cel_dci_bridge/tests/test_cache_manager_override.py @@ -28,9 +28,7 @@ def test_dci_backed_external_routes_to_dispatcher(self, mock_client_class): 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"} - ) + plain_provider = self.Provider.create({"name": "Plain", "code": "plain_super"}) var = self.Variable.create( { "name": "var_no_dci", @@ -44,9 +42,7 @@ def test_non_dci_external_falls_back_to_super(self): ) cache_mgr = self.env["spp.data.cache.manager"] - result = cache_mgr._compute_variable_values( - var, [self.partner_a.id], "current", program_id=None - ) + 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, {}) @@ -67,9 +63,7 @@ def test_field_source_type_unaffected(self): ) cache_mgr = self.env["spp.data.cache.manager"] - result = cache_mgr._compute_variable_values( - field_var, [self.partner_a.id], "current", program_id=None - ) + 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}) @@ -115,9 +109,7 @@ def test_dispatcher_exception_yields_null_not_raise(self, mock_client_class): 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 - ) + 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. diff --git a/spp_cel_dci_bridge/tests/test_dispatcher.py b/spp_cel_dci_bridge/tests/test_dispatcher.py index 8ebedfc8..7a9dc4ff 100644 --- a/spp_cel_dci_bridge/tests/test_dispatcher.py +++ b/spp_cel_dci_bridge/tests/test_dispatcher.py @@ -49,9 +49,7 @@ def test_empty_subjects_returns_empty_dict(self): 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"} - ) + provider = self.Provider.create({"name": "Plain", "code": "plain_p"}) var = self.Variable.create( { "name": "var_plain", @@ -61,16 +59,12 @@ def test_non_dci_provider_returns_empty(self): "external_provider_id": provider.id, } ) - self.assertEqual( - self.dispatcher.fetch_values_for_variable(var, [1], "current"), {} - ) + 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"), {} - ) + self.assertEqual(self.dispatcher.fetch_values_for_variable(var, [1], "current"), {}) def test_unknown_registry_type_raises(self): var, source, _ = self._make_variable("DR", "unknown") diff --git a/spp_cel_dci_bridge/tests/test_failure_policy.py b/spp_cel_dci_bridge/tests/test_failure_policy.py index 552ac31e..21a76a0e 100644 --- a/spp_cel_dci_bridge/tests/test_failure_policy.py +++ b/spp_cel_dci_bridge/tests/test_failure_policy.py @@ -46,9 +46,7 @@ def side_effect(**_): # 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} - ) + 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): diff --git a/spp_cel_dci_bridge/tests/test_schema_extensions.py b/spp_cel_dci_bridge/tests/test_schema_extensions.py index a1176721..385576c3 100644 --- a/spp_cel_dci_bridge/tests/test_schema_extensions.py +++ b/spp_cel_dci_bridge/tests/test_schema_extensions.py @@ -25,9 +25,7 @@ def setUpClass(cls): ) def test_provider_dci_data_source_field_exists(self): - provider = self.Provider.create( - {"name": "Plain Provider", "code": "plain_provider"} - ) + provider = self.Provider.create({"name": "Plain Provider", "code": "plain_provider"}) self.assertFalse(provider.dci_data_source_id) self.assertFalse(provider.is_dci_backed) @@ -87,9 +85,7 @@ def test_variable_dci_attribute_path_accepted(self): 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"} - ) + provider = self.Provider.create({"name": "REST Provider", "code": "rest_provider"}) var = self.Variable.create( { "name": "var_rest", diff --git a/spp_cel_dci_bridge/views/cel_variable_views.xml b/spp_cel_dci_bridge/views/cel_variable_views.xml index 618a74be..fd327f65 100644 --- a/spp_cel_dci_bridge/views/cel_variable_views.xml +++ b/spp_cel_dci_bridge/views/cel_variable_views.xml @@ -1,6 +1,5 @@ - spp.cel.variable.form.dci spp.cel.variable @@ -21,5 +20,4 @@ - diff --git a/spp_cel_dci_bridge/views/data_provider_views.xml b/spp_cel_dci_bridge/views/data_provider_views.xml index b982f3b6..93c8feaf 100644 --- a/spp_cel_dci_bridge/views/data_provider_views.xml +++ b/spp_cel_dci_bridge/views/data_provider_views.xml @@ -1,6 +1,5 @@ - spp.data.provider.form.dci spp.data.provider @@ -23,5 +22,4 @@ - diff --git a/spp_cel_dci_bridge/views/dci_fetch_audit_views.xml b/spp_cel_dci_bridge/views/dci_fetch_audit_views.xml index 66296340..e9a8647a 100644 --- a/spp_cel_dci_bridge/views/dci_fetch_audit_views.xml +++ b/spp_cel_dci_bridge/views/dci_fetch_audit_views.xml @@ -1,6 +1,5 @@ - spp.dci.fetch.audit.list spp.dci.fetch.audit @@ -12,7 +11,13 @@ - + @@ -29,11 +34,31 @@ - - - - - + + + + + @@ -44,5 +69,4 @@ list - diff --git a/spp_dci_openg2p/README.rst b/spp_dci_openg2p/README.rst new file mode 100644 index 00000000..d69b0e8e --- /dev/null +++ b/spp_dci_openg2p/README.rst @@ -0,0 +1,184 @@ +============================ +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 Disability Registry gets the wiring out of the box. Config-only in +v1 — zero Python code. + +What this module ships +~~~~~~~~~~~~~~~~~~~~~~ + ++-----------------------------------+----------------------------------+ +| Record | Purpose | ++===================================+==================================+ +| ``spp.dci.data.source`` | DCI data source: base URL, | +| 'openg2p_dr' | sender ID, registry_type=DR | ++-----------------------------------+----------------------------------+ +| ``spp.data.provider`` | CEL-side provider linked to the | +| 'openg2p_dr' | DCI source | ++-----------------------------------+----------------------------------+ +| ``spp_studio.var_has_disability`` | The semantic ``has_disability`` | +| (override) | CEL accessor, repointed at the | +| | DCI provider | ++-----------------------------------+----------------------------------+ + +The CEL accessor name stays vendor-neutral (``has_disability``, per +ADR-023 §1a). The OpenG2P-ness lives only in the data source and +provider records. Repointing at a different DCI Disability Registry is a +configuration change on the data source, never 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 + ``has_disability`` CEL accessor) +- Python code (any OpenG2P-specific behavioural quirk that emerges in + the future would be added here as adapter code; v1 stays pure config) + +Architectural shape +~~~~~~~~~~~~~~~~~~~ + +``spp_dci_openg2p`` is a vendor preset on top of the registry-type DCI +client (``spp_dci_client_dr``), 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_dr (DCI client for the Disability Registry type) + depends on + spp_dci_client (base DCI client) + +Other DCI Disability Registries (e.g., a national DR) would ship as +separate sibling preset modules (``spp_dci_``), reusing +``spp_cel_dci_bridge`` and ``spp_dci_client_dr``. + +See Also +~~~~~~~~ + +- ADR-023 — overall design, why the bridge exists, registry-type vs + vendor-preset module distinction +- ``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 +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +1. Navigate to **Settings > Technical > DCI > Data Sources** (path may + vary by spp_security group). +2. Open the ``openg2p_dr`` data source. +3. Replace the placeholder ``base_url`` + (``https://openg2p.example.org/api/v1``) with your OpenG2P deployment + URL. +4. Change ``auth_type`` from ``none`` to whatever OpenG2P requires + (typically ``oauth2``). +5. Populate ``oauth2_token_url``, ``oauth2_client_id``, + ``oauth2_client_secret``. +6. Verify ``our_sender_id`` matches what your OpenG2P instance expects + to see from OpenSPP. +7. Click **Test Connection** to verify reachability. + +Cache TTL +~~~~~~~~~ + +The preset ships with ``cache_ttl_seconds = 300`` (5 minutes) on the +``has_disability`` variable so the DCI round-trip is visible during +demos. For production: + +- Open the ``spp_studio.var_has_disability`` CEL variable +- Raise ``cache_ttl_seconds`` to 86400 (24h) or higher, balancing data + freshness against DCI request volume + +Identity mapping +~~~~~~~~~~~~~~~~ + +The bridge resolves the partner's identifier from ``partner.reg_ids`` +using the first matching id_type. Ensure every registrant in scope +carries an identifier the OpenG2P deployment can resolve (UIN, national +ID, etc.). Subjects without a resolvable identifier are recorded in +``spp.dci.fetch.audit`` as ``result='not_found'`` and are excluded from +``has_disability == true`` matches under the default null failure +policy. + +Switching to a different DCI Disability Registry +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This preset can be uninstalled and replaced with a different vendor +preset (or hand-configured records) without changing any CEL rule. The +semantic ``has_disability`` accessor stays the same; the data source +behind it changes. + +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/__manifest__.py b/spp_dci_openg2p/__manifest__.py index 75c470f1..cbd3f634 100644 --- a/spp_dci_openg2p/__manifest__.py +++ b/spp_dci_openg2p/__manifest__.py @@ -1,10 +1,7 @@ # pylint: disable=pointless-statement { "name": "OpenSPP DCI - OpenG2P Preset", - "summary": ( - "Pre-configured DCI data source, provider, and CEL variables " - "for OpenG2P deployments" - ), + "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", diff --git a/spp_dci_openg2p/data/openg2p_cel_variables.xml b/spp_dci_openg2p/data/openg2p_cel_variables.xml index 754dd72b..5a80ac46 100644 --- a/spp_dci_openg2p/data/openg2p_cel_variables.xml +++ b/spp_dci_openg2p/data/openg2p_cel_variables.xml @@ -1,6 +1,5 @@ - +

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 Disability Registry gets the wiring out of the box. Config-only in +v1 — zero Python code.

+
+

What this module ships

+ ++++ + + + + + + + + + + + + + + + + +
RecordPurpose
spp.dci.data.source +‘openg2p_dr’DCI data source: base URL, +sender ID, registry_type=DR
spp.data.provider +‘openg2p_dr’CEL-side provider linked to the +DCI source
spp_studio.var_has_disability +(override)The semantic has_disability +CEL accessor, repointed at the +DCI provider
+

The CEL accessor name stays vendor-neutral (has_disability, per +ADR-023 §1a). The OpenG2P-ness lives only in the data source and +provider records. Repointing at a different DCI Disability Registry is a +configuration change on the data source, never 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 +has_disability CEL accessor)
  • +
  • Python code (any OpenG2P-specific behavioural quirk that emerges in +the future would be added here as adapter code; v1 stays pure config)
  • +
+
+
+

Architectural shape

+

spp_dci_openg2p is a vendor preset on top of the registry-type DCI +client (spp_dci_client_dr), 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_dr      (DCI client for the Disability Registry type)
+    depends on
+spp_dci_client         (base DCI client)
+
+

Other DCI Disability Registries (e.g., a national DR) would ship as +separate sibling preset modules (spp_dci_<vendor>), reusing +spp_cel_dci_bridge and spp_dci_client_dr.

+
+
+

See Also

+
    +
  • ADR-023 — overall design, why the bridge exists, registry-type vs +vendor-preset module distinction
  • +
  • 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

+
    +
  1. Navigate to Settings > Technical > DCI > Data Sources (path may +vary by spp_security group).
  2. +
  3. Open the openg2p_dr data source.
  4. +
  5. Replace the placeholder base_url +(https://openg2p.example.org/api/v1) with your OpenG2P deployment +URL.
  6. +
  7. Change auth_type from none to whatever OpenG2P requires +(typically oauth2).
  8. +
  9. Populate oauth2_token_url, oauth2_client_id, +oauth2_client_secret.
  10. +
  11. Verify our_sender_id matches what your OpenG2P instance expects +to see from OpenSPP.
  12. +
  13. Click Test Connection to verify reachability.
  14. +
+
+
+

Cache TTL

+

The preset ships with cache_ttl_seconds = 300 (5 minutes) on the +has_disability variable so the DCI round-trip is visible during +demos. For production:

+
    +
  • Open the spp_studio.var_has_disability CEL variable
  • +
  • Raise cache_ttl_seconds to 86400 (24h) or higher, balancing data +freshness against DCI request volume
  • +
+
+
+

Identity mapping

+

The bridge resolves the partner’s identifier from partner.reg_ids +using the first matching id_type. Ensure every registrant in scope +carries an identifier the OpenG2P deployment can resolve (UIN, national +ID, etc.). Subjects without a resolvable identifier are recorded in +spp.dci.fetch.audit as result='not_found' and are excluded from +has_disability == true matches under the default null failure +policy.

+
+
+

Switching to a different DCI Disability Registry

+

This preset can be uninstalled and replaced with a different vendor +preset (or hand-configured records) without changing any CEL rule. The +semantic has_disability accessor stays the same; the data source +behind it changes.

+
+

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.

+
+
+
+ + + From a28831bdbf0dc75bbe8692e4b8b32869ea6ddf9a Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Thu, 14 May 2026 02:03:14 +0800 Subject: [PATCH 13/62] fix(spp_cel_dci_bridge): preserve operator attribution in DCI audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _record_audit() escalated to sudo() to write the audit row, which made the user_id field's default lambda resolve self.env.user against the sudoed env — recording every fetch as user_root. This defeated the audit's compliance purpose (we recorded WHAT but lost WHO triggered it). Capture self.env.uid into acting_user_id BEFORE sudo() and pass it explicitly. Audit rows now record the operator who triggered enrollment. Test reworked to drive the dispatcher via .with_user(officer) where officer is a non-admin internal user, then assert the row records that officer rather than user_root. --- spp_cel_dci_bridge/models/dci_dispatcher.py | 18 +++++++++---- .../tests/test_audit_logging.py | 26 ++++++++++++++++--- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/spp_cel_dci_bridge/models/dci_dispatcher.py b/spp_cel_dci_bridge/models/dci_dispatcher.py index 8d88260b..19bce3d0 100644 --- a/spp_cel_dci_bridge/models/dci_dispatcher.py +++ b/spp_cel_dci_bridge/models/dci_dispatcher.py @@ -200,16 +200,24 @@ def _handler_dr(self, variable, source, subject_ids, period_key): def _record_audit(self, variable, source, subject_id, result, started_at, error_message=None): """Write one spp.dci.fetch.audit row. - Always uses sudo so background workers without per-user write rights - can record. Audit reading is restricted via ACL. + 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) - # sudo() is intentional: background workers (precompute job) may - # not have spp_admin rights but every fetch must produce an audit - # row for compliance. Reading the audit log is still ACL-gated. + 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, diff --git a/spp_cel_dci_bridge/tests/test_audit_logging.py b/spp_cel_dci_bridge/tests/test_audit_logging.py index e057a2d8..f2a7510c 100644 --- a/spp_cel_dci_bridge/tests/test_audit_logging.py +++ b/spp_cel_dci_bridge/tests/test_audit_logging.py @@ -84,12 +84,32 @@ def test_one_audit_row_per_subject(self, mock_client_class): self.assertTrue(all(r.result == "ok" for r in rows)) @patch("odoo.addons.spp_dci_client_dr.services.dr_service.DCIClient") - def test_audit_records_user_id(self, mock_client_class): + 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 - self.env["spp.cel.dci.dispatcher"].fetch_values_for_variable(self.variable, [self.partner_a.id], "current") + # 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(rows.user_id, self.env.user) + 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")) From 291816aa7ac060a167256242116b897a36780b74 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Thu, 14 May 2026 02:06:51 +0800 Subject: [PATCH 14/62] fix(spp_cel_dci_bridge): surface broken DCI integration via DCIConfigurationError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, configuration errors silently degraded into "no one is eligible": - spp_dci_client_dr not installed -> ImportError branch returned {} - _handler_sr / _handler_fr stubs returned {} - Unknown registry_type raised UserError that the cache manager could swallow under null policy _compute_dci_values would then fill every subject with None, the cache looked fresh, and the eligibility CEL filter silently excluded everyone. This is the worst kind of compliance bug — silently wrong, indistinguishable from "no one is disabled." Introduce DCIConfigurationError (subclass of UserError so existing catch-blocks still work). Raise it from: - The three ImportError branches (_handler_dr/_handler_crvs/_handler_ibr) - The two v1 stub handlers (_handler_sr, _handler_fr) - The unknown-registry-type dispatch failure _compute_dci_values now distinguishes configuration errors from runtime errors: configuration errors propagate unconditionally regardless of external_failure_policy. Runtime errors (transient network failures, registry returns 500) continue to follow the policy. Tests: - Dispatcher raises DCIConfigurationError for SR/FR/unknown - Cache manager lets DCIConfigurationError propagate under all three failure policies (null/last_known/fail) --- spp_cel_dci_bridge/__init__.py | 1 + spp_cel_dci_bridge/exceptions.py | 26 +++++++ .../models/data_cache_manager.py | 8 +++ spp_cel_dci_bridge/models/dci_dispatcher.py | 72 +++++++++++-------- spp_cel_dci_bridge/tests/test_dispatcher.py | 27 ++++++- .../tests/test_failure_policy.py | 53 ++++++++++++++ 6 files changed, 155 insertions(+), 32 deletions(-) create mode 100644 spp_cel_dci_bridge/exceptions.py diff --git a/spp_cel_dci_bridge/__init__.py b/spp_cel_dci_bridge/__init__.py index 0650744f..4cc91a92 100644 --- a/spp_cel_dci_bridge/__init__.py +++ b/spp_cel_dci_bridge/__init__.py @@ -1 +1,2 @@ +from . import exceptions from . import models 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/data_cache_manager.py b/spp_cel_dci_bridge/models/data_cache_manager.py index 3f791ada..f982c475 100644 --- a/spp_cel_dci_bridge/models/data_cache_manager.py +++ b/spp_cel_dci_bridge/models/data_cache_manager.py @@ -3,6 +3,8 @@ from odoo import _, models from odoo.exceptions import UserError +from ..exceptions import DCIConfigurationError + _logger = logging.getLogger(__name__) @@ -44,6 +46,12 @@ def _compute_dci_values(self, variable, subject_ids, period_key, program_id): 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", diff --git a/spp_cel_dci_bridge/models/dci_dispatcher.py b/spp_cel_dci_bridge/models/dci_dispatcher.py index 19bce3d0..bc70667a 100644 --- a/spp_cel_dci_bridge/models/dci_dispatcher.py +++ b/spp_cel_dci_bridge/models/dci_dispatcher.py @@ -2,7 +2,8 @@ import time from odoo import _, api, models -from odoo.exceptions import UserError + +from ..exceptions import DCIConfigurationError _logger = logging.getLogger(__name__) @@ -91,7 +92,7 @@ def fetch_values_for_variable(self, variable, subject_ids, period_key): canonical = self._REGISTRY_TYPE_ALIASES.get(source.registry_type) handler_name = self._HANDLERS.get(canonical) if canonical else None if not handler_name: - raise UserError( + raise DCIConfigurationError( _( "No DCI handler for registry_type=%(reg)s on variable %(var)s", reg=source.registry_type, @@ -126,13 +127,15 @@ def _handler_dr(self, variable, source, subject_ids, period_key): from odoo.addons.spp_dci_client_dr.services.dr_service import ( DRService, ) - except ImportError: - _logger.warning( - "spp_dci_client_dr is not installed; cannot fetch variable " - "%s. Install spp_dci_client_dr or remove the variable.", - variable.name, - ) - return {} + 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"] @@ -244,12 +247,15 @@ def _handler_crvs(self, variable, source, subject_ids, period_key): from odoo.addons.spp_dci_client_crvs.services.crvs_service import ( CRVSService, ) - except ImportError: - _logger.warning( - "spp_dci_client_crvs is not installed; cannot fetch variable %s.", - variable.name, - ) - return {} + 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"] @@ -334,12 +340,15 @@ def _handler_ibr(self, variable, source, subject_ids, period_key): from odoo.addons.spp_dci_client_ibr.services.ibr_service import ( IBRService, ) - except ImportError: - _logger.warning( - "spp_dci_client_ibr is not installed; cannot fetch variable %s.", - variable.name, - ) - return {} + 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) @@ -414,19 +423,24 @@ def _first_identifier(partner): def _handler_sr(self, variable, source, subject_ids, period_key): """Social Registry handler; not implemented in v1.""" - _logger.info( - "SR handler not implemented; returning empty for variable %s", - variable.name, + 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, + ) ) - return {} def _handler_fr(self, variable, source, subject_ids, period_key): """Functional Registry handler; not implemented in v1.""" - _logger.info( - "FR handler not implemented; returning empty for variable %s", - variable.name, + 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, + ) ) - return {} # ------------------------------------------------------------------ # Helpers shared by handlers diff --git a/spp_cel_dci_bridge/tests/test_dispatcher.py b/spp_cel_dci_bridge/tests/test_dispatcher.py index 7a9dc4ff..8dc60b1a 100644 --- a/spp_cel_dci_bridge/tests/test_dispatcher.py +++ b/spp_cel_dci_bridge/tests/test_dispatcher.py @@ -1,6 +1,8 @@ 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): @@ -66,17 +68,36 @@ def test_inactive_source_returns_empty(self): source.active = False self.assertEqual(self.dispatcher.fetch_values_for_variable(var, [1], "current"), {}) - def test_unknown_registry_type_raises(self): + def test_unknown_registry_type_raises_configuration_error(self): var, source, _ = self._make_variable("DR", "unknown") - # Bypass the registry_type constraint by writing raw + # 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(UserError): + 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") diff --git a/spp_cel_dci_bridge/tests/test_failure_policy.py b/spp_cel_dci_bridge/tests/test_failure_policy.py index 21a76a0e..eb91ec22 100644 --- a/spp_cel_dci_bridge/tests/test_failure_policy.py +++ b/spp_cel_dci_bridge/tests/test_failure_policy.py @@ -3,6 +3,8 @@ 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 @@ -236,3 +238,54 @@ def side_effect(**_): 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, + ) From 9a7e6a7ba986269acd513474415194fe2c225f88 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Thu, 14 May 2026 02:08:41 +0800 Subject: [PATCH 15/62] fix(spp_dci_openg2p): post_init_hook re-asserts DCI binding on upgrade spp_studio.var_has_disability lacks noupdate=1 in its declaring module, so a future `-u spp_studio` (run as part of any unrelated upgrade) will silently reset the variable back to source_type='field', breaking the demo deployment with no error. The preset's own noupdate=1 only protects against re-applying THIS module's data file, not upstream resets. post_init_hook re-asserts the DCI binding (source_type='external', external_provider_id, dci_attribute_path, ttl, failure_policy) after every install/upgrade. Odoo upgrade ordering guarantees this fires after spp_studio's data files have loaded, so any silent reset gets undone here. Idempotent: when the binding is already correct, the hook short-circuits without writing. Tests: - Simulated spp_studio reset then ran hook: binding restored - Idempotency check: hook on clean state is a no-op --- spp_dci_openg2p/__init__.py | 65 +++++++++++++++++++++++++++ spp_dci_openg2p/__manifest__.py | 1 + spp_dci_openg2p/tests/test_install.py | 57 +++++++++++++++++++++++ 3 files changed, 123 insertions(+) diff --git a/spp_dci_openg2p/__init__.py b/spp_dci_openg2p/__init__.py index e69de29b..c90a5cb4 100644 --- a/spp_dci_openg2p/__init__.py +++ b/spp_dci_openg2p/__init__.py @@ -0,0 +1,65 @@ +import logging + +_logger = logging.getLogger(__name__) + + +def post_init_hook(env): + """Re-assert the DCI binding on spp_studio.var_has_disability. + + The preset overrides spp_studio.var_has_disability so the semantic + `has_disability` CEL accessor sources from OpenG2P over DCI instead + of the local res.partner field. The override is declared in + data/openg2p_cel_variables.xml with `noupdate="1"`, but that only + protects against re-applying THIS module's data file. It does NOT + protect against a future `-u spp_studio`, which would reset the + variable back to source_type='field' from spp_studio's original + standard_variables.xml. + + This hook re-asserts the DCI binding after every install/upgrade so + the demo deployment stays correctly wired. The Odoo upgrade ordering + guarantees this module's post_init_hook fires AFTER spp_studio's + data files have been loaded, so any silent reset is undone here. + """ + 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." + ) + return + + 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 " + "DCI binding on has_disability variable." + ) + 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", + } + + drift = { + field: value + for field, value in expected.items() + if (variable[field].id if hasattr(variable[field], "id") else variable[field]) != value + } + if drift: + 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.debug( + "spp_studio.var_has_disability DCI binding already correct; no changes." + ) diff --git a/spp_dci_openg2p/__manifest__.py b/spp_dci_openg2p/__manifest__.py index cbd3f634..8e332b38 100644 --- a/spp_dci_openg2p/__manifest__.py +++ b/spp_dci_openg2p/__manifest__.py @@ -22,4 +22,5 @@ "installable": True, "application": False, "auto_install": False, + "post_init_hook": "post_init_hook", } diff --git a/spp_dci_openg2p/tests/test_install.py b/spp_dci_openg2p/tests/test_install.py index 44c4f195..cf8c6eae 100644 --- a/spp_dci_openg2p/tests/test_install.py +++ b/spp_dci_openg2p/tests/test_install.py @@ -1,5 +1,7 @@ from odoo.tests.common import TransactionCase, tagged +from odoo.addons.spp_dci_openg2p import post_init_hook + @tagged("post_install", "-at_install") class TestOpenG2PPresetInstall(TransactionCase): @@ -48,3 +50,58 @@ def test_cel_accessor_is_semantic_not_vendor_named(self): for forbidden in ("openg2p", "g2p", "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. Without this protection, an unrelated + upgrade silently breaks the demo deployment.""" + variable = self.env.ref("spp_studio.var_has_disability") + provider = self.env.ref("spp_dci_openg2p.openg2p_dr_provider") + + # Simulate spp_studio re-applying its standard_variables.xml + 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", + } + ) + # Confirm the reset took effect + self.assertEqual(variable.source_type, "field") + self.assertFalse(variable.external_provider_id) + + # Run the hook + post_init_hook(self.env) + + # Verify the DCI binding was re-asserted + 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.cache_strategy, "ttl") + self.assertEqual(variable.cache_ttl_seconds, 300) + + def test_post_init_hook_is_idempotent(self): + """Running the hook when the binding is already correct must not + write or log noise. Verify no validation errors and the variable + state is unchanged.""" + 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) From 0f90f5cbb8d6776922a1258953b676254e618189 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Thu, 14 May 2026 02:10:08 +0800 Subject: [PATCH 16/62] fix(spp_cel_dci_bridge): register menuitems for DCI fetch audit action_dci_fetch_audit was previously unreachable from any menu. Two entries because two operator personas need the log: - DCI > Activity Logs > DCI Fetch Audit (DCI ops persona) - CEL Domain > Data Management > DCI Fetch Audit (CEL ops persona) Both gated to spp_admin since audit data is sensitive. --- .../views/dci_fetch_audit_views.xml | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/spp_cel_dci_bridge/views/dci_fetch_audit_views.xml b/spp_cel_dci_bridge/views/dci_fetch_audit_views.xml index e9a8647a..0d4439f2 100644 --- a/spp_cel_dci_bridge/views/dci_fetch_audit_views.xml +++ b/spp_cel_dci_bridge/views/dci_fetch_audit_views.xml @@ -69,4 +69,28 @@ list + + + + +
From a6ba0b7e1942bd45f42111d65bdc5f4647ee82f7 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Thu, 14 May 2026 02:11:33 +0800 Subject: [PATCH 17/62] fix(spp_cel_dci_bridge): dedicate notebook page for DCI integration on provider form Previously inserted dci_data_source_id inside the parent "Connection" group on the data provider form, right next to base_url and auth_type. This was confusing: when DCI routing is active, those parent fields are runtime-ignored (the linked DCI Data Source has its own URL and auth), but the form let operators edit them as if they mattered. New layout: - New "DCI Integration" notebook page (first, before "Authentication") - Hosts dci_data_source_id with no_create/no_quick_create options - Info alert appears when is_dci_backed, explaining that the legacy Base URL/Auth fields are ignored at runtime - Legacy base_url and auth_type become readonly when DCI-backed --- .../views/data_provider_views.xml | 46 +++++++++++++++++-- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/spp_cel_dci_bridge/views/data_provider_views.xml b/spp_cel_dci_bridge/views/data_provider_views.xml index 93c8feaf..22bf5109 100644 --- a/spp_cel_dci_bridge/views/data_provider_views.xml +++ b/spp_cel_dci_bridge/views/data_provider_views.xml @@ -1,14 +1,53 @@ + spp.data.provider.form.dci spp.data.provider - - - + + + + + + + + + + + + + + + is_dci_backed + + is_dci_backed + + @@ -22,4 +61,5 @@ + From ed18796893891f938ec2cc680f1b8399fd88296c Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Thu, 14 May 2026 02:12:56 +0800 Subject: [PATCH 18/62] fix(spp_cel_dci_bridge): gate CEL variable DCI fields on is_dci_backed Previously, dci_attribute_path and external_failure_policy showed for any external-source variable, including non-DCI providers (REST APIs, scoring services) where they have no meaning. Add external_provider_is_dci_backed as a related field on spp.cel.variable so the view can gate visibility through the provider's flag. Tighten both fields' invisible= conditions to require it. Also: mark dci_attribute_path required= when the provider is DCI-backed. Previously the constraint at _check_dci_attribute_path() raised ValidationError only on save; now the form renders the red asterisk and Odoo's client-side check catches it before save. --- spp_cel_dci_bridge/models/cel_variable.py | 10 +++++++++ .../views/cel_variable_views.xml | 21 ++++++++++++++----- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/spp_cel_dci_bridge/models/cel_variable.py b/spp_cel_dci_bridge/models/cel_variable.py index e99144f1..52b079ab 100644 --- a/spp_cel_dci_bridge/models/cel_variable.py +++ b/spp_cel_dci_bridge/models/cel_variable.py @@ -5,6 +5,16 @@ 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", + string="External Provider is DCI-Backed", + store=False, + readonly=True, + ) + dci_attribute_path = fields.Char( string="DCI Attribute Path", help=( diff --git a/spp_cel_dci_bridge/views/cel_variable_views.xml b/spp_cel_dci_bridge/views/cel_variable_views.xml index fd327f65..d9b85e70 100644 --- a/spp_cel_dci_bridge/views/cel_variable_views.xml +++ b/spp_cel_dci_bridge/views/cel_variable_views.xml @@ -1,23 +1,34 @@ + spp.cel.variable.form.dci spp.cel.variable + + + From 7ee490345c679b85e1ba6a3fdbba434edb2c9289 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Thu, 14 May 2026 02:27:01 +0800 Subject: [PATCH 19/62] fix(spp_cel_dci_bridge): subject Reference field on DCI fetch audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit list displayed subject_id as a bare integer with no resolution to the partner. Compliance reviewers looking at "subject_id 4271" had no way to trace it back to a person. Add subject_ref as a computed Reference field that resolves (subject_model, subject_id) to a partner record. Not stored — falsy when the partner has been deleted since the fetch — but the immutable subject_id snapshot is preserved as the historical truth. The list shows subject_ref by default; subject_id is hidden in the list (toggle-able) and remains the canonical search field for historical investigations. Tests: - subject_ref resolves to the current partner record - subject_ref is False when subject_id points to a missing partner, but the integer subject_id is preserved --- spp_cel_dci_bridge/models/dci_fetch_audit.py | 32 ++++++++++++++-- .../tests/test_audit_logging.py | 37 +++++++++++++++++++ .../views/dci_fetch_audit_views.xml | 3 +- 3 files changed, 68 insertions(+), 4 deletions(-) diff --git a/spp_cel_dci_bridge/models/dci_fetch_audit.py b/spp_cel_dci_bridge/models/dci_fetch_audit.py index 0e21cfff..20959a16 100644 --- a/spp_cel_dci_bridge/models/dci_fetch_audit.py +++ b/spp_cel_dci_bridge/models/dci_fetch_audit.py @@ -1,4 +1,4 @@ -from odoo import fields, models +from odoo import api, fields, models class DCIFetchAudit(models.Model): @@ -30,8 +30,34 @@ class DCIFetchAudit(models.Model): registry_type = fields.Char(required=True) variable_name = fields.Char(required=True, index=True) - subject_model = fields.Char(default="res.partner") - subject_id = fields.Integer(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=[ diff --git a/spp_cel_dci_bridge/tests/test_audit_logging.py b/spp_cel_dci_bridge/tests/test_audit_logging.py index f2a7510c..ea812e18 100644 --- a/spp_cel_dci_bridge/tests/test_audit_logging.py +++ b/spp_cel_dci_bridge/tests/test_audit_logging.py @@ -83,6 +83,43 @@ def test_one_audit_row_per_subject(self, mock_client_class): 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_records_acting_user_not_root(self, mock_client_class): """Regression: audit must record the operator who triggered the diff --git a/spp_cel_dci_bridge/views/dci_fetch_audit_views.xml b/spp_cel_dci_bridge/views/dci_fetch_audit_views.xml index 0d4439f2..fe918e04 100644 --- a/spp_cel_dci_bridge/views/dci_fetch_audit_views.xml +++ b/spp_cel_dci_bridge/views/dci_fetch_audit_views.xml @@ -10,7 +10,8 @@ - + + Date: Thu, 14 May 2026 02:29:19 +0800 Subject: [PATCH 20/62] chore(spp_cel_dci_bridge): ruff-format + drop redundant field string --- spp_cel_dci_bridge/models/cel_variable.py | 1 - spp_cel_dci_bridge/tests/test_audit_logging.py | 4 +--- spp_cel_dci_bridge/views/cel_variable_views.xml | 2 -- spp_cel_dci_bridge/views/data_provider_views.xml | 10 +++++----- spp_dci_openg2p/__init__.py | 13 ++++--------- 5 files changed, 10 insertions(+), 20 deletions(-) diff --git a/spp_cel_dci_bridge/models/cel_variable.py b/spp_cel_dci_bridge/models/cel_variable.py index 52b079ab..6e129b8a 100644 --- a/spp_cel_dci_bridge/models/cel_variable.py +++ b/spp_cel_dci_bridge/models/cel_variable.py @@ -10,7 +10,6 @@ class CELVariable(models.Model): # (which Odoo's view validator rejects). external_provider_is_dci_backed = fields.Boolean( related="external_provider_id.is_dci_backed", - string="External Provider is DCI-Backed", store=False, readonly=True, ) diff --git a/spp_cel_dci_bridge/tests/test_audit_logging.py b/spp_cel_dci_bridge/tests/test_audit_logging.py index ea812e18..52482786 100644 --- a/spp_cel_dci_bridge/tests/test_audit_logging.py +++ b/spp_cel_dci_bridge/tests/test_audit_logging.py @@ -90,9 +90,7 @@ def test_subject_ref_resolves_to_current_partner(self, mock_client_class): 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" - ) + 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) diff --git a/spp_cel_dci_bridge/views/cel_variable_views.xml b/spp_cel_dci_bridge/views/cel_variable_views.xml index d9b85e70..05aa5208 100644 --- a/spp_cel_dci_bridge/views/cel_variable_views.xml +++ b/spp_cel_dci_bridge/views/cel_variable_views.xml @@ -1,6 +1,5 @@ - spp.cel.variable.form.dci spp.cel.variable @@ -30,5 +29,4 @@ - diff --git a/spp_cel_dci_bridge/views/data_provider_views.xml b/spp_cel_dci_bridge/views/data_provider_views.xml index 22bf5109..3366723e 100644 --- a/spp_cel_dci_bridge/views/data_provider_views.xml +++ b/spp_cel_dci_bridge/views/data_provider_views.xml @@ -1,12 +1,10 @@ - spp.data.provider.form.dci spp.data.provider - OpenG2P Disability Registry openg2p_dr DR - https://openg2p.example.org/api/v1 + openg2p + https://partner-registry.play.openg2p.org + /dci/registry/sync/search none + openspp.test + openg2p.test 30 draft OpenG2P preset. Configure auth credentials before use. + >OpenG2P playground preset. Uses the Farmer Registry endpoint as a DR stand-in until OpenG2P publishes a real Disability Registry. See spp_dci_openg2p/readme/CONFIGURE.md for the migration plan. 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..d3d6b094 --- /dev/null +++ b/spp_dci_openg2p/models/dci_data_source.py @@ -0,0 +1,34 @@ +from odoo import fields, models + + +class DCIDataSource(models.Model): + """Add a vendor discriminator so the bridge can route to vendor-specific + DCI clients when a deployment's source has known quirks. + + The DCI spec leaves several request/response shapes ambiguous (notably + the `idtype-value` query and the `data.reg_records[]` wrapper). Vendors + have picked different interpretations. Rather than fork the upstream + DCIClient, we mark sources with a `vendor` and let the dispatcher pick + the right adapter. + + Selection values: + - openg2p: OpenG2P Partner Registry / Farmer Registry shape. Query + uses nested {id_type, id_value} payload; response wraps records + in data.reg_records[]. + """ + + _inherit = "spp.dci.data.source" + + vendor = fields.Selection( + selection=[ + ("openg2p", "OpenG2P"), + ], + 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_dci_openg2p/models/dci_dispatcher.py b/spp_dci_openg2p/models/dci_dispatcher.py new file mode 100644 index 00000000..f2a4b73e --- /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 +DR handler to ``OpenG2PFRService`` instead of the upstream ``DRService``. +The handler is otherwise structurally identical to the bridge's +``_handler_dr``: same per-subject loop, same audit row shape, same +attribute-path extraction. + +This is the Option C "adapter code" path from ADR-023 §6. Until OpenG2P's +real Disability Registry endpoint is available, the demo deployment uses +the Farmer Registry as a DR stand-in (FR-as-DR pretense). See +``services/openg2p_fr_service.py`` for the migration plan. +""" + +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): + # Route OpenG2P sources to the vendor-specific service. Sources + # without a vendor (or with a different vendor) fall through to + # the upstream DR handler. + if getattr(source, "vendor", False) == "openg2p": + return self._handler_openg2p_fr(variable, source, subject_ids, period_key) + return super()._handler_dr(variable, source, subject_ids, period_key) + + def _handler_openg2p_fr(self, variable, source, subject_ids, period_key): + """Mirror of _handler_dr but using OpenG2PFRService. + + Kept structurally identical to the upstream DR handler so the + bridge's per-subject loop semantics (audit row shape, attribute + extraction, error swallow) match exactly. This lets the upstream + handler's tests stand in as parity checks until OpenG2P provides + a real DR endpoint and this override becomes unnecessary. + """ + try: + from ..services.openg2p_fr_service import OpenG2PFRService + 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 Critical #2's silent failure). + raise DCIConfigurationError( + f"OpenG2P FR service is not importable; cannot fetch " + f"variable {variable.name}. Reinstall spp_dci_openg2p." + ) from e + + service = OpenG2PFRService(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( + "OpenG2P FR 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/readme/CONFIGURE.md b/spp_dci_openg2p/readme/CONFIGURE.md index e337a09f..ae74baf6 100644 --- a/spp_dci_openg2p/readme/CONFIGURE.md +++ b/spp_dci_openg2p/readme/CONFIGURE.md @@ -1,24 +1,65 @@ ### After installing this module -1. Navigate to **Settings > Technical > DCI > Data Sources** (path may vary by spp_security group). +The preset auto-creates a DCI data source, CEL provider, and CEL variable wired against the OpenG2P playground at `partner-registry.play.openg2p.org`. 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. -3. Replace the placeholder `base_url` (`https://openg2p.example.org/api/v1`) with your OpenG2P deployment URL. -4. Change `auth_type` from `none` to whatever OpenG2P requires (typically `oauth2`). -5. Populate `oauth2_token_url`, `oauth2_client_id`, `oauth2_client_secret`. -6. Verify `our_sender_id` matches what your OpenG2P instance expects to see from OpenSPP. -7. Click **Test Connection** to verify reachability. +3. Verify (or adjust) **Base URL** — defaults to `https://partner-registry.play.openg2p.org`. +4. The **Search Endpoint** is set to `/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. Click **Test Connection**. State should flip to `Active`. -### Cache TTL +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. + +### FR-as-DR pretense (demo-only) + +The OpenG2P playground exposes a **Farmer Registry** (FR), not a Disability Registry (DR). Per the SPDCI schema: + +``` +reg_type: ns:org:RegistryType:Social +reg_record_type: spdci-extensions-dci:Farmer +``` + +Until OpenG2P publishes a real DR endpoint, this preset treats FR as a DR stand-in: + +- The data source is configured with `registry_type='DR'` so the bridge dispatcher routes to the standard `_handler_dr`. +- `vendor='openg2p'` on the data source triggers the preset's dispatcher override, which uses `OpenG2PFRService` instead of upstream `DRService`. +- `OpenG2PFRService` queries OpenG2P's Farmer Registry. **Presence of any farmer record for a partner → `has_disability=True`**. Absence (or `REG-ERR-001 REGISTER_NOT_FOUND`) → null → fails the eligibility filter. +- The CEL surface stays exactly `has_disability == true`. Only this service's interpretation is the pretense. + +Audience-facing this looks like a real DR lookup. Operationally it tests the full DCI round-trip with OpenG2P's actual playground. -The preset ships with `cache_ttl_seconds = 300` (5 minutes) on the `has_disability` variable so the DCI round-trip is visible during demos. For production: +### Demo data: which identifiers exist in the OpenG2P playground? -- Open the `spp_studio.var_has_disability` CEL variable -- Raise `cache_ttl_seconds` to 86400 (24h) or higher, balancing data freshness against DCI request volume +Ask the OpenG2P team for sample identifiers that exist in their Farmer Registry. Configure your test partners with those identifiers (under their **External Identifiers** / `reg_ids`), and the dispatcher's `OpenG2PFRService._get_partner_identifier` 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 `has_disability == true` matches. + +### Migration plan — when OpenG2P publishes a real Disability Registry + +The migration is purely configuration; no code or data changes: + +| Step | What to change | +|---|---| +| 1. Point at the new URL | Edit `base_url` on `openg2p_dr` data source (UI) | +| 2. Switch from FR pretense to real DR | Clear the `vendor` field on the data source (set blank). The dispatcher's override falls through to the standard `_handler_dr` → upstream `DRService`. | +| 3. Verify OpenG2P's DR conforms to standard DCI shapes | Run a search; if you get `rjct.search_criteria.invalid: query.value.id_type is required` or response unwrap fails, OpenG2P's DR has the same query/response quirks as their FR. Keep `vendor='openg2p'` set and extend `OpenG2PFRService` to query the DR `reg_record_type`. Track this in ADR-023 v2 work. | +| 4. The CEL accessor stays `has_disability` | No CEL rule changes. Cached values will become real `has_disability` booleans from the DR record. | + +In words: clear one field on the data source, and OpenSPP starts reading real disability data from OpenG2P with no other edits anywhere. + +### Cache TTL -### Identity mapping +The preset ships with `cache_ttl_seconds = 300` (5 minutes) on the `has_disability` variable so the DCI round-trip is visible during demos. For production, raise to 86400 (24h) or higher via the `spp_studio.var_has_disability` form. -The bridge resolves the partner's identifier from `partner.reg_ids` using the first matching id_type. Ensure every registrant in scope carries an identifier the OpenG2P deployment can resolve (UIN, national ID, etc.). Subjects without a resolvable identifier are recorded in `spp.dci.fetch.audit` as `result='not_found'` and are excluded from `has_disability == true` matches under the default null failure policy. +### Switching to a different DCI Disability Registry vendor -### Switching to a different DCI Disability Registry +If you target a non-OpenG2P registry, the preset is the wrong starting point — clone it as `spp_dci_` and adjust: -This preset can be uninstalled and replaced with a different vendor preset (or hand-configured records) without changing any CEL rule. The semantic `has_disability` accessor stays the same; the data source behind it changes. +- The data source's `base_url` and `vendor` field +- The service class (mirror `OpenG2PFRService` for that vendor's quirks) +- The dispatcher override's branch diff --git a/spp_dci_openg2p/services/__init__.py b/spp_dci_openg2p/services/__init__.py new file mode 100644 index 00000000..ee635789 --- /dev/null +++ b/spp_dci_openg2p/services/__init__.py @@ -0,0 +1,2 @@ +from . import openg2p_dci_client +from . import openg2p_fr_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..1a91e1c6 --- /dev/null +++ b/spp_dci_openg2p/services/openg2p_dci_client.py @@ -0,0 +1,88 @@ +"""OpenG2P-aware DCIClient subclass. + +The DCI spec leaves two shapes ambiguous; OpenG2P picked different +interpretations than `spp_dci_client`. This subclass overrides only the +delta: + +1. `query` payload for `idtype-value` searches + ------------------------------------------------------------------ + Upstream emits: "query": {"type": "", "value": ""} + OpenG2P expects: "query": {"type": "idtype-value", + "value": {"id_type": "", + "id_value": ""}} + Verified live: the upstream shape returns + `rjct.search_criteria.invalid` ("query.value.id_type is required"). + +2. `reg_record_type` in search_criteria + ------------------------------------------------------------------ + OpenG2P's DciSearchCriteria schema requires `reg_record_type` + (e.g., "spdci-extensions-dci:Farmer"). Upstream's `SearchCriteria` + Pydantic model doesn't carry that field, so it never reaches the wire. + The subclass injects it into the dumped envelope before signing. + +Everything else (envelope, signing, OAuth2, retries, async) reuses +upstream code unchanged. +""" + +import logging + +from odoo.addons.spp_dci.schemas import QueryType +from odoo.addons.spp_dci_client.services.client import DCIClient + +_logger = logging.getLogger(__name__) + +DEFAULT_OPENG2P_REG_RECORD_TYPE = "spdci-extensions-dci:Farmer" + + +class OpenG2PDCIClient(DCIClient): + """DCIClient that emits OpenG2P-compatible search payloads.""" + + def __init__(self, data_source, env, reg_record_type=None): + super().__init__(data_source, env) + self._reg_record_type = reg_record_type or DEFAULT_OPENG2P_REG_RECORD_TYPE + + def _parse_query(self, query_type, query_value): + if query_type == QueryType.IDTYPE_VALUE: + if ":" not in query_value: + return super()._parse_query(query_type, query_value) + id_type, id_value = query_value.split(":", 1) + return { + "type": QueryType.IDTYPE_VALUE, + "value": { + "id_type": id_type.strip(), + "id_value": id_value.strip(), + }, + } + return super()._parse_query(query_type, query_value) + + 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, + registry_type=registry_type, + registry_event_type=registry_event_type, + record_type=record_type, + page=page, + page_size=page_size, + callback_url=callback_url, + ) + # Upstream's SearchCriteria Pydantic model omits reg_record_type; + # inject it directly into the dumped message. This must happen + # BEFORE re-signing because the signature covers header+message. + message = envelope.get("message") or {} + for item in message.get("search_request") or []: + criteria = item.get("search_criteria") + if isinstance(criteria, dict): + criteria["reg_record_type"] = self._reg_record_type + # Re-sign with the modified message so the signature is consistent. + return self._sign_request(envelope["header"], message) diff --git a/spp_dci_openg2p/services/openg2p_fr_service.py b/spp_dci_openg2p/services/openg2p_fr_service.py new file mode 100644 index 00000000..19c0b35f --- /dev/null +++ b/spp_dci_openg2p/services/openg2p_fr_service.py @@ -0,0 +1,178 @@ +"""OpenG2P FR-as-DR facade for the SPDCI demo. + +The OpenG2P playground at https://partner-registry.play.openg2p.org/ exposes +a Farmer/Partner Registry (reg_type ``ns:org:RegistryType:Social``, +reg_record_type ``spdci-extensions-dci:Farmer``), not a Disability Registry. + +For the demo we pretend FR is DR: querying OpenG2P for a partner's +existence in its farmer registry yields a synthetic ``has_disability`` +value. The semantic is "is registered in OpenG2P" → True. The CEL surface +stays ``has_disability == true``; only this service's interpretation is +the FR-as-DR pretense. + +When OpenG2P's real Disability Registry endpoint becomes available, the +migration is purely configuration: + + 1. On the OpenG2P data source, set ``vendor = False`` (clear the + OpenG2P-specific routing) and set ``base_url`` to the new DR URL. + 2. The bridge dispatcher's standard ``_handler_dr`` then uses + ``spp_dci_client_dr.DRService``, which reads ``has_disability`` from + the real DR record. + +If the real DR endpoint preserves OpenG2P's ``idtype-value`` query quirk +and ``data.reg_records[]`` response wrapper, leave ``vendor='openg2p'`` +set and extend this service to query the DR reg_record_type instead of +``Farmer``. Add a v2 selection option to the ``vendor`` field then +(``openg2p_dr``) so the dispatcher routes to a DR-specific facade. +""" + +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__) + +# OpenG2P playground reg_type / reg_record_type per the OpenAPI schema's +# DciSearchResultData example. Verified live: the server accepts these. +OPENG2P_FR_REG_TYPE = "ns:org:RegistryType:Social" +OPENG2P_FR_REG_RECORD_TYPE = "spdci-extensions-dci:Farmer" + +# Identifier priority — same shape as DRService._get_partner_identifier +# so the migration to the real DR service is behaviour-preserving. +IDENTIFIER_PRIORITY = ("UIN", "DRN", "NATIONAL_ID", "NID") + + +class OpenG2PFRService: + """DR-shaped facade over OpenG2P's Farmer Registry. + + Mirrors the subset of ``DRService`` that the bridge dispatcher's + ``_handler_dr`` calls: ``__init__(env, data_source_code)`` and + ``get_disability_status(partner)``. The dispatcher does not depend on + any DR-specific helpers, so this class can stand in for DRService + when the data source's ``vendor`` is set to ``openg2p``. + """ + + 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) + # registry_type is still "DR" on the OpenG2P preset's data source + # (so the bridge dispatcher routes here through _handler_dr). We + # do NOT validate it here — the dispatcher's vendor check is + # already authoritative. + self.client = OpenG2PDCIClient( + self.data_source, + env, + reg_record_type=OPENG2P_FR_REG_RECORD_TYPE, + ) + + # ------------------------------------------------------------------ + # Public API — matches DRService surface used by the bridge dispatcher + # ------------------------------------------------------------------ + + def get_disability_status(self, partner) -> dict | None: + """Return a DR-shaped dict for ``partner`` based on FR query result. + + Returns: + dict: ``{"has_disability": True, ...}`` if the partner is found + in OpenG2P's farmer registry (FR-as-DR pretense). + None: if the partner has no resolvable identifier OR OpenG2P + returned no record. + + Raises: + UserError: If the request fails for non-not-found reasons + (network error, bad config). Per-subject errors are caught + by the dispatcher loop and surfaced as audit rows. + """ + if not partner: + raise ValidationError("Partner is required") + + identifier = self._get_partner_identifier(partner) + if not identifier: + _logger.warning("No suitable identifier found for partner ID=%s", partner.id) + return None + + identifier_type, identifier_value = identifier + _logger.info( + "Querying OpenG2P FR for partner ID=%s using %s:%s", + partner.id, + identifier_type, + identifier_value, + ) + + try: + response = self.client.search( + query_type=QueryType.IDTYPE_VALUE, + query_value=f"{identifier_type}:{identifier_value}", + registry_type=OPENG2P_FR_REG_TYPE, + record_type=OPENG2P_FR_REG_RECORD_TYPE, + page=1, + page_size=1, + ) + except Exception as e: + _logger.error("OpenG2P FR fetch failed: %s", e, exc_info=True) + raise UserError(f"Failed to query OpenG2P: {e}") from e + + record = self._extract_first_record(response) + if record is None: + return None + + # FR-as-DR pretense: presence of a farmer record => has_disability=True + return { + "has_disability": True, + "source_registry": "OpenG2P (FR-as-DR demo)", + "raw_data": record, + } + + def is_pwd(self, partner) -> bool: + """Boolean convenience matching DRService.is_pwd shape.""" + result = self.get_disability_status(partner) + return bool(result and result.get("has_disability")) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _get_partner_identifier(self, partner): + """Return (id_type_code, id_value) for the partner. Priority order + matches DRService so swapping back to the real DRService preserves + which identifier is tried 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.id_type_id.code, reg_id.value) + if reg_ids: + first_id = reg_ids[0] + if first_id.id_type_id.code and first_id.value: + return (first_id.id_type_id.code, 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] + + The first matching record across the response is returned, 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 index b0ace1ea..e82ca92e 100644 --- a/spp_dci_openg2p/static/description/index.html +++ b/spp_dci_openg2p/static/description/index.html @@ -464,49 +464,130 @@

Configuration

After installing this module

+

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

    -
  1. Navigate to Settings > Technical > DCI > Data Sources (path may -vary by spp_security group).
  2. +
  3. Navigate to Custom > DCI > Configuration > Data Sources.
  4. Open the openg2p_dr data source.
  5. -
  6. Replace the placeholder base_url -(https://openg2p.example.org/api/v1) with your OpenG2P deployment -URL.
  7. -
  8. Change auth_type from none to whatever OpenG2P requires -(typically oauth2).
  9. -
  10. Populate oauth2_token_url, oauth2_client_id, -oauth2_client_secret.
  11. -
  12. Verify our_sender_id matches what your OpenG2P instance expects -to see from OpenSPP.
  13. -
  14. Click Test Connection to verify reachability.
  15. +
  16. Verify (or adjust) Base URL — defaults to +https://partner-registry.play.openg2p.org.
  17. +
  18. The Search Endpoint is set to /dci/registry/sync/search +(OpenG2P uses the /dci prefix).
  19. +
  20. Sender ID / Receiver ID — placeholder values are +pre-populated. Replace with what the OpenG2P operator expects from +your deployment.
  21. +
  22. 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.

+
+
+

FR-as-DR pretense (demo-only)

+

The OpenG2P playground exposes a Farmer Registry (FR), not a +Disability Registry (DR). Per the SPDCI schema:

+
+reg_type:        ns:org:RegistryType:Social
+reg_record_type: spdci-extensions-dci:Farmer
+
+

Until OpenG2P publishes a real DR endpoint, this preset treats FR as a +DR stand-in:

+
    +
  • The data source is configured with registry_type='DR' so the +bridge dispatcher routes to the standard _handler_dr.
  • +
  • vendor='openg2p' on the data source triggers the preset’s +dispatcher override, which uses OpenG2PFRService instead of +upstream DRService.
  • +
  • OpenG2PFRService queries OpenG2P’s Farmer Registry. Presence of +any farmer record for a partner → ``has_disability=True``. Absence +(or REG-ERR-001 REGISTER_NOT_FOUND) → null → fails the eligibility +filter.
  • +
  • The CEL surface stays exactly has_disability == true. Only this +service’s interpretation is the pretense.
  • +
+

Audience-facing this looks like a real DR lookup. Operationally it tests +the full DCI round-trip with OpenG2P’s actual playground.

+
+
+

Demo data: which identifiers exist in the OpenG2P playground?

+

Ask the OpenG2P team for sample identifiers that exist in their Farmer +Registry. Configure your test partners with those identifiers (under +their External Identifiers / reg_ids), and the dispatcher’s +OpenG2PFRService._get_partner_identifier 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 +has_disability == true matches.

+
+
+

Migration plan — when OpenG2P publishes a real Disability Registry

+

The migration is purely configuration; no code or data changes:

+ ++++ + + + + + + + + + + + + + + + + + + + +
StepWhat to change
    +
  1. Point at the new URL
  2. +
+
Edit base_url on openg2p_dr data source (UI)
2. Switch from FR pretense to +real DRClear the vendor field on the data source (set blank). The +dispatcher’s override falls through to the standard +_handler_dr → upstream DRService.
3. Verify OpenG2P’s DR conforms +to standard DCI shapesRun a search; if you get +rjct.search_criteria.invalid: query.value.id_type is required +or response unwrap fails, OpenG2P’s DR has the same +query/response quirks as their FR. Keep vendor='openg2p' set +and extend OpenG2PFRService to query the DR +reg_record_type. Track this in ADR-023 v2 work.
4. The CEL accessor stays +has_disabilityNo CEL rule changes. Cached values will become real +has_disability booleans from the DR record.
+

In words: clear one field on the data source, and OpenSPP starts reading +real disability data from OpenG2P with no other edits anywhere.

Cache TTL

The preset ships with cache_ttl_seconds = 300 (5 minutes) on the has_disability variable so the DCI round-trip is visible during -demos. For production:

+demos. For production, raise to 86400 (24h) or higher via the +spp_studio.var_has_disability form.

+
+
+

Switching to a different DCI Disability Registry vendor

+

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

    -
  • Open the spp_studio.var_has_disability CEL variable
  • -
  • Raise cache_ttl_seconds to 86400 (24h) or higher, balancing data -freshness against DCI request volume
  • +
  • The data source’s base_url and vendor field
  • +
  • The service class (mirror OpenG2PFRService for that vendor’s +quirks)
  • +
  • The dispatcher override’s branch
-
-
-

Identity mapping

-

The bridge resolves the partner’s identifier from partner.reg_ids -using the first matching id_type. Ensure every registrant in scope -carries an identifier the OpenG2P deployment can resolve (UIN, national -ID, etc.). Subjects without a resolvable identifier are recorded in -spp.dci.fetch.audit as result='not_found' and are excluded from -has_disability == true matches under the default null failure -policy.

-
-
-

Switching to a different DCI Disability Registry

-

This preset can be uninstalled and replaced with a different vendor -preset (or hand-configured records) without changing any CEL rule. The -semantic has_disability accessor stays the same; the data source -behind it changes.

Bug Tracker

Bugs are tracked on GitHub Issues. diff --git a/spp_dci_openg2p/tests/__init__.py b/spp_dci_openg2p/tests/__init__.py index b6486773..9ff26780 100644 --- a/spp_dci_openg2p/tests/__init__.py +++ b/spp_dci_openg2p/tests/__init__.py @@ -1 +1,4 @@ from . import test_install +from . import test_openg2p_dci_client +from . import test_openg2p_fr_service +from . import test_dispatcher_routing 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..d9b319c9 --- /dev/null +++ b/spp_dci_openg2p/tests/test_dispatcher_routing.py @@ -0,0 +1,148 @@ +"""End-to-end test: bridge dispatcher routes vendor=openg2p sources to +the OpenG2P FR service, and the result populates spp.data.value such +that a CEL eligibility filter matches the right partners. +""" + +from unittest.mock import MagicMock, patch + +from odoo.tests.common import TransactionCase, tagged + + +def make_fr_response_for_uin(uin_to_records): + """Build a stateful client.search mock: response depends on the UIN + inside the search envelope, so we can vary by partner. + """ + + def _search(**kwargs): + # The OpenG2P client's search puts query_value as "TYPE:VALUE" + qv = kwargs.get("query_value", "") + _, _, value = qv.partition(":") + 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": "ns:org:RegistryType:Social", + "reg_record_type": "spdci-extensions-dci:Farmer", + "reg_records": records, + }, + } + ] + } + } + + return _search + + +@tagged("post_install", "-at_install") +class TestDispatcherRoutesOpenG2P(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Partner setup with UIN identifier (priority order picks UIN first) + vocab = cls.env["spp.vocabulary"].search([("namespace_uri", "=", "urn:openspp:vocab:id-type")], limit=1) + if not vocab: + vocab = cls.env["spp.vocabulary"].create( + { + "name": "ID Type (dispatcher test)", + "namespace_uri": "urn:openspp:vocab:id-type", + } + ) + cls.id_type_uin = cls.env["spp.vocabulary.code"].create( + { + "vocabulary_id": vocab.id, + "code": "UIN", + "display": "UIN (dispatcher test)", + "target_type": "individual", + "is_local": True, + } + ) + cls.partner_in_fr = cls.env["res.partner"].create( + {"name": "FR Partner", "is_registrant": True, "is_group": False} + ) + cls.env["spp.registry.id"].create( + { + "partner_id": cls.partner_in_fr.id, + "id_type_id": cls.id_type_uin.id, + "value": "UIN-FR-1", + } + ) + cls.partner_not_in_fr = 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_fr.id, + "id_type_id": cls.id_type_uin.id, + "value": "UIN-UNKNOWN", + } + ) + + # The OpenG2P preset auto-creates this data source + provider + + # variable. Confirm by reading via ref. + cls.data_source = cls.env.ref("spp_dci_openg2p.openg2p_dr_source") + cls.variable = cls.env.ref("spp_studio.var_has_disability") + + def test_data_source_has_vendor_openg2p(self): + self.assertEqual(self.data_source.vendor, "openg2p") + + @patch("odoo.addons.spp_dci_openg2p.services.openg2p_fr_service.OpenG2PDCIClient") + def test_openg2p_handler_returns_has_disability_true(self, mock_client_class): + """Partner with a farmer record returns has_disability=True (the + FR-as-DR pretense).""" + mock_client = MagicMock() + mock_client.search.side_effect = make_fr_response_for_uin({"UIN-FR-1": [{"farmer_id": "F-1"}]}) + mock_client_class.return_value = mock_client + + result = self.env["spp.cel.dci.dispatcher"].fetch_values_for_variable( + self.variable, [self.partner_in_fr.id], "current" + ) + + self.assertEqual(result, {self.partner_in_fr.id: True}) + + @patch("odoo.addons.spp_dci_openg2p.services.openg2p_fr_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_fr_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_not_in_fr.id], "current" + ) + + self.assertEqual(result, {}) + audits = self.env["spp.dci.fetch.audit"].search( + [("variable_name", "=", "has_disability"), ("subject_id", "=", self.partner_not_in_fr.id)] + ) + self.assertEqual(audits.result, "not_found") + + @patch("odoo.addons.spp_dci_openg2p.services.openg2p_fr_service.OpenG2PDCIClient") + def test_clearing_vendor_falls_back_to_standard_dr_handler(self, mock_client_class): + """When vendor is cleared, the bridge's standard _handler_dr runs + — using upstream DRService. This is the migration path: clear + the vendor field on the data source and the bridge stops using + the FR-as-DR adapter, no other changes required.""" + self.data_source.vendor = False + + # Patch DRService since the standard handler would invoke it + with patch("odoo.addons.spp_dci_client_dr.services.dr_service.DCIClient") as mock_dci_client_class: + mock_dci_client = MagicMock() + mock_dci_client.search_by_id.return_value = {"message": {"search_response": []}} + mock_dci_client_class.return_value = mock_dci_client + + # Should not call OpenG2P client at all + self.env["spp.cel.dci.dispatcher"].fetch_values_for_variable( + self.variable, [self.partner_in_fr.id], "current" + ) + + mock_client_class.assert_not_called() + mock_dci_client_class.assert_called_once() 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..20806483 --- /dev/null +++ b/spp_dci_openg2p/tests/test_openg2p_dci_client.py @@ -0,0 +1,123 @@ +"""OpenG2PDCIClient request-shape regression tests. + +Locks in the two delta behaviours vs. upstream DCIClient: + - idtype-value query nests {id_type, id_value} under value + - search_criteria carries reg_record_type +""" + +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_OPENG2P_REG_RECORD_TYPE, + 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": "DR", + "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", + } + ) + + def test_parse_query_nests_id_type_and_id_value(self): + client = OpenG2PDCIClient(self.data_source, self.env) + query = client._parse_query(QueryType.IDTYPE_VALUE, "UIN:1234") + self.assertEqual( + query, + { + "type": QueryType.IDTYPE_VALUE, + "value": {"id_type": "UIN", "id_value": "1234"}, + }, + ) + + def test_parse_query_non_idtype_value_falls_through(self): + client = OpenG2PDCIClient(self.data_source, self.env) + # Predicate query string is passed through unchanged by upstream + out = client._parse_query(QueryType.PREDICATE, "some predicate") + self.assertEqual(out, "some predicate") + + def test_parse_query_invalid_idtype_value_falls_through(self): + """Malformed query_value (no colon) goes to super, preserving the + upstream ValidationError behaviour.""" + client = OpenG2PDCIClient(self.data_source, self.env) + from odoo.exceptions import ValidationError + + with self.assertRaises(ValidationError): + client._parse_query(QueryType.IDTYPE_VALUE, "no-colon-here") + + def test_search_envelope_injects_reg_record_type(self): + """Search envelope must carry reg_record_type on each + search_criteria — upstream's SearchCriteria Pydantic model omits + it, so this is a post-build injection.""" + client = OpenG2PDCIClient(self.data_source, self.env) + + envelope = client._build_search_envelope( + query_type=QueryType.IDTYPE_VALUE, + query=client._parse_query(QueryType.IDTYPE_VALUE, "UIN:9999"), + registry_type="ns:org:RegistryType:Social", + registry_event_type=None, + record_type="PERSON", + page=1, + page_size=1, + ) + + criterias = [item["search_criteria"] for item in envelope["message"]["search_request"]] + self.assertTrue(criterias) + for criteria in criterias: + self.assertEqual( + criteria.get("reg_record_type"), + DEFAULT_OPENG2P_REG_RECORD_TYPE, + ) + + def test_search_envelope_query_is_nested_shape(self): + """End-to-end: envelope.message.search_request[i].search_criteria.query + is OpenG2P's nested shape, not the upstream flat shape.""" + client = OpenG2PDCIClient(self.data_source, self.env) + envelope = client._build_search_envelope( + query_type=QueryType.IDTYPE_VALUE, + query=client._parse_query(QueryType.IDTYPE_VALUE, "UIN:7777"), + registry_type="ns:org:RegistryType:Social", + registry_event_type=None, + record_type="PERSON", + page=1, + page_size=1, + ) + query = envelope["message"]["search_request"][0]["search_criteria"]["query"] + self.assertEqual(query["type"], QueryType.IDTYPE_VALUE) + self.assertEqual(query["value"]["id_type"], "UIN") + self.assertEqual(query["value"]["id_value"], "7777") + + def test_custom_reg_record_type_via_constructor(self): + """The vendor adapter accepts a custom reg_record_type — used + when OpenG2P's real DR endpoint becomes available and we need + to point at a non-Farmer record type.""" + client = OpenG2PDCIClient( + self.data_source, + self.env, + reg_record_type="spdci-extensions-dci:PWD_Person", + ) + envelope = client._build_search_envelope( + query_type=QueryType.IDTYPE_VALUE, + query=client._parse_query(QueryType.IDTYPE_VALUE, "UIN:1"), + registry_type="ns:org:RegistryType:DR", + registry_event_type=None, + record_type="PERSON", + page=1, + page_size=1, + ) + criteria = envelope["message"]["search_request"][0]["search_criteria"] + self.assertEqual(criteria["reg_record_type"], "spdci-extensions-dci:PWD_Person") diff --git a/spp_dci_openg2p/tests/test_openg2p_fr_service.py b/spp_dci_openg2p/tests/test_openg2p_fr_service.py new file mode 100644 index 00000000..c196745a --- /dev/null +++ b/spp_dci_openg2p/tests/test_openg2p_fr_service.py @@ -0,0 +1,215 @@ +"""OpenG2PFRService FR-as-DR pretense tests. + +Locks in: + - Returns {"has_disability": True, ...} when OpenG2P returns any reg_record + - Returns None when reg_records is empty or response has no search_response + - Unwraps OpenG2P's data.reg_records[] response shape correctly + - Skips partners without a usable identifier +""" + +from unittest.mock import MagicMock, patch + +from odoo.tests.common import TransactionCase, tagged + +from odoo.addons.spp_dci_openg2p.services.openg2p_fr_service import ( + OpenG2PFRService, +) + + +def make_fr_response(reg_records): + """Shape that matches OpenG2P's actual response envelope.""" + 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": "ns:org:RegistryType:Social", + "reg_record_type": "spdci-extensions-dci:Farmer", + "reg_records": reg_records, + }, + } + ], + }, + } + + +def make_fr_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 TestOpenG2PFRService(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.data_source = cls.env["spp.dci.data.source"].create( + { + "name": "OpenG2P FR Test Source", + "code": "openg2p_fr_test", + "registry_type": "DR", + "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", + } + ) + + # ID type vocabulary code for UIN + vocab = cls.env["spp.vocabulary"].search([("namespace_uri", "=", "urn:openspp:vocab:id-type")], limit=1) + if not vocab: + vocab = cls.env["spp.vocabulary"].create( + {"name": "ID Type (FR test)", "namespace_uri": "urn:openspp:vocab:id-type"} + ) + cls.id_type_uin = cls.env["spp.vocabulary.code"].create( + { + "vocabulary_id": vocab.id, + "code": "UIN", + "display": "UIN (FR test)", + "target_type": "individual", + "is_local": True, + } + ) + + cls.partner_known = cls.env["res.partner"].create( + {"name": "Known Farmer", "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": "FR-KNOWN-1", + } + ) + + cls.partner_no_id = cls.env["res.partner"].create( + {"name": "Partner Without ID", "is_registrant": True, "is_group": False} + ) + + def test_returns_has_disability_true_when_record_found(self): + with patch.object( + OpenG2PFRService.__mro__[0], + "__init__", + lambda self, env, data_source_code: None, + ): + service = OpenG2PFRService.__new__(OpenG2PFRService) + service.env = self.env + service.data_source_code = "openg2p_fr_test" + service.data_source = self.data_source + service.client = MagicMock() + service.client.search.return_value = make_fr_response([{"farmer_id": "F-1", "name": "Known Farmer"}]) + + result = service.get_disability_status(self.partner_known) + + self.assertIsNotNone(result) + self.assertTrue(result["has_disability"]) + self.assertEqual(result["source_registry"], "OpenG2P (FR-as-DR demo)") + self.assertEqual(result["raw_data"]["farmer_id"], "F-1") + + def test_returns_none_when_no_records(self): + with patch.object( + OpenG2PFRService.__mro__[0], + "__init__", + lambda self, env, data_source_code: None, + ): + service = OpenG2PFRService.__new__(OpenG2PFRService) + service.env = self.env + service.data_source_code = "openg2p_fr_test" + service.data_source = self.data_source + service.client = MagicMock() + service.client.search.return_value = make_fr_not_found_response() + + result = service.get_disability_status(self.partner_known) + + self.assertIsNone(result) + + def test_returns_none_when_partner_has_no_identifier(self): + with patch.object( + OpenG2PFRService.__mro__[0], + "__init__", + lambda self, env, data_source_code: None, + ): + service = OpenG2PFRService.__new__(OpenG2PFRService) + service.env = self.env + service.data_source_code = "openg2p_fr_test" + service.data_source = self.data_source + service.client = MagicMock() + + result = service.get_disability_status(self.partner_no_id) + + self.assertIsNone(result) + # Service must not have called the DCI client for a partner with + # no identifier — saves an HTTP round-trip. + service.client.search.assert_not_called() + + def test_extract_first_record_handles_empty_reg_records(self): + response = make_fr_response([]) + self.assertIsNone(OpenG2PFRService._extract_first_record(response)) + + def test_extract_first_record_handles_missing_data_key(self): + response = {"message": {"search_response": [{"reference_id": "r1"}]}} + self.assertIsNone(OpenG2PFRService._extract_first_record(response)) + + def test_extract_first_record_returns_first_across_responses(self): + response = make_fr_response([]) + # Add a second search_response entry that has records + response["message"]["search_response"].append( + { + "reference_id": "r2", + "data": {"reg_records": [{"farmer_id": "F-2"}]}, + } + ) + record = OpenG2PFRService._extract_first_record(response) + self.assertEqual(record["farmer_id"], "F-2") + + def test_is_pwd_convenience(self): + with patch.object( + OpenG2PFRService.__mro__[0], + "__init__", + lambda self, env, data_source_code: None, + ): + service = OpenG2PFRService.__new__(OpenG2PFRService) + service.env = self.env + service.data_source_code = "openg2p_fr_test" + service.data_source = self.data_source + service.client = MagicMock() + service.client.search.return_value = make_fr_response([{"farmer_id": "F-1"}]) + + self.assertTrue(service.is_pwd(self.partner_known)) From 013308f2d0179b7a14ba078e4dca5f2b7caff23a Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Thu, 14 May 2026 14:41:48 +0800 Subject: [PATCH 27/62] feat(spp_dci_openg2p): seed UIN vocabulary code for the registrant form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenG2PFRService.IDENTIFIER_PRIORITY = ("UIN", "DRN", "NATIONAL_ID", "NID") expects matching vocabulary codes on the urn:openspp:vocab:id-type vocabulary so the registrant form's Identity tab can pick them as ID Type. spp_vocabulary ships only lowercase generic codes (national_id, passport, ...), so without this seed an operator could not pick UIN when adding a reg_id to a test partner — the dispatcher would fall back through the priority list and find nothing. Adds spp_dci_openg2p/data/openg2p_id_types.xml seeding `UIN` (uppercase to match SPDCI wire convention). Vocabulary is referenced via spp_vocabulary.vocab_id_type — adds spp_vocabulary to module deps. Test updates: two test classes used to create a fresh `UIN` code in setUpClass; that now collides with the preset's seed. Switched both to env.ref('spp_dci_openg2p.id_type_uin'). New tests assert the seed exists and matches the service's IDENTIFIER_PRIORITY first entry. If OpenG2P later returns records under DRN / NATIONAL_ID / NID, follow the same pattern in openg2p_id_types.xml. --- spp_dci_openg2p/__manifest__.py | 2 ++ spp_dci_openg2p/data/openg2p_id_types.xml | 30 +++++++++++++++++++ .../tests/test_dispatcher_routing.py | 22 +++----------- spp_dci_openg2p/tests/test_install.py | 25 ++++++++++++++++ .../tests/test_openg2p_fr_service.py | 19 +++--------- 5 files changed, 65 insertions(+), 33 deletions(-) create mode 100644 spp_dci_openg2p/data/openg2p_id_types.xml diff --git a/spp_dci_openg2p/__manifest__.py b/spp_dci_openg2p/__manifest__.py index 8e332b38..94f6a65c 100644 --- a/spp_dci_openg2p/__manifest__.py +++ b/spp_dci_openg2p/__manifest__.py @@ -12,9 +12,11 @@ "depends": [ "spp_cel_dci_bridge", "spp_dci_client_dr", + "spp_vocabulary", ], "external_dependencies": {"python": []}, "data": [ + "data/openg2p_id_types.xml", "data/openg2p_data_source.xml", "data/openg2p_data_provider.xml", "data/openg2p_cel_variables.xml", 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..9a93b8f4 --- /dev/null +++ b/spp_dci_openg2p/data/openg2p_id_types.xml @@ -0,0 +1,30 @@ + + + + + + UIN + UIN (Universal Identification Number) + individual + Universal Identification Number — the canonical SPDCI identifier sent to OpenG2P's DCI search endpoint as the `id_type` field. + 20 + + diff --git a/spp_dci_openg2p/tests/test_dispatcher_routing.py b/spp_dci_openg2p/tests/test_dispatcher_routing.py index d9b319c9..fd3a8842 100644 --- a/spp_dci_openg2p/tests/test_dispatcher_routing.py +++ b/spp_dci_openg2p/tests/test_dispatcher_routing.py @@ -46,24 +46,10 @@ class TestDispatcherRoutesOpenG2P(TransactionCase): def setUpClass(cls): super().setUpClass() - # Partner setup with UIN identifier (priority order picks UIN first) - vocab = cls.env["spp.vocabulary"].search([("namespace_uri", "=", "urn:openspp:vocab:id-type")], limit=1) - if not vocab: - vocab = cls.env["spp.vocabulary"].create( - { - "name": "ID Type (dispatcher test)", - "namespace_uri": "urn:openspp:vocab:id-type", - } - ) - cls.id_type_uin = cls.env["spp.vocabulary.code"].create( - { - "vocabulary_id": vocab.id, - "code": "UIN", - "display": "UIN (dispatcher test)", - "target_type": "individual", - "is_local": True, - } - ) + # 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 ("Code 'UIN' already exists in vocabulary 'ID Type'"). + cls.id_type_uin = cls.env.ref("spp_dci_openg2p.id_type_uin") cls.partner_in_fr = cls.env["res.partner"].create( {"name": "FR Partner", "is_registrant": True, "is_group": False} ) diff --git a/spp_dci_openg2p/tests/test_install.py b/spp_dci_openg2p/tests/test_install.py index 772771f7..2fb7d31b 100644 --- a/spp_dci_openg2p/tests/test_install.py +++ b/spp_dci_openg2p/tests/test_install.py @@ -9,6 +9,31 @@ class TestOpenG2PPresetInstall(TransactionCase): """Smoke test: the three preset records exist after install and are linked correctly.""" + 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 OpenG2PFRService.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_fr_service import ( + IDENTIFIER_PRIORITY, + ) + + code = self.env.ref("spp_dci_openg2p.id_type_uin") + self.assertEqual(IDENTIFIER_PRIORITY[0], code.code) + def test_data_source_present(self): source = self.env.ref("spp_dci_openg2p.openg2p_dr_source") self.assertEqual(source.code, "openg2p_dr") diff --git a/spp_dci_openg2p/tests/test_openg2p_fr_service.py b/spp_dci_openg2p/tests/test_openg2p_fr_service.py index c196745a..199986f4 100644 --- a/spp_dci_openg2p/tests/test_openg2p_fr_service.py +++ b/spp_dci_openg2p/tests/test_openg2p_fr_service.py @@ -92,21 +92,10 @@ def setUpClass(cls): } ) - # ID type vocabulary code for UIN - vocab = cls.env["spp.vocabulary"].search([("namespace_uri", "=", "urn:openspp:vocab:id-type")], limit=1) - if not vocab: - vocab = cls.env["spp.vocabulary"].create( - {"name": "ID Type (FR test)", "namespace_uri": "urn:openspp:vocab:id-type"} - ) - cls.id_type_uin = cls.env["spp.vocabulary.code"].create( - { - "vocabulary_id": vocab.id, - "code": "UIN", - "display": "UIN (FR test)", - "target_type": "individual", - "is_local": True, - } - ) + # Reuse the UIN vocab code seeded by the preset (data/openg2p_id_types.xml). + # Creating a fresh `UIN` here would hit the vocabulary code uniqueness + # constraint. + cls.id_type_uin = cls.env.ref("spp_dci_openg2p.id_type_uin") cls.partner_known = cls.env["res.partner"].create( {"name": "Known Farmer", "is_registrant": True, "is_group": False} From cc1853f80a42c20e61e19472162cbd9e6cb9162d Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Thu, 14 May 2026 15:30:10 +0800 Subject: [PATCH 28/62] fix(spp_cel_dci_bridge): pre-warm cache before program-level CEL eligibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an operator clicks "Import Eligible" or "Enroll Eligible" on a Program (not a Cycle), the eligibility manager compiles `has_disability == true` to `metric('has_disability', me) == true`. The executor finds the cache empty, falls back to a legacy Python path, and crashes: AttributeError: 'spp.indicator' object has no attribute 'evaluate' Root cause: cycle_manager_base.py already calls _precompute_cycle_cached_variables before each eligibility check. The program-level flow has no equivalent pre-fetch — the SQL fast path is never available, the broken Python fallback always fires. Fix: inherit spp.program.membership.manager.default and override _prepare_eligible_domain to call cache_mgr.precompute_cached_variables on the candidate cohort before the CEL compile runs. After pre-fetch the cache is fresh, the executor takes the SQL fast path, and the broken legacy path is never touched. Cohort definition matches the base domain that spp_programs/models/cel/eligibility_cel.py applies before the CEL filter (is_registrant=True, is_group respects target_type, disabled=False). When membership is passed (cycle / verify flows), the cohort is narrowed to those partners — same scaling profile as the cycle manager. Tests cover: no CEL expression -> no pre-warm; CEL expression with matching cohort -> pre-warm fires; empty cohort -> short-circuit; pre-warm failure -> caught and logged, eligibility evaluation continues. No sudo on the cohort search: respects operator record rules, matching cycle_manager_base behaviour. Adds spp_programs to the bridge's manifest depends (we now inherit spp.program.membership.manager.default from that module). --- spp_cel_dci_bridge/__manifest__.py | 1 + spp_cel_dci_bridge/models/__init__.py | 1 + .../models/eligibility_manager.py | 133 ++++++++++++++++++ spp_cel_dci_bridge/tests/__init__.py | 1 + .../tests/test_eligibility_prewarm.py | 123 ++++++++++++++++ 5 files changed, 259 insertions(+) create mode 100644 spp_cel_dci_bridge/models/eligibility_manager.py create mode 100644 spp_cel_dci_bridge/tests/test_eligibility_prewarm.py diff --git a/spp_cel_dci_bridge/__manifest__.py b/spp_cel_dci_bridge/__manifest__.py index 3831d953..0a183472 100644 --- a/spp_cel_dci_bridge/__manifest__.py +++ b/spp_cel_dci_bridge/__manifest__.py @@ -15,6 +15,7 @@ "spp_dci_client_dr", "spp_dci_client_crvs", "spp_dci_client_ibr", + "spp_programs", "spp_studio", ], "external_dependencies": {"python": []}, diff --git a/spp_cel_dci_bridge/models/__init__.py b/spp_cel_dci_bridge/models/__init__.py index aa90f11b..f0b656e7 100644 --- a/spp_cel_dci_bridge/models/__init__.py +++ b/spp_cel_dci_bridge/models/__init__.py @@ -3,3 +3,4 @@ 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/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/tests/__init__.py b/spp_cel_dci_bridge/tests/__init__.py index b56ff4a5..df84186b 100644 --- a/spp_cel_dci_bridge/tests/__init__.py +++ b/spp_cel_dci_bridge/tests/__init__.py @@ -6,3 +6,4 @@ 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/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]) From e822163fd870bafbc5eb7c228117e4d9a58d8570 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Thu, 14 May 2026 15:39:46 +0800 Subject: [PATCH 29/62] fix(spp_cel_domain): guard against legacy spp.indicator.evaluate missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-017's Phase 4 migration removed the .evaluate() / .enqueue_refresh_from_domain() methods from spp.indicator, but the model itself is still registered when spp_indicators is installed. The executor's _exec_metric branch detects the model via `if "spp.indicator" not in self.env` and falls through when present — then crashes calling `svc.evaluate(...)` because the method is gone. This is hit by every code path that compiles a `metric()` reference against an incomplete cache: CEL builder live preview, validation, ad-hoc compile. Guard: check hasattr(svc, "evaluate") before invoking. If absent, treat the same as the model-not-installed branch — warn, return [], let the caller continue. Preview shows "0 matching" instead of crashing; callers that need real results pre-warm the cache via spp.data.cache.manager.precompute_cached_variables (spp_programs/managers/cycle_manager_base.py and spp_cel_dci_bridge/models/eligibility_manager.py already do this). Caught by spp_cel_dci_bridge integration: pre-warm only fires from the eligibility manager flow, not the CEL widget's /spp_cel/validate live preview. The widget hits this every keystroke / Save in the Advanced Builder. --- spp_cel_domain/models/cel_executor.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/spp_cel_domain/models/cel_executor.py b/spp_cel_domain/models/cel_executor.py index 411ca204..79ab7770 100644 --- a/spp_cel_domain/models/cel_executor.py +++ b/spp_cel_domain/models/cel_executor.py @@ -1164,6 +1164,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 From beba099288d39602131082520f580278f57c19fb Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Thu, 14 May 2026 16:34:52 +0800 Subject: [PATCH 30/62] fix(spp_dci_openg2p): preset must activate the var_has_disability variable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DCI binding override on spp_studio.var_has_disability set source_type and the DCI fields, but left the variable in DRAFT state (spp_studio's default for new variables). Symptom observed in the demo: - Variable shown as source_type=field, state=Draft, Field Missing - Pre-warm iterates cached variables but the field-based has_disability quietly returns empty (the local field doesn't exist when spp_disability_registry isn't installed) - No DCI call. No audit row. No cache row. Two fixes: 1. Data XML override now sets state='active' AND active=True explicitly so first install pushes the variable through the Draft → Active transition. 2. post_init_hook tracks state + active in _EXPECTED_BINDING_FIELDS so any drift (e.g., from a later spp_studio upgrade or manual edit reverting state to draft) gets corrected on every install/upgrade of the preset. Also cleans up the hook's docstring to spell out the three reasons it exists alongside the data XML: noupdate semantics, state machine transitions, and as a safety net for failed XML loads. Tests assert state='active' and active=True after both initial install and post-reset re-assert. The drift-simulation test now also resets state='draft' to verify the hook activates it back. --- spp_dci_openg2p/__init__.py | 95 ++++++++++++++----- .../data/openg2p_cel_variables.xml | 15 ++- spp_dci_openg2p/tests/test_install.py | 15 ++- 3 files changed, 99 insertions(+), 26 deletions(-) diff --git a/spp_dci_openg2p/__init__.py b/spp_dci_openg2p/__init__.py index 62cee4c5..674e6b7f 100644 --- a/spp_dci_openg2p/__init__.py +++ b/spp_dci_openg2p/__init__.py @@ -6,34 +6,62 @@ _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. - The preset overrides spp_studio.var_has_disability so the semantic - `has_disability` CEL accessor sources from OpenG2P over DCI instead - of the local res.partner field. The override is declared in - data/openg2p_cel_variables.xml with `noupdate="1"`, but that only - protects against re-applying THIS module's data file. It does NOT - protect against a future `-u spp_studio`, which would reset the - variable back to source_type='field' from spp_studio's original - standard_variables.xml. - - This hook re-asserts the DCI binding after every install/upgrade so - the demo deployment stays correctly wired. The Odoo upgrade ordering - guarantees this module's post_init_hook fires AFTER spp_studio's - data files have been loaded, so any silent reset is undone here. + 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 OpenG2P. + + 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." + "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_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 DCI binding on has_disability variable." + "spp_dci_openg2p.openg2p_dr_provider not found; cannot re-assert " + "DCI binding on has_disability variable. Verify " + "data/openg2p_data_provider.xml loaded successfully." ) return @@ -45,19 +73,42 @@ def post_init_hook(env): "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 (Draft / Active / + # Inactive) used by spp_studio's lifecycle and CEL symbol + # visibility + # - active=True is the standard Odoo archived/unarchived flag + # used by precompute_cached_variables' search domain + "state": "active", + "active": True, } - drift = { - field: value - for field, value in expected.items() - if (variable[field].id if hasattr(variable[field], "id") else variable[field]) != value - } + 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 spp_studio.var_has_disability: %d field(s) restored (%s)", + "Re-asserted DCI binding on spp_studio.var_has_disability: " + "%d field(s) restored (%s)", len(drift), ", ".join(drift.keys()), ) else: - _logger.debug("spp_studio.var_has_disability DCI binding already correct; no changes.") + _logger.info( + "spp_studio.var_has_disability DCI binding already correct; " + "no changes." + ) diff --git a/spp_dci_openg2p/data/openg2p_cel_variables.xml b/spp_dci_openg2p/data/openg2p_cel_variables.xml index 5a80ac46..44101682 100644 --- a/spp_dci_openg2p/data/openg2p_cel_variables.xml +++ b/spp_dci_openg2p/data/openg2p_cel_variables.xml @@ -17,12 +17,21 @@ The xml id is intentionally spp_studio.var_has_disability so Odoo treats this as an in-place update of the existing record. + State + active are set explicitly so the variable goes from DRAFT + (spp_studio default) straight to ACTIVE on install — required for + the variable to participate in the CEL resolver and cache pre-warm. + cache_ttl_seconds=300 (5 min) is tuned for the SPDCI demo so the - DCI round-trip is visible. Production deployments may raise this. + DCI round-trip is visible to viewers. Production deployments may + raise this. external_failure_policy=null: if OpenG2P is unreachable, the subject - evaluates against null (fails `has_disability == true`). + evaluates against null (does not match `has_disability == true`). Compliance-critical rules can override to 'fail' on the variable. + + post_init_hook (spp_dci_openg2p/__init__.py) re-asserts these + fields on every install/upgrade so drift from manual edits or + cross-module data loads gets corrected automatically. --> external @@ -33,5 +42,7 @@ ttl 300 null + active + diff --git a/spp_dci_openg2p/tests/test_install.py b/spp_dci_openg2p/tests/test_install.py index 2fb7d31b..44a2f905 100644 --- a/spp_dci_openg2p/tests/test_install.py +++ b/spp_dci_openg2p/tests/test_install.py @@ -66,6 +66,10 @@ def test_cel_variable_rewired_to_dci_provider(self): # Local field source is cleared so the resolver does not also # try to expand to r.is_person_with_disability. self.assertFalse(variable.source_field) + # Variable must be activated so it participates in the resolver + # and the cache pre-warm. + 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. @@ -86,7 +90,8 @@ def test_post_init_hook_re_asserts_after_studio_reset(self): variable = self.env.ref("spp_studio.var_has_disability") provider = self.env.ref("spp_dci_openg2p.openg2p_dr_provider") - # Simulate spp_studio re-applying its standard_variables.xml + # Simulate spp_studio re-applying its standard_variables.xml: the + # variable ships as Draft, source_type=field, no provider. variable.write( { "source_type": "field", @@ -96,22 +101,28 @@ def test_post_init_hook_re_asserts_after_studio_reset(self): "dci_attribute_path": False, "cache_strategy": "none", "external_failure_policy": "null", + "state": "draft", } ) # Confirm the reset took effect self.assertEqual(variable.source_type, "field") self.assertFalse(variable.external_provider_id) + self.assertEqual(variable.state, "draft") # Run the hook post_init_hook(self.env) - # Verify the DCI binding was re-asserted + # Verify the DCI binding was re-asserted AND the variable was + # activated. Without activation it's invisible to the resolver + # and skipped by precompute. 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.cache_strategy, "ttl") + self.assertEqual(variable.state, "active") + self.assertTrue(variable.active) self.assertEqual(variable.cache_ttl_seconds, 300) def test_post_init_hook_handles_missing_variable_gracefully(self): From 8e1c80ac3c65e5261eab6c73fc0f5908dd45c0dd Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Thu, 14 May 2026 21:41:59 +0800 Subject: [PATCH 31/62] refactor(spp_dci_openg2p): retarget preset to OpenG2P Social Registry role Replaces the FR-as-DR pretense with OpenG2P playing its proper Social Registry role (ADR-024 federated demo topology). Disability data moves to a separate OpenSPP-DR instance in subsequent phases. Wire-format changes (per OpenG2P-provided sample): - query_type = expression with namespaced URI ns:org:QueryType:expression - query.value = nested {expression:{query:{search_text:{$eq:}}}} shape - reg_type and reg_record_type both literal "Individual" - consent and authorize blocks attached to every search_criteria Routing changes: - Data source registry_type DR -> SR - Dispatcher override moves from _handler_dr to _handler_sr - Bridge's _handler_sr stub still raises DCIConfigurationError when vendor is cleared, preserving the silent-failure guard Service changes: - OpenG2PFRService replaced by OpenG2PSocialService - New surface: get_partner_record(partner) -> dict | None (raw reg_record) - No vendor-specific synthesis; bridge dispatcher extracts via variable.dci_attribute_path Manifest no longer depends on spp_dci_client_dr (SR path doesn't need it). --- spp_dci_openg2p/__manifest__.py | 1 - spp_dci_openg2p/data/openg2p_data_source.xml | 24 +- spp_dci_openg2p/data/openg2p_id_types.xml | 15 +- spp_dci_openg2p/models/dci_dispatcher.py | 52 ++--- spp_dci_openg2p/readme/CONFIGURE.md | 47 ++-- spp_dci_openg2p/readme/DESCRIPTION.md | 19 +- spp_dci_openg2p/services/__init__.py | 2 +- .../services/openg2p_dci_client.py | 163 +++++++++++--- .../services/openg2p_fr_service.py | 178 --------------- .../services/openg2p_social_service.py | 163 ++++++++++++++ spp_dci_openg2p/tests/__init__.py | 4 +- .../tests/test_dispatcher_routing.py | 109 ++++----- spp_dci_openg2p/tests/test_install.py | 9 +- .../tests/test_openg2p_dci_client.py | 173 +++++++++----- .../tests/test_openg2p_fr_service.py | 204 ----------------- .../tests/test_openg2p_social_service.py | 213 ++++++++++++++++++ 16 files changed, 759 insertions(+), 617 deletions(-) delete mode 100644 spp_dci_openg2p/services/openg2p_fr_service.py create mode 100644 spp_dci_openg2p/services/openg2p_social_service.py delete mode 100644 spp_dci_openg2p/tests/test_openg2p_fr_service.py create mode 100644 spp_dci_openg2p/tests/test_openg2p_social_service.py diff --git a/spp_dci_openg2p/__manifest__.py b/spp_dci_openg2p/__manifest__.py index 94f6a65c..30e77c25 100644 --- a/spp_dci_openg2p/__manifest__.py +++ b/spp_dci_openg2p/__manifest__.py @@ -11,7 +11,6 @@ "maintainers": ["jeremi", "gonzalesedwin1123"], "depends": [ "spp_cel_dci_bridge", - "spp_dci_client_dr", "spp_vocabulary", ], "external_dependencies": {"python": []}, diff --git a/spp_dci_openg2p/data/openg2p_data_source.xml b/spp_dci_openg2p/data/openg2p_data_source.xml index 3862e4b2..109b0cf5 100644 --- a/spp_dci_openg2p/data/openg2p_data_source.xml +++ b/spp_dci_openg2p/data/openg2p_data_source.xml @@ -7,21 +7,23 @@ Replace base_url and OAuth credentials with the actual OpenG2P deployment before running against real data. - registry_type='DR' so the bridge dispatcher routes through _handler_dr. + registry_type='SR' so the bridge dispatcher routes through _handler_sr. vendor='openg2p' so the preset's dispatcher override picks the - OpenG2P-specific service (handles idtype-value query shape and - data.reg_records[] response wrapper). When OpenG2P's real DR endpoint - becomes available and conforms to the standard DCI shape, clear the - vendor field to fall back to the upstream DRService. + OpenG2P-specific service (expression query shape with namespaced + URI query type, consent + authorize blocks, data.reg_records[] + response wrapper). When OpenG2P's request shape converges on the + upstream DCI defaults, clear the vendor field to fall back to a + future standard SR handler. - FR-AS-DR PRETENSE: until OpenG2P's Disability Registry endpoint is - published, this preset uses the Farmer Registry as a DR stand-in. - See services/openg2p_fr_service.py for the migration plan. + Disability data lives in a separate OpenSPP-DR instance, not in + OpenG2P — see ADR-024. This source is OpenG2P's Social Registry + only; the preset's role here is to feed SR-shaped attributes + (e.g., is_poor, has_dependent_under_school_age) into CEL. --> - OpenG2P Disability Registry + OpenG2P Social Registry openg2p_dr - DR + SR openg2p https://partner-registry.play.openg2p.org /dci/registry/sync/search @@ -43,6 +45,6 @@ draft OpenG2P playground preset. Uses the Farmer Registry endpoint as a DR stand-in until OpenG2P publishes a real Disability Registry. See spp_dci_openg2p/readme/CONFIGURE.md for the migration plan. + >OpenG2P playground preset (Social Registry role). 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 index 9a93b8f4..43b6025a 100644 --- a/spp_dci_openg2p/data/openg2p_id_types.xml +++ b/spp_dci_openg2p/data/openg2p_id_types.xml @@ -7,15 +7,16 @@ `urn:openspp:vocab:id-type` vocabulary (defined in spp_vocabulary's vocabulary_id_type.xml). The codes there use lowercase (`national_id`, `passport`), but the DCI/SPDCI convention is UPPERCASE for the wire - `id_type` field (UIN, BRN, DRN, NATIONAL_ID). OpenG2PFRService sends - the vocab code verbatim to OpenG2P, so the vocab code must match what - OpenG2P expects to see in the search request. + identifier values. OpenG2PSocialService selects the partner's first + identifier value matching IDENTIFIER_PRIORITY and sends it as + OpenG2P's `search_text` field, so the vocab code must match the + types we expect to find on registrants. We only seed UIN here — the most common identifier across DCI - deployments. If OpenG2P returns records under a different id_type - (DRN, NATIONAL_ID, NID), add codes here following the same pattern. - OpenG2PFRService's IDENTIFIER_PRIORITY = ("UIN", "DRN", "NATIONAL_ID", - "NID") picks them up in that order. + deployments. If your partners use a different id type (DRN, + NATIONAL_ID, NID), add codes here following the same pattern. + OpenG2PSocialService's IDENTIFIER_PRIORITY = + ("UIN", "DRN", "NATIONAL_ID", "NID") picks them up in that order. --> diff --git a/spp_dci_openg2p/models/dci_dispatcher.py b/spp_dci_openg2p/models/dci_dispatcher.py index f2a4b73e..1f5476b5 100644 --- a/spp_dci_openg2p/models/dci_dispatcher.py +++ b/spp_dci_openg2p/models/dci_dispatcher.py @@ -1,15 +1,14 @@ """Bridge dispatcher override for OpenG2P-vendor sources. When a CEL variable's DCI data source has ``vendor='openg2p'``, route the -DR handler to ``OpenG2PFRService`` instead of the upstream ``DRService``. -The handler is otherwise structurally identical to the bridge's -``_handler_dr``: same per-subject loop, same audit row shape, same -attribute-path extraction. - -This is the Option C "adapter code" path from ADR-023 §6. Until OpenG2P's -real Disability Registry endpoint is available, the demo deployment uses -the Farmer Registry as a DR stand-in (FR-as-DR pretense). See -``services/openg2p_fr_service.py`` for the migration plan. +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 @@ -25,35 +24,36 @@ class DCIDispatcher(models.AbstractModel): _inherit = "spp.cel.dci.dispatcher" - def _handler_dr(self, variable, source, subject_ids, period_key): + 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 upstream DR handler. + # the bridge's not-implemented stub, which raises + # DCIConfigurationError — preserving the silent-failure guard. if getattr(source, "vendor", False) == "openg2p": - return self._handler_openg2p_fr(variable, source, subject_ids, period_key) - return super()._handler_dr(variable, source, subject_ids, period_key) + return self._handler_openg2p_sr(variable, source, subject_ids, period_key) + return super()._handler_sr(variable, source, subject_ids, period_key) - def _handler_openg2p_fr(self, variable, source, subject_ids, period_key): - """Mirror of _handler_dr but using OpenG2PFRService. + def _handler_openg2p_sr(self, variable, source, subject_ids, period_key): + """SR handler backed by OpenG2PSocialService. - Kept structurally identical to the upstream DR handler so the - bridge's per-subject loop semantics (audit row shape, attribute - extraction, error swallow) match exactly. This lets the upstream - handler's tests stand in as parity checks until OpenG2P provides - a real DR endpoint and this override becomes unnecessary. + 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_fr_service import OpenG2PFRService + 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 Critical #2's silent failure). + # returning {} (would trigger ADR-023 Critical #2's silent + # failure mode). raise DCIConfigurationError( - f"OpenG2P FR service is not importable; cannot fetch " + f"OpenG2P Social service is not importable; cannot fetch " f"variable {variable.name}. Reinstall spp_dci_openg2p." ) from e - service = OpenG2PFRService(self.env, data_source_code=source.code) + 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 @@ -62,11 +62,11 @@ def _handler_openg2p_fr(self, variable, source, subject_ids, period_key): for partner in partners: started = time.monotonic() try: - payload = service.get_disability_status(partner) + 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 FR fetch failed for partner %d (var=%s): %s", + "OpenG2P SR fetch failed for partner %d (var=%s): %s", partner.id, variable.name, e, diff --git a/spp_dci_openg2p/readme/CONFIGURE.md b/spp_dci_openg2p/readme/CONFIGURE.md index ae74baf6..7d24f767 100644 --- a/spp_dci_openg2p/readme/CONFIGURE.md +++ b/spp_dci_openg2p/readme/CONFIGURE.md @@ -3,7 +3,7 @@ The preset auto-creates a DCI data source, CEL provider, and CEL variable wired against the OpenG2P playground at `partner-registry.play.openg2p.org`. 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. +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). 3. Verify (or adjust) **Base URL** — defaults to `https://partner-registry.play.openg2p.org`. 4. The **Search Endpoint** is set to `/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. @@ -11,55 +11,42 @@ The preset auto-creates a DCI data source, CEL provider, and CEL variable wired 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. -### FR-as-DR pretense (demo-only) +### OpenG2P plays the Social Registry role -The OpenG2P playground exposes a **Farmer Registry** (FR), not a Disability Registry (DR). Per the SPDCI schema: +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). -``` -reg_type: ns:org:RegistryType:Social -reg_record_type: spdci-extensions-dci:Farmer -``` - -Until OpenG2P publishes a real DR endpoint, this preset treats FR as a DR stand-in: +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: -- The data source is configured with `registry_type='DR'` so the bridge dispatcher routes to the standard `_handler_dr`. -- `vendor='openg2p'` on the data source triggers the preset's dispatcher override, which uses `OpenG2PFRService` instead of upstream `DRService`. -- `OpenG2PFRService` queries OpenG2P's Farmer Registry. **Presence of any farmer record for a partner → `has_disability=True`**. Absence (or `REG-ERR-001 REGISTER_NOT_FOUND`) → null → fails the eligibility filter. -- The CEL surface stays exactly `has_disability == true`. Only this service's interpretation is the pretense. +- `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`) -Audience-facing this looks like a real DR lookup. Operationally it tests the full DCI round-trip with OpenG2P's actual playground. +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 identifiers that exist in their Farmer Registry. Configure your test partners with those identifiers (under their **External Identifiers** / `reg_ids`), and the dispatcher's `OpenG2PFRService._get_partner_identifier` priority order will pick them up: +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 `has_disability == true` matches. - -### Migration plan — when OpenG2P publishes a real Disability Registry - -The migration is purely configuration; no code or data changes: +Partners with no matching identifier are recorded in `spp.dci.fetch.audit` as `result='not_found'` and excluded from CEL evaluation. -| Step | What to change | -|---|---| -| 1. Point at the new URL | Edit `base_url` on `openg2p_dr` data source (UI) | -| 2. Switch from FR pretense to real DR | Clear the `vendor` field on the data source (set blank). The dispatcher's override falls through to the standard `_handler_dr` → upstream `DRService`. | -| 3. Verify OpenG2P's DR conforms to standard DCI shapes | Run a search; if you get `rjct.search_criteria.invalid: query.value.id_type is required` or response unwrap fails, OpenG2P's DR has the same query/response quirks as their FR. Keep `vendor='openg2p'` set and extend `OpenG2PFRService` to query the DR `reg_record_type`. Track this in ADR-023 v2 work. | -| 4. The CEL accessor stays `has_disability` | No CEL rule changes. Cached values will become real `has_disability` booleans from the DR record. | +### When OpenG2P's request shape converges on standard DCI -In words: clear one field on the data source, and OpenSPP starts reading real disability data from OpenG2P with no other edits anywhere. +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). ### Cache TTL The preset ships with `cache_ttl_seconds = 300` (5 minutes) on the `has_disability` variable so the DCI round-trip is visible during demos. For production, raise to 86400 (24h) or higher via the `spp_studio.var_has_disability` form. -### Switching to a different DCI Disability Registry vendor +### Switching to a different SR vendor -If you target a non-OpenG2P registry, the preset is the wrong starting point — clone it as `spp_dci_` and adjust: +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 `OpenG2PFRService` for that vendor's quirks) +- 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 index 7593160e..6828d20f 100644 --- a/spp_dci_openg2p/readme/DESCRIPTION.md +++ b/spp_dci_openg2p/readme/DESCRIPTION.md @@ -1,38 +1,39 @@ -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 Disability Registry gets the wiring out of the box. Config-only in v1 — zero Python code. +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=DR | +| `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_studio.var_has_disability` (override) | The semantic `has_disability` CEL accessor, repointed at the DCI provider | +| `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]` | -The CEL accessor name stays vendor-neutral (`has_disability`, per ADR-023 §1a). The OpenG2P-ness lives only in the data source and provider records. Repointing at a different DCI Disability Registry is a configuration change on the data source, never a CEL change. +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 `has_disability` CEL accessor) -- Python code (any OpenG2P-specific behavioural quirk that emerges in the future would be added here as adapter code; v1 stays pure config) +- 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 registry-type DCI client (`spp_dci_client_dr`), not a DCI client itself: +`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_dr (DCI client for the Disability Registry type) - depends on spp_dci_client (base DCI client) ``` -Other DCI Disability Registries (e.g., a national DR) would ship as separate sibling preset modules (`spp_dci_`), reusing `spp_cel_dci_bridge` and `spp_dci_client_dr`. +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/services/__init__.py b/spp_dci_openg2p/services/__init__.py index ee635789..7197cfe3 100644 --- a/spp_dci_openg2p/services/__init__.py +++ b/spp_dci_openg2p/services/__init__.py @@ -1,2 +1,2 @@ from . import openg2p_dci_client -from . import openg2p_fr_service +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 index 1a91e1c6..3d6b8379 100644 --- a/spp_dci_openg2p/services/openg2p_dci_client.py +++ b/spp_dci_openg2p/services/openg2p_dci_client.py @@ -1,60 +1,112 @@ """OpenG2P-aware DCIClient subclass. -The DCI spec leaves two shapes ambiguous; OpenG2P picked different -interpretations than `spp_dci_client`. This subclass overrides only the -delta: - -1. `query` payload for `idtype-value` searches - ------------------------------------------------------------------ - Upstream emits: "query": {"type": "", "value": ""} - OpenG2P expects: "query": {"type": "idtype-value", - "value": {"id_type": "", - "id_value": ""}} - Verified live: the upstream shape returns - `rjct.search_criteria.invalid` ("query.value.id_type is required"). - -2. `reg_record_type` in search_criteria - ------------------------------------------------------------------ - OpenG2P's DciSearchCriteria schema requires `reg_record_type` - (e.g., "spdci-extensions-dci:Farmer"). Upstream's `SearchCriteria` - Pydantic model doesn't carry that field, so it never reaches the wire. - The subclass injects it into the dumped envelope before signing. - -Everything else (envelope, signing, OAuth2, retries, async) reuses -upstream code unchanged. +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__) -DEFAULT_OPENG2P_REG_RECORD_TYPE = "spdci-extensions-dci:Farmer" +# 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_record_type=None): + 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): - if query_type == QueryType.IDTYPE_VALUE: - if ":" not in query_value: - return super()._parse_query(query_type, query_value) - id_type, id_value = query_value.split(":", 1) + """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": QueryType.IDTYPE_VALUE, + "type": OPENG2P_QUERY_TYPE_URI, "value": { - "id_type": id_type.strip(), - "id_value": id_value.strip(), + "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, @@ -69,20 +121,59 @@ def _build_search_envelope( envelope = super()._build_search_envelope( query_type=query_type, query=query, - registry_type=registry_type, + # 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, ) - # Upstream's SearchCriteria Pydantic model omits reg_record_type; - # inject it directly into the dumped message. This must happen - # BEFORE re-signing because the signature covers header+message. 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 - # Re-sign with the modified message so the signature is consistent. + # 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_fr_service.py b/spp_dci_openg2p/services/openg2p_fr_service.py deleted file mode 100644 index 19c0b35f..00000000 --- a/spp_dci_openg2p/services/openg2p_fr_service.py +++ /dev/null @@ -1,178 +0,0 @@ -"""OpenG2P FR-as-DR facade for the SPDCI demo. - -The OpenG2P playground at https://partner-registry.play.openg2p.org/ exposes -a Farmer/Partner Registry (reg_type ``ns:org:RegistryType:Social``, -reg_record_type ``spdci-extensions-dci:Farmer``), not a Disability Registry. - -For the demo we pretend FR is DR: querying OpenG2P for a partner's -existence in its farmer registry yields a synthetic ``has_disability`` -value. The semantic is "is registered in OpenG2P" → True. The CEL surface -stays ``has_disability == true``; only this service's interpretation is -the FR-as-DR pretense. - -When OpenG2P's real Disability Registry endpoint becomes available, the -migration is purely configuration: - - 1. On the OpenG2P data source, set ``vendor = False`` (clear the - OpenG2P-specific routing) and set ``base_url`` to the new DR URL. - 2. The bridge dispatcher's standard ``_handler_dr`` then uses - ``spp_dci_client_dr.DRService``, which reads ``has_disability`` from - the real DR record. - -If the real DR endpoint preserves OpenG2P's ``idtype-value`` query quirk -and ``data.reg_records[]`` response wrapper, leave ``vendor='openg2p'`` -set and extend this service to query the DR reg_record_type instead of -``Farmer``. Add a v2 selection option to the ``vendor`` field then -(``openg2p_dr``) so the dispatcher routes to a DR-specific facade. -""" - -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__) - -# OpenG2P playground reg_type / reg_record_type per the OpenAPI schema's -# DciSearchResultData example. Verified live: the server accepts these. -OPENG2P_FR_REG_TYPE = "ns:org:RegistryType:Social" -OPENG2P_FR_REG_RECORD_TYPE = "spdci-extensions-dci:Farmer" - -# Identifier priority — same shape as DRService._get_partner_identifier -# so the migration to the real DR service is behaviour-preserving. -IDENTIFIER_PRIORITY = ("UIN", "DRN", "NATIONAL_ID", "NID") - - -class OpenG2PFRService: - """DR-shaped facade over OpenG2P's Farmer Registry. - - Mirrors the subset of ``DRService`` that the bridge dispatcher's - ``_handler_dr`` calls: ``__init__(env, data_source_code)`` and - ``get_disability_status(partner)``. The dispatcher does not depend on - any DR-specific helpers, so this class can stand in for DRService - when the data source's ``vendor`` is set to ``openg2p``. - """ - - 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) - # registry_type is still "DR" on the OpenG2P preset's data source - # (so the bridge dispatcher routes here through _handler_dr). We - # do NOT validate it here — the dispatcher's vendor check is - # already authoritative. - self.client = OpenG2PDCIClient( - self.data_source, - env, - reg_record_type=OPENG2P_FR_REG_RECORD_TYPE, - ) - - # ------------------------------------------------------------------ - # Public API — matches DRService surface used by the bridge dispatcher - # ------------------------------------------------------------------ - - def get_disability_status(self, partner) -> dict | None: - """Return a DR-shaped dict for ``partner`` based on FR query result. - - Returns: - dict: ``{"has_disability": True, ...}`` if the partner is found - in OpenG2P's farmer registry (FR-as-DR pretense). - None: if the partner has no resolvable identifier OR OpenG2P - returned no record. - - Raises: - UserError: If the request fails for non-not-found reasons - (network error, bad config). Per-subject errors are caught - by the dispatcher loop and surfaced as audit rows. - """ - if not partner: - raise ValidationError("Partner is required") - - identifier = self._get_partner_identifier(partner) - if not identifier: - _logger.warning("No suitable identifier found for partner ID=%s", partner.id) - return None - - identifier_type, identifier_value = identifier - _logger.info( - "Querying OpenG2P FR for partner ID=%s using %s:%s", - partner.id, - identifier_type, - identifier_value, - ) - - try: - response = self.client.search( - query_type=QueryType.IDTYPE_VALUE, - query_value=f"{identifier_type}:{identifier_value}", - registry_type=OPENG2P_FR_REG_TYPE, - record_type=OPENG2P_FR_REG_RECORD_TYPE, - page=1, - page_size=1, - ) - except Exception as e: - _logger.error("OpenG2P FR fetch failed: %s", e, exc_info=True) - raise UserError(f"Failed to query OpenG2P: {e}") from e - - record = self._extract_first_record(response) - if record is None: - return None - - # FR-as-DR pretense: presence of a farmer record => has_disability=True - return { - "has_disability": True, - "source_registry": "OpenG2P (FR-as-DR demo)", - "raw_data": record, - } - - def is_pwd(self, partner) -> bool: - """Boolean convenience matching DRService.is_pwd shape.""" - result = self.get_disability_status(partner) - return bool(result and result.get("has_disability")) - - # ------------------------------------------------------------------ - # Helpers - # ------------------------------------------------------------------ - - def _get_partner_identifier(self, partner): - """Return (id_type_code, id_value) for the partner. Priority order - matches DRService so swapping back to the real DRService preserves - which identifier is tried 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.id_type_id.code, reg_id.value) - if reg_ids: - first_id = reg_ids[0] - if first_id.id_type_id.code and first_id.value: - return (first_id.id_type_id.code, 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] - - The first matching record across the response is returned, 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/services/openg2p_social_service.py b/spp_dci_openg2p/services/openg2p_social_service.py new file mode 100644 index 00000000..ccf307d6 --- /dev/null +++ b/spp_dci_openg2p/services/openg2p_social_service.py @@ -0,0 +1,163 @@ +"""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.addons.spp_dci.schemas import QueryType +from odoo.exceptions import UserError, ValidationError + +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("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(f"Failed to query OpenG2P: {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/tests/__init__.py b/spp_dci_openg2p/tests/__init__.py index 9ff26780..ea1bd6c5 100644 --- a/spp_dci_openg2p/tests/__init__.py +++ b/spp_dci_openg2p/tests/__init__.py @@ -1,4 +1,4 @@ +from . import test_dispatcher_routing from . import test_install from . import test_openg2p_dci_client -from . import test_openg2p_fr_service -from . import test_dispatcher_routing +from . import test_openg2p_social_service diff --git a/spp_dci_openg2p/tests/test_dispatcher_routing.py b/spp_dci_openg2p/tests/test_dispatcher_routing.py index fd3a8842..5be46934 100644 --- a/spp_dci_openg2p/tests/test_dispatcher_routing.py +++ b/spp_dci_openg2p/tests/test_dispatcher_routing.py @@ -1,23 +1,27 @@ -"""End-to-end test: bridge dispatcher routes vendor=openg2p sources to -the OpenG2P FR service, and the result populates spp.data.value such -that a CEL eligibility filter matches the right partners. +"""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_fr_response_for_uin(uin_to_records): - """Build a stateful client.search mock: response depends on the UIN - inside the search envelope, so we can vary by partner. + +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): - # The OpenG2P client's search puts query_value as "TYPE:VALUE" - qv = kwargs.get("query_value", "") - _, _, value = qv.partition(":") - records = uin_to_records.get(value, []) + # 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 { @@ -28,8 +32,8 @@ def _search(**kwargs): "timestamp": "2026-05-14T00:00:00Z", "status": "succ", "data": { - "reg_type": "ns:org:RegistryType:Social", - "reg_record_type": "spdci-extensions-dci:Farmer", + "reg_type": "Individual", + "reg_record_type": "Individual", "reg_records": records, }, } @@ -46,28 +50,28 @@ class TestDispatcherRoutesOpenG2P(TransactionCase): 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 ("Code 'UIN' already exists in vocabulary 'ID Type'"). + # 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_fr = cls.env["res.partner"].create( - {"name": "FR Partner", "is_registrant": True, "is_group": False} + 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_fr.id, + "partner_id": cls.partner_in_sr.id, "id_type_id": cls.id_type_uin.id, - "value": "UIN-FR-1", + "value": "IND-NSR-0001", } ) - cls.partner_not_in_fr = cls.env["res.partner"].create( + 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_fr.id, + "partner_id": cls.partner_not_in_sr.id, "id_type_id": cls.id_type_uin.id, - "value": "UIN-UNKNOWN", + "value": "IND-UNKNOWN", } ) @@ -76,59 +80,60 @@ def setUpClass(cls): cls.data_source = cls.env.ref("spp_dci_openg2p.openg2p_dr_source") cls.variable = cls.env.ref("spp_studio.var_has_disability") - def test_data_source_has_vendor_openg2p(self): + 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_fr_service.OpenG2PDCIClient") - def test_openg2p_handler_returns_has_disability_true(self, mock_client_class): - """Partner with a farmer record returns has_disability=True (the - FR-as-DR pretense).""" + @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).""" mock_client = MagicMock() - mock_client.search.side_effect = make_fr_response_for_uin({"UIN-FR-1": [{"farmer_id": "F-1"}]}) + mock_client.search.side_effect = make_sr_response_for_search_text( + {"IND-NSR-0001": [{"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_in_fr.id], "current" + self.variable, [self.partner_in_sr.id], "current" ) - self.assertEqual(result, {self.partner_in_fr.id: True}) + self.assertEqual(result, {self.partner_in_sr.id: True}) - @patch("odoo.addons.spp_dci_openg2p.services.openg2p_fr_service.OpenG2PDCIClient") + @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.""" + """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_fr_response_for_uin({}) + 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_fr.id], "current" + self.variable, [self.partner_not_in_sr.id], "current" ) self.assertEqual(result, {}) audits = self.env["spp.dci.fetch.audit"].search( - [("variable_name", "=", "has_disability"), ("subject_id", "=", self.partner_not_in_fr.id)] + [("variable_name", "=", "has_disability"), ("subject_id", "=", self.partner_not_in_sr.id)] ) self.assertEqual(audits.result, "not_found") - @patch("odoo.addons.spp_dci_openg2p.services.openg2p_fr_service.OpenG2PDCIClient") - def test_clearing_vendor_falls_back_to_standard_dr_handler(self, mock_client_class): - """When vendor is cleared, the bridge's standard _handler_dr runs - — using upstream DRService. This is the migration path: clear - the vendor field on the data source and the bridge stops using - the FR-as-DR adapter, no other changes required.""" + @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 - # Patch DRService since the standard handler would invoke it - with patch("odoo.addons.spp_dci_client_dr.services.dr_service.DCIClient") as mock_dci_client_class: - mock_dci_client = MagicMock() - mock_dci_client.search_by_id.return_value = {"message": {"search_response": []}} - mock_dci_client_class.return_value = mock_dci_client - - # Should not call OpenG2P client at all + with self.assertRaises(DCIConfigurationError): self.env["spp.cel.dci.dispatcher"].fetch_values_for_variable( - self.variable, [self.partner_in_fr.id], "current" + self.variable, [self.partner_in_sr.id], "current" ) - mock_client_class.assert_not_called() - mock_dci_client_class.assert_called_once() + mock_client_class.assert_not_called() diff --git a/spp_dci_openg2p/tests/test_install.py b/spp_dci_openg2p/tests/test_install.py index 44a2f905..e14aa9b0 100644 --- a/spp_dci_openg2p/tests/test_install.py +++ b/spp_dci_openg2p/tests/test_install.py @@ -13,7 +13,7 @@ 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 OpenG2PFRService.IDENTIFIER_PRIORITY. + convention and the first entry in OpenG2PSocialService.IDENTIFIER_PRIORITY. """ code = self.env.ref("spp_dci_openg2p.id_type_uin") self.assertEqual(code.code, "UIN") @@ -27,7 +27,7 @@ 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_fr_service import ( + from odoo.addons.spp_dci_openg2p.services.openg2p_social_service import ( IDENTIFIER_PRIORITY, ) @@ -37,7 +37,10 @@ def test_uin_code_matches_service_priority_first(self): def test_data_source_present(self): source = self.env.ref("spp_dci_openg2p.openg2p_dr_source") self.assertEqual(source.code, "openg2p_dr") - self.assertEqual(source.registry_type, "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) diff --git a/spp_dci_openg2p/tests/test_openg2p_dci_client.py b/spp_dci_openg2p/tests/test_openg2p_dci_client.py index 20806483..27716856 100644 --- a/spp_dci_openg2p/tests/test_openg2p_dci_client.py +++ b/spp_dci_openg2p/tests/test_openg2p_dci_client.py @@ -1,15 +1,23 @@ """OpenG2PDCIClient request-shape regression tests. -Locks in the two delta behaviours vs. upstream DCIClient: - - idtype-value query nests {id_type, id_value} under value - - search_criteria carries reg_record_type +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, ) @@ -23,7 +31,7 @@ def setUpClass(cls): { "name": "OpenG2P Test Source", "code": "openg2p_test", - "registry_type": "DR", + "registry_type": "SR", "vendor": "openg2p", "base_url": "https://partner-registry.play.openg2p.org", "search_endpoint": "/dci/registry/sync/search", @@ -33,91 +41,142 @@ def setUpClass(cls): } ) - def test_parse_query_nests_id_type_and_id_value(self): + # ------------------------------------------------------------------ + # _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.IDTYPE_VALUE, "UIN:1234") + query = client._parse_query(QueryType.EXPRESSION, "IND-NSR-0001") self.assertEqual( query, { - "type": QueryType.IDTYPE_VALUE, - "value": {"id_type": "UIN", "id_value": "1234"}, + "type": OPENG2P_QUERY_TYPE_URI, + "value": { + "expression": { + "query": { + "search_text": {"$eq": "IND-NSR-0001"}, + }, + }, + }, }, ) - def test_parse_query_non_idtype_value_falls_through(self): + 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) - # Predicate query string is passed through unchanged by upstream out = client._parse_query(QueryType.PREDICATE, "some predicate") self.assertEqual(out, "some predicate") - def test_parse_query_invalid_idtype_value_falls_through(self): - """Malformed query_value (no colon) goes to super, preserving the - upstream ValidationError behaviour.""" - client = OpenG2PDCIClient(self.data_source, self.env) - from odoo.exceptions import ValidationError - - with self.assertRaises(ValidationError): - client._parse_query(QueryType.IDTYPE_VALUE, "no-colon-here") + # ------------------------------------------------------------------ + # _build_search_envelope: reg_type forced, reg_record_type injected, + # consent + authorize attached + # ------------------------------------------------------------------ - def test_search_envelope_injects_reg_record_type(self): - """Search envelope must carry reg_record_type on each - search_criteria — upstream's SearchCriteria Pydantic model omits - it, so this is a post-build injection.""" - client = OpenG2PDCIClient(self.data_source, self.env) - - envelope = client._build_search_envelope( - query_type=QueryType.IDTYPE_VALUE, - query=client._parse_query(QueryType.IDTYPE_VALUE, "UIN:9999"), + 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_nested_shape(self): + def test_search_envelope_query_is_namespaced_expression_shape(self): """End-to-end: envelope.message.search_request[i].search_criteria.query - is OpenG2P's nested shape, not the upstream flat shape.""" + carries the namespaced URI type and the nested search_text body.""" client = OpenG2PDCIClient(self.data_source, self.env) - envelope = client._build_search_envelope( - query_type=QueryType.IDTYPE_VALUE, - query=client._parse_query(QueryType.IDTYPE_VALUE, "UIN:7777"), - registry_type="ns:org:RegistryType:Social", - registry_event_type=None, - record_type="PERSON", - page=1, - page_size=1, - ) + envelope = self._build_envelope(client, search_text="IND-NSR-7777") query = envelope["message"]["search_request"][0]["search_criteria"]["query"] - self.assertEqual(query["type"], QueryType.IDTYPE_VALUE) - self.assertEqual(query["value"]["id_type"], "UIN") - self.assertEqual(query["value"]["id_value"], "7777") - - def test_custom_reg_record_type_via_constructor(self): - """The vendor adapter accepts a custom reg_record_type — used - when OpenG2P's real DR endpoint becomes available and we need - to point at a non-Farmer record type.""" + 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_record_type="spdci-extensions-dci:PWD_Person", - ) - envelope = client._build_search_envelope( - query_type=QueryType.IDTYPE_VALUE, - query=client._parse_query(QueryType.IDTYPE_VALUE, "UIN:1"), - registry_type="ns:org:RegistryType:DR", - registry_event_type=None, - record_type="PERSON", - page=1, - page_size=1, + reg_type="HouseholdMember", + reg_record_type="HouseholdMember", ) + envelope = self._build_envelope(client) criteria = envelope["message"]["search_request"][0]["search_criteria"] - self.assertEqual(criteria["reg_record_type"], "spdci-extensions-dci:PWD_Person") + self.assertEqual(criteria["reg_type"], "HouseholdMember") + self.assertEqual(criteria["reg_record_type"], "HouseholdMember") diff --git a/spp_dci_openg2p/tests/test_openg2p_fr_service.py b/spp_dci_openg2p/tests/test_openg2p_fr_service.py deleted file mode 100644 index 199986f4..00000000 --- a/spp_dci_openg2p/tests/test_openg2p_fr_service.py +++ /dev/null @@ -1,204 +0,0 @@ -"""OpenG2PFRService FR-as-DR pretense tests. - -Locks in: - - Returns {"has_disability": True, ...} when OpenG2P returns any reg_record - - Returns None when reg_records is empty or response has no search_response - - Unwraps OpenG2P's data.reg_records[] response shape correctly - - Skips partners without a usable identifier -""" - -from unittest.mock import MagicMock, patch - -from odoo.tests.common import TransactionCase, tagged - -from odoo.addons.spp_dci_openg2p.services.openg2p_fr_service import ( - OpenG2PFRService, -) - - -def make_fr_response(reg_records): - """Shape that matches OpenG2P's actual response envelope.""" - 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": "ns:org:RegistryType:Social", - "reg_record_type": "spdci-extensions-dci:Farmer", - "reg_records": reg_records, - }, - } - ], - }, - } - - -def make_fr_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 TestOpenG2PFRService(TransactionCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - - cls.data_source = cls.env["spp.dci.data.source"].create( - { - "name": "OpenG2P FR Test Source", - "code": "openg2p_fr_test", - "registry_type": "DR", - "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", - } - ) - - # Reuse the UIN vocab code seeded by the preset (data/openg2p_id_types.xml). - # Creating a fresh `UIN` here would hit the vocabulary code uniqueness - # constraint. - cls.id_type_uin = cls.env.ref("spp_dci_openg2p.id_type_uin") - - cls.partner_known = cls.env["res.partner"].create( - {"name": "Known Farmer", "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": "FR-KNOWN-1", - } - ) - - cls.partner_no_id = cls.env["res.partner"].create( - {"name": "Partner Without ID", "is_registrant": True, "is_group": False} - ) - - def test_returns_has_disability_true_when_record_found(self): - with patch.object( - OpenG2PFRService.__mro__[0], - "__init__", - lambda self, env, data_source_code: None, - ): - service = OpenG2PFRService.__new__(OpenG2PFRService) - service.env = self.env - service.data_source_code = "openg2p_fr_test" - service.data_source = self.data_source - service.client = MagicMock() - service.client.search.return_value = make_fr_response([{"farmer_id": "F-1", "name": "Known Farmer"}]) - - result = service.get_disability_status(self.partner_known) - - self.assertIsNotNone(result) - self.assertTrue(result["has_disability"]) - self.assertEqual(result["source_registry"], "OpenG2P (FR-as-DR demo)") - self.assertEqual(result["raw_data"]["farmer_id"], "F-1") - - def test_returns_none_when_no_records(self): - with patch.object( - OpenG2PFRService.__mro__[0], - "__init__", - lambda self, env, data_source_code: None, - ): - service = OpenG2PFRService.__new__(OpenG2PFRService) - service.env = self.env - service.data_source_code = "openg2p_fr_test" - service.data_source = self.data_source - service.client = MagicMock() - service.client.search.return_value = make_fr_not_found_response() - - result = service.get_disability_status(self.partner_known) - - self.assertIsNone(result) - - def test_returns_none_when_partner_has_no_identifier(self): - with patch.object( - OpenG2PFRService.__mro__[0], - "__init__", - lambda self, env, data_source_code: None, - ): - service = OpenG2PFRService.__new__(OpenG2PFRService) - service.env = self.env - service.data_source_code = "openg2p_fr_test" - service.data_source = self.data_source - service.client = MagicMock() - - result = service.get_disability_status(self.partner_no_id) - - self.assertIsNone(result) - # Service must not have called the DCI client for a partner with - # no identifier — saves an HTTP round-trip. - service.client.search.assert_not_called() - - def test_extract_first_record_handles_empty_reg_records(self): - response = make_fr_response([]) - self.assertIsNone(OpenG2PFRService._extract_first_record(response)) - - def test_extract_first_record_handles_missing_data_key(self): - response = {"message": {"search_response": [{"reference_id": "r1"}]}} - self.assertIsNone(OpenG2PFRService._extract_first_record(response)) - - def test_extract_first_record_returns_first_across_responses(self): - response = make_fr_response([]) - # Add a second search_response entry that has records - response["message"]["search_response"].append( - { - "reference_id": "r2", - "data": {"reg_records": [{"farmer_id": "F-2"}]}, - } - ) - record = OpenG2PFRService._extract_first_record(response) - self.assertEqual(record["farmer_id"], "F-2") - - def test_is_pwd_convenience(self): - with patch.object( - OpenG2PFRService.__mro__[0], - "__init__", - lambda self, env, data_source_code: None, - ): - service = OpenG2PFRService.__new__(OpenG2PFRService) - service.env = self.env - service.data_source_code = "openg2p_fr_test" - service.data_source = self.data_source - service.client = MagicMock() - service.client.search.return_value = make_fr_response([{"farmer_id": "F-1"}]) - - self.assertTrue(service.is_pwd(self.partner_known)) 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}) From d0f1e9539ea9fe6a53ca94093b3b8c326965f55b Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Thu, 14 May 2026 21:46:23 +0800 Subject: [PATCH 32/62] feat(spp_dci_openg2p): replace has_disability override with SR variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-024 federated topology: the OpenG2P preset owns Social Registry variables, not disability. Disability is sourced from a separate OpenSPP-DR instance, which will be bound by spp_dci_openspp_dr (Phase 6). Drops the var_has_disability override (no XML record, no post_init_hook re-assertion) so the variable falls back to its spp_studio default. The DR preset will rebind it when installed. Adds two new SR-bound CEL variables, each pre-bound to the OpenG2P SR provider with semantic vendor-neutral accessors (ADR-023 §1a): - is_poor (dci_attribute_path=is_poor) - has_dependent_under_school_age (dci_attribute_path=has_dependent_under_school_age) post_init_hook is generalised to iterate a _PRESET_VARIABLES table so new SR variables can be added without touching the drift-correction loop. --- spp_dci_openg2p/__init__.py | 173 ++++++++------ .../data/openg2p_cel_variables.xml | 81 +++++-- spp_dci_openg2p/readme/CONFIGURE.md | 2 +- spp_dci_openg2p/readme/DESCRIPTION.md | 17 +- .../tests/test_dispatcher_routing.py | 9 +- spp_dci_openg2p/tests/test_install.py | 214 ++++++++++++------ 6 files changed, 322 insertions(+), 174 deletions(-) diff --git a/spp_dci_openg2p/__init__.py b/spp_dci_openg2p/__init__.py index 674e6b7f..b124ec6f 100644 --- a/spp_dci_openg2p/__init__.py +++ b/spp_dci_openg2p/__init__.py @@ -21,94 +21,121 @@ ) +# (xml_id, dci_attribute_path) tuples for every CEL variable this preset binds +# to the OpenG2P SR provider. Add a row here when introducing a new +# SR-sourced variable; the rest of the hook handles drift correction +# uniformly. +_PRESET_VARIABLES = ( + ("spp_dci_openg2p.var_is_poor", "is_poor"), + ( + "spp_dci_openg2p.var_has_dependent_under_school_age", + "has_dependent_under_school_age", + ), +) + + def post_init_hook(env): - """Re-assert the DCI binding on spp_studio.var_has_disability. + """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 the canonical - has_disability variable and rewrites the necessary fields so the - bridge dispatcher can route it to OpenG2P. + 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 override: + 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 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. + 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. - """ - 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_openg2p.openg2p_dr_provider", raise_if_not_found=False) + 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 " - "DCI binding on has_disability variable. Verify " + "spp_dci_openg2p.openg2p_dr_provider not found; cannot " + "re-assert SR variable bindings. Verify " "data/openg2p_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 (Draft / Active / - # Inactive) used by spp_studio's lifecycle and CEL symbol - # visibility - # - active=True is the standard 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] - # Many2one comparison: compare ids, not recordsets - if hasattr(current, "id"): - current_value = current.id if current else False + for xml_id, attribute_path in _PRESET_VARIABLES: + 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 + + expected = { + "source_type": "external", + "source_field": False, + "external_provider_id": provider.id, + "dci_attribute_path": attribute_path, + "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 (Draft / Active + # / Inactive) used by spp_studio's lifecycle and CEL + # symbol visibility + # - active=True is the standard 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] + # 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: - 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." - ) + _logger.info( + "%s DCI binding already correct; no changes.", + xml_id, + ) diff --git a/spp_dci_openg2p/data/openg2p_cel_variables.xml b/spp_dci_openg2p/data/openg2p_cel_variables.xml index 44101682..ca2a18ef 100644 --- a/spp_dci_openg2p/data/openg2p_cel_variables.xml +++ b/spp_dci_openg2p/data/openg2p_cel_variables.xml @@ -1,48 +1,85 @@ - + + is_poor + Is Poor (OpenG2P SR) + Whether the registrant is classified as poor in the Social Registry. Sourced from OpenG2P over DCI; see ADR-024 for the federated topology. + + boolean + external + res.partner + is_poor + individual + + is_poor + ttl + 300 + null + active + + True + + + has_dependent_under_school_age + Has Dependent Under School Age (OpenG2P SR) + Whether the registrant has at least one dependent below school-entry age. Sourced from OpenG2P over DCI; see ADR-024 for the federated topology. + + boolean external res.partner - + has_dependent_under_school_age + individual - has_disability + has_dependent_under_school_age ttl 300 null active + True diff --git a/spp_dci_openg2p/readme/CONFIGURE.md b/spp_dci_openg2p/readme/CONFIGURE.md index 7d24f767..08718d43 100644 --- a/spp_dci_openg2p/readme/CONFIGURE.md +++ b/spp_dci_openg2p/readme/CONFIGURE.md @@ -41,7 +41,7 @@ The vendor-specific path is opt-in. If OpenG2P's published API ever drops the na ### Cache TTL -The preset ships with `cache_ttl_seconds = 300` (5 minutes) on the `has_disability` variable so the DCI round-trip is visible during demos. For production, raise to 86400 (24h) or higher via the `spp_studio.var_has_disability` form. +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 diff --git a/spp_dci_openg2p/readme/DESCRIPTION.md b/spp_dci_openg2p/readme/DESCRIPTION.md index 6828d20f..14126644 100644 --- a/spp_dci_openg2p/readme/DESCRIPTION.md +++ b/spp_dci_openg2p/readme/DESCRIPTION.md @@ -2,13 +2,16 @@ Permanent OpenG2P preset for the CEL <-> DCI bridge. Ships pre-configured `spp.d ### 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_studio.var_has_disability` (override) | The semantic `has_disability` CEL accessor, repointed at the DCI provider | -| `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]` | +| 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. diff --git a/spp_dci_openg2p/tests/test_dispatcher_routing.py b/spp_dci_openg2p/tests/test_dispatcher_routing.py index 5be46934..c00d342b 100644 --- a/spp_dci_openg2p/tests/test_dispatcher_routing.py +++ b/spp_dci_openg2p/tests/test_dispatcher_routing.py @@ -76,9 +76,10 @@ def setUpClass(cls): ) # The OpenG2P preset auto-creates this data source + provider + - # variable. Confirm by reading via ref. + # 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_studio.var_has_disability") + 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") @@ -90,7 +91,7 @@ def test_openg2p_handler_extracts_attribute_path_from_reg_record(self, mock_clie ``dci_attribute_path`` from the raw reg_record (no synthesis).""" mock_client = MagicMock() mock_client.search.side_effect = make_sr_response_for_search_text( - {"IND-NSR-0001": [{"has_disability": True}]} + {"IND-NSR-0001": [{"is_poor": True}]} ) mock_client_class.return_value = mock_client @@ -114,7 +115,7 @@ def test_openg2p_handler_records_not_found_for_unknown_partner(self, mock_client self.assertEqual(result, {}) audits = self.env["spp.dci.fetch.audit"].search( - [("variable_name", "=", "has_disability"), ("subject_id", "=", self.partner_not_in_sr.id)] + [("variable_name", "=", "is_poor"), ("subject_id", "=", self.partner_not_in_sr.id)] ) self.assertEqual(audits.result, "not_found") diff --git a/spp_dci_openg2p/tests/test_install.py b/spp_dci_openg2p/tests/test_install.py index e14aa9b0..73a32a63 100644 --- a/spp_dci_openg2p/tests/test_install.py +++ b/spp_dci_openg2p/tests/test_install.py @@ -5,9 +5,19 @@ 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 three preset records exist after install and are linked correctly.""" + """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 @@ -34,6 +44,10 @@ def test_uin_code_matches_service_priority_first(self): 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") @@ -51,98 +65,157 @@ def test_provider_links_to_data_source(self): self.assertEqual(provider.dci_data_source_id, source) self.assertTrue(provider.is_dci_backed) - def test_cel_variable_rewired_to_dci_provider(self): - """The preset overrides spp_studio.var_has_disability so the - semantic `has_disability` CEL accessor sources from OpenG2P over - DCI instead of from the local res.partner field.""" - variable = self.env.ref("spp_studio.var_has_disability") + # ------------------------------------------------------------------ + # 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, "has_disability") - self.assertEqual(variable.cel_accessor, "has_disability") + self.assertEqual(variable.name, "is_poor") + self.assertEqual(variable.cel_accessor, "is_poor") 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.dci_attribute_path, "is_poor") self.assertEqual(variable.cache_strategy, "ttl") self.assertEqual(variable.cache_ttl_seconds, 300) self.assertEqual(variable.external_failure_policy, "null") - # Local field source is cleared so the resolver does not also - # try to expand to r.is_person_with_disability. - self.assertFalse(variable.source_field) - # Variable must be activated so it participates in the resolver - # and the cache pre-warm. 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. + def test_var_has_dependent_under_school_age_bound_to_dci_provider(self): + 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, "active") + self.assertTrue(variable.active) - The OpenG2P-ness lives only in the data-source/provider records, - never in the CEL surface. This test asserts the convention. + 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") - for forbidden in ("openg2p", "g2p", "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. Without this protection, an unrelated - upgrade silently breaks the demo deployment.""" - 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 spp_studio re-applying its standard_variables.xml: the - # variable ships as Draft, source_type=field, no provider. + # Simulate someone resetting the variable to draft / no 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", + "active": False, } ) - # Confirm the reset took effect - self.assertEqual(variable.source_type, "field") self.assertFalse(variable.external_provider_id) self.assertEqual(variable.state, "draft") - # Run the hook post_init_hook(self.env) - # Verify the DCI binding was re-asserted AND the variable was - # activated. Without activation it's invisible to the resolver - # and skipped by precompute. 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.dci_attribute_path, "is_poor") self.assertEqual(variable.cache_strategy, "ttl") + self.assertEqual(variable.cache_ttl_seconds, 300) self.assertEqual(variable.state, "active") self.assertTrue(variable.active) - self.assertEqual(variable.cache_ttl_seconds, 300) + + def test_post_init_hook_re_asserts_all_preset_variables(self): + """Both preset variables (is_poor AND has_dependent_under_school_age) + must be re-asserted by a single hook run. Resetting one and not the + other still produces a healthy state after the hook runs.""" + var_dep = self.env.ref( + "spp_dci_openg2p.var_has_dependent_under_school_age" + ) + var_dep.write( + { + "external_provider_id": False, + "dci_attribute_path": False, + "state": "draft", + } + ) + + 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" + ) + self.assertEqual(var_dep.state, "active") def test_post_init_hook_handles_missing_variable_gracefully(self): - """If spp_studio.var_has_disability is missing (e.g., spp_studio - was uninstalled but the preset is still loaded), the hook must - log a warning and return — not raise — so partial uninstall + """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.""" - with patch("odoo.api.Environment.ref") as mock_ref: - # First call (variable lookup) returns None - mock_ref.return_value = self.env["spp.cel.variable"].browse() + 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 — not raise. Variable stays in - whatever state it's in.""" + hook must log an error and return early — not raise.""" original_ref = self.env.ref def selective_ref(xmlid, *args, **kwargs): @@ -155,20 +228,27 @@ def selective_ref(xmlid, *args, **kwargs): post_init_hook(self.env) def test_post_init_hook_is_idempotent(self): - """Running the hook when the binding is already correct must not - write or log noise. Verify no validation errors and the variable - state is unchanged.""" - 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, - } + """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) - 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) + + 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], + ) From 9b0b6be78c309ce7df9fb16609ceff91e270eed5 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Thu, 14 May 2026 22:01:06 +0800 Subject: [PATCH 33/62] feat(spp_dci_server_disability): real /disability/registry/sync/search handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces spp_dci_server's 501 stub at the disability search endpoint with a working DR implementation. Installs on the OpenSPP-DR instance in the federated topology (ADR-024); SP instances see no change. Components: - DisabilitySearchService: parse SearchRequest -> look up partner by spp.registry.id.value -> return wire-format reg_record with has_disability/disability_certified/disability_percentage keys - disability_search_router: signed DCI envelope, mirrors the shape of spp_dci_server.routers.search.search_registry - fastapi.endpoint override: filters the parent's stub router out of _get_fastapi_routers and substitutes the real one — FastAPI matches routes by registration order, so filtering is required to win the /disability/registry/sync/search path collision - UIN vocab code seed for the system id-type vocabulary (only path through which a code can be added) Wire-format details: - Query parsing handles both idtype-value (upstream flat shape) and expression (OpenG2P nested search_text shape). - "Not found" surfaces as status=rjct with REG-ERR-001 / REGISTER_NOT_FOUND (the SPDCI SearchStatusReasonCode enum has no canonical not-found code; the OpenG2P convention is the only widely understood pattern). - Disability fields are read defensively (getattr with False/None defaults) so the module doesn't strict-depend on the partner-fields module. --- spp_dci_server_disability/__init__.py | 3 + spp_dci_server_disability/__manifest__.py | 27 ++ .../data/dr_id_types.xml | 36 +++ spp_dci_server_disability/models/__init__.py | 1 + .../models/fastapi_endpoint_dr.py | 51 ++++ spp_dci_server_disability/readme/CONFIGURE.md | 30 ++ .../readme/DESCRIPTION.md | 39 +++ spp_dci_server_disability/routers/__init__.py | 1 + .../routers/disability_router.py | 171 +++++++++++ .../security/ir.model.access.csv | 1 + .../services/__init__.py | 1 + .../services/disability_search_service.py | 248 +++++++++++++++ spp_dci_server_disability/tests/__init__.py | 1 + .../tests/test_disability_search_service.py | 289 ++++++++++++++++++ 14 files changed, 899 insertions(+) create mode 100644 spp_dci_server_disability/__init__.py create mode 100644 spp_dci_server_disability/__manifest__.py create mode 100644 spp_dci_server_disability/data/dr_id_types.xml create mode 100644 spp_dci_server_disability/models/__init__.py create mode 100644 spp_dci_server_disability/models/fastapi_endpoint_dr.py create mode 100644 spp_dci_server_disability/readme/CONFIGURE.md create mode 100644 spp_dci_server_disability/readme/DESCRIPTION.md create mode 100644 spp_dci_server_disability/routers/__init__.py create mode 100644 spp_dci_server_disability/routers/disability_router.py create mode 100644 spp_dci_server_disability/security/ir.model.access.csv create mode 100644 spp_dci_server_disability/services/__init__.py create mode 100644 spp_dci_server_disability/services/disability_search_service.py create mode 100644 spp_dci_server_disability/tests/__init__.py create mode 100644 spp_dci_server_disability/tests/test_disability_search_service.py 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/readme/CONFIGURE.md b/spp_dci_server_disability/readme/CONFIGURE.md new file mode 100644 index 00000000..fd180a7f --- /dev/null +++ b/spp_dci_server_disability/readme/CONFIGURE.md @@ -0,0 +1,30 @@ +### After installing this module + +The endpoint is live at `https:///dci/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/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 three fields from `res.partner`: + +| Local field | Wire-format key in `reg_records[0]` | +| ---------------------------- | ----------------------------------- | +| `is_person_with_disability` | `has_disability` | +| `disability_certified` | `disability_certified` | +| `disability_percentage` | `disability_percentage` | + +Missing fields are returned as `False` / `None` rather than raising — modules that define these fields are not strict dependencies of this server module. diff --git a/spp_dci_server_disability/readme/DESCRIPTION.md b/spp_dci_server_disability/readme/DESCRIPTION.md new file mode 100644 index 00000000..770ae12b --- /dev/null +++ b/spp_dci_server_disability/readme/DESCRIPTION.md @@ -0,0 +1,39 @@ +Server-side DCI Disability Registry implementation. Replaces the 501 stub at `/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 `/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_certified": false, + "disability_percentage": 50.0, + "partner_name": "Maria Santos", + "partner_uid": 12345 +} +``` + +The CEL-bridge SP side reads `has_disability` (not the local field name `is_person_with_disability`) — the mapping happens here. + +### 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..750fde49 --- /dev/null +++ b/spp_dci_server_disability/routers/disability_router.py @@ -0,0 +1,171 @@ +"""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..48b2204f --- /dev/null +++ b/spp_dci_server_disability/services/disability_search_service.py @@ -0,0 +1,248 @@ +"""DCI Disability Registry search service. + +Looks up local res.partner records by the incoming ``search_text`` and +returns disability data (``has_disability``, ``disability_certified``, +``disability_percentage``) 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 ``is_person_with_disability``, + ``disability_certified``, and ``disability_percentage`` from the + partner and returns them under the wire-format key + ``has_disability`` (plus the others verbatim). + - 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}: " + f"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. + """ + reg_id = self.env["spp.registry.id"].search( + [("value", "=", identifier_value)], + order="partner_id asc", + limit=1, + ) + return reg_id.partner_id if reg_id else self.env["res.partner"].browse() + + # ------------------------------------------------------------------ + # Reg-record construction + # ------------------------------------------------------------------ + + @staticmethod + def _build_reg_record(partner) -> dict: + """Produce the wire-format reg_record dict from a res.partner. + + The CEL-bridge SP side reads ``has_disability`` (not the local + field name ``is_person_with_disability``). Mapping happens here. + + Disability-related fields are read defensively — modules that + define them are not strict dependencies of this server module, + so the fields may be missing on the partner record. Missing + fields are reported as ``False`` / ``None`` rather than raising. + """ + return { + "has_disability": bool( + getattr(partner, "is_person_with_disability", False) + ), + "disability_certified": bool( + getattr(partner, "disability_certified", False) + ), + "disability_percentage": getattr( + partner, "disability_percentage", None + ), + "partner_name": partner.name, + "partner_uid": partner.id, + } 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..3f512e32 --- /dev/null +++ b/spp_dci_server_disability/tests/test_disability_search_service.py @@ -0,0 +1,289 @@ +"""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; + # otherwise the service's defensive read returns False. + if "is_person_with_disability" in cls.env["res.partner"]._fields: + cls.partner_pwd.is_person_with_disability = True + + 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 is_person_with_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_uses_wire_format_keys_not_local_field_names(self): + """SP side reads `has_disability` (not `is_person_with_disability`) + — this test locks the wire-format mapping in place.""" + 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.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") From 52fb056e0274fc8384df11e439e1a5411a39409c Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Thu, 14 May 2026 22:12:09 +0800 Subject: [PATCH 34/62] feat(spp_dci_openspp_dr): SP-side preset for an OpenSPP-DR instance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors spp_dci_openg2p's structure but for the DR side of the federated demo topology (ADR-024). Configures a DCI data source targeting a sibling OpenSPP-DR container, a CEL provider, and rebinds spp_studio.var_has_disability to source from the DR provider. The vendor field used to live in spp_dci_openg2p only — hoisting it into spp_cel_dci_bridge with an empty selection lets each preset register its own vendor via selection_add. Both presets now compile in any combination without depending on each other. Why a vendor override exists for the DR path: upstream DRService reads disability fields from `data` directly, but the SPDCI spec puts records at `data.reg_records[0]`. OpenSPPDRService takes ownership of the response unwrap until DRService is fixed upstream. Default base_url=http://openspp-dr:8069 lines up with the docker-compose service name introduced in the next phase. --- spp_cel_dci_bridge/models/__init__.py | 1 + spp_cel_dci_bridge/models/dci_data_source.py | 33 ++++ spp_dci_openg2p/models/dci_data_source.py | 30 +--- spp_dci_openspp_dr/__init__.py | 117 +++++++++++++ spp_dci_openspp_dr/__manifest__.py | 31 ++++ .../data/openspp_dr_cel_variable.xml | 36 ++++ .../data/openspp_dr_data_provider.xml | 15 ++ .../data/openspp_dr_data_source.xml | 43 +++++ .../data/openspp_dr_id_types.xml | 30 ++++ spp_dci_openspp_dr/models/__init__.py | 2 + spp_dci_openspp_dr/models/dci_data_source.py | 18 ++ spp_dci_openspp_dr/models/dci_dispatcher.py | 85 +++++++++ spp_dci_openspp_dr/readme/CONFIGURE.md | 25 +++ spp_dci_openspp_dr/readme/DESCRIPTION.md | 26 +++ .../security/ir.model.access.csv | 1 + spp_dci_openspp_dr/services/__init__.py | 1 + .../services/openspp_dr_service.py | 139 +++++++++++++++ spp_dci_openspp_dr/tests/__init__.py | 3 + .../tests/test_dispatcher_routing.py | 133 +++++++++++++++ spp_dci_openspp_dr/tests/test_install.py | 123 +++++++++++++ .../tests/test_openspp_dr_service.py | 161 ++++++++++++++++++ 21 files changed, 1030 insertions(+), 23 deletions(-) create mode 100644 spp_cel_dci_bridge/models/dci_data_source.py create mode 100644 spp_dci_openspp_dr/__init__.py create mode 100644 spp_dci_openspp_dr/__manifest__.py create mode 100644 spp_dci_openspp_dr/data/openspp_dr_cel_variable.xml create mode 100644 spp_dci_openspp_dr/data/openspp_dr_data_provider.xml create mode 100644 spp_dci_openspp_dr/data/openspp_dr_data_source.xml create mode 100644 spp_dci_openspp_dr/data/openspp_dr_id_types.xml create mode 100644 spp_dci_openspp_dr/models/__init__.py create mode 100644 spp_dci_openspp_dr/models/dci_data_source.py create mode 100644 spp_dci_openspp_dr/models/dci_dispatcher.py create mode 100644 spp_dci_openspp_dr/readme/CONFIGURE.md create mode 100644 spp_dci_openspp_dr/readme/DESCRIPTION.md create mode 100644 spp_dci_openspp_dr/security/ir.model.access.csv create mode 100644 spp_dci_openspp_dr/services/__init__.py create mode 100644 spp_dci_openspp_dr/services/openspp_dr_service.py create mode 100644 spp_dci_openspp_dr/tests/__init__.py create mode 100644 spp_dci_openspp_dr/tests/test_dispatcher_routing.py create mode 100644 spp_dci_openspp_dr/tests/test_install.py create mode 100644 spp_dci_openspp_dr/tests/test_openspp_dr_service.py diff --git a/spp_cel_dci_bridge/models/__init__.py b/spp_cel_dci_bridge/models/__init__.py index f0b656e7..0775f747 100644 --- a/spp_cel_dci_bridge/models/__init__.py +++ b/spp_cel_dci_bridge/models/__init__.py @@ -1,5 +1,6 @@ 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 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_dci_openg2p/models/dci_data_source.py b/spp_dci_openg2p/models/dci_data_source.py index d3d6b094..2397cd63 100644 --- a/spp_dci_openg2p/models/dci_data_source.py +++ b/spp_dci_openg2p/models/dci_data_source.py @@ -2,33 +2,17 @@ class DCIDataSource(models.Model): - """Add a vendor discriminator so the bridge can route to vendor-specific - DCI clients when a deployment's source has known quirks. + """Register the OpenG2P vendor adapter on the shared vendor selection. - The DCI spec leaves several request/response shapes ambiguous (notably - the `idtype-value` query and the `data.reg_records[]` wrapper). Vendors - have picked different interpretations. Rather than fork the upstream - DCIClient, we mark sources with a `vendor` and let the dispatcher pick - the right adapter. - - Selection values: - - openg2p: OpenG2P Partner Registry / Farmer Registry shape. Query - uses nested {id_type, id_value} payload; response wraps records - in data.reg_records[]. + 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=[ - ("openg2p", "OpenG2P"), - ], - 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." - ), + selection_add=[("openg2p", "OpenG2P")], + ondelete={"openg2p": "set null"}, ) diff --git a/spp_dci_openspp_dr/__init__.py b/spp_dci_openspp_dr/__init__.py new file mode 100644 index 00000000..14a4351c --- /dev/null +++ b/spp_dci_openspp_dr/__init__.py @@ -0,0 +1,117 @@ +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..98957a36 --- /dev/null +++ b/spp_dci_openspp_dr/__manifest__.py @@ -0,0 +1,31 @@ +{ # 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_id_types.xml", + "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..749be28d --- /dev/null +++ b/spp_dci_openspp_dr/data/openspp_dr_data_source.xml @@ -0,0 +1,43 @@ + + + + + OpenSPP Disability Registry + openspp_dr + DR + openspp + http://openspp-dr:8069 + /dci/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/data/openspp_dr_id_types.xml b/spp_dci_openspp_dr/data/openspp_dr_id_types.xml new file mode 100644 index 00000000..b73ddb45 --- /dev/null +++ b/spp_dci_openspp_dr/data/openspp_dr_id_types.xml @@ -0,0 +1,30 @@ + + + + + + UIN + UIN (Universal Identification Number) + individual + Universal Identification Number — used to tag SP-side registrants for DCI lookups against the OpenSPP-DR. + 20 + + 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/readme/CONFIGURE.md b/spp_dci_openspp_dr/readme/CONFIGURE.md new file mode 100644 index 00000000..d4ec40f2 --- /dev/null +++ b/spp_dci_openspp_dr/readme/CONFIGURE.md @@ -0,0 +1,25 @@ +### 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/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. + +### 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..00ab9f5c --- /dev/null +++ b/spp_dci_openspp_dr/services/openspp_dr_service.py @@ -0,0 +1,139 @@ +"""OpenSPP-DR Disability Registry client service. + +Queries the sibling OpenSPP-DR instance over DCI (``spp_dci_server_disability`` +endpoint at ``/dci/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.addons.spp_dci_client.services import DCIClient +from odoo.exceptions import UserError, ValidationError + +_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("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(f"Failed to query OpenSPP-DR: {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/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/test_dispatcher_routing.py b/spp_dci_openspp_dr/tests/test_dispatcher_routing.py new file mode 100644 index 00000000..581c75a2 --- /dev/null +++ b/spp_dci_openspp_dr/tests/test_dispatcher_routing.py @@ -0,0 +1,133 @@ +"""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 + + +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 = cls.env.ref("spp_dci_openspp_dr.id_type_uin_sp") + 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..f3b8761a --- /dev/null +++ b/spp_dci_openspp_dr/tests/test_install.py @@ -0,0 +1,123 @@ +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_uin_id_type_vocab_code_present(self): + code = self.env.ref("spp_dci_openspp_dr.id_type_uin_sp") + self.assertEqual(code.code, "UIN") + self.assertEqual(code.target_type, "individual") + + def test_uin_code_matches_service_priority_first(self): + from odoo.addons.spp_dci_openspp_dr.services.openspp_dr_service import ( + IDENTIFIER_PRIORITY, + ) + + code = self.env.ref("spp_dci_openspp_dr.id_type_uin_sp") + self.assertEqual(IDENTIFIER_PRIORITY[0], code.code) + + 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/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..6333b8d1 --- /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, +) + + +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/disability/registry/sync/search", + "auth_type": "none", + "our_sender_id": "openspp-sp.test", + "receiver_id": "openspp-dr.test", + } + ) + + cls.id_type_uin = cls.env.ref("spp_dci_openspp_dr.id_type_uin_sp") + 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}) From 56ee2f3d7910a2804deb69759acd12ce669541e4 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Thu, 14 May 2026 22:13:25 +0800 Subject: [PATCH 35/62] chore(docker): add DR-instance overlay compose for federated demo Second OpenSPP container that plays the Disability Registry role in the federated demo topology (ADR-024). The SP container reaches it in-network at http://openspp-dr:8069; the host can reach each instance on a different port (SP=8069, DR=8070). Both containers share the existing db service but use distinct databases (openspp vs openspp_dr) so neither sees the other's data. Launch: docker compose \ -f docker-compose.yml \ -f docker-compose.dr.yml \ --profile ui --profile dr up -d Module wiring is left to operators via ODOO_INIT_MODULES / ODOO_DR_INIT_MODULES env vars, defaulting to spp_dci_server_disability on the DR side. --- docker-compose.dr.yml | 102 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 docker-compose.dr.yml diff --git a/docker-compose.dr.yml b/docker-compose.dr.yml new file mode 100644 index 00000000..12db2bce --- /dev/null +++ b/docker-compose.dr.yml @@ -0,0 +1,102 @@ +# OpenSPP-DR docker-compose overlay +# +# Adds a second OpenSPP container that plays the Disability Registry +# role in the federated demo topology (ADR-024). The SP-side container +# (defined in docker-compose.yml under the `openspp` service) speaks +# DCI to this container at http://openspp-dr:8069 — the service name +# doubles as the in-network hostname. +# +# Both containers share the same PostgreSQL container but use different +# databases (`openspp` for SP, `openspp_dr` for DR) so neither sees the +# other's data. +# +# Usage: +# +# # Launch SP + DR together +# docker compose \ +# -f docker-compose.yml \ +# -f docker-compose.dr.yml \ +# --profile ui --profile dr up -d +# +# # Access: +# # SP UI: http://localhost:8069 (admin/admin) +# # DR UI: http://localhost:8070 (admin/admin) +# # SP -> DR (in-network): http://openspp-dr:8069 +# +# # Stop everything +# docker compose \ +# -f docker-compose.yml \ +# -f docker-compose.dr.yml \ +# --profile ui --profile dr down +# +# # Clean restart (drops DR database too) +# docker compose \ +# -f docker-compose.yml \ +# -f docker-compose.dr.yml \ +# --profile ui --profile dr down -v +# +# Module wiring: +# +# - SP container should install `spp_dci_openspp_dr` + `spp_dci_openg2p` +# (set ODOO_INIT_MODULES env var, see CONFIGURE.md in each preset) +# - DR container ships `spp_dci_server_disability` via ODOO_INIT_MODULES +# below — operators can override. + +services: + openspp-dr: + image: openspp-dev + build: + context: . + dockerfile: docker/Dockerfile + target: dev + profiles: + - dr + depends_on: + db: + condition: service_healthy + environment: + # Distinct database so SP and DR don't share registry data. + 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" + + # Default to the server-side DR module + a registry foundation + # (spp_registry seeds the partner / reg_id stack that + # DisabilitySearchService queries against). Operators can + # override to install a richer module set for demo data. + 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: + # Different host port so both SP (8069) and DR (8070) are + # reachable from the host. The in-network port stays 8069 — + # SP-side OpenSPPDRService talks to http://openspp-dr:8069. + - "8070:8069" + volumes: + - .:/mnt/extra-addons/openspp:ro,z + - odoo_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: + - openspp + +volumes: + # Separate filestore so the DR's attachments don't collide with the + # SP's. Inherits the existing top-level `volumes` declaration in + # docker-compose.yml (postgres_data, odoo_data, e2e_snapshots). + odoo_dr_data: From 53d0c8e8084e189adb726ffa5ba48634e2025bab Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Thu, 14 May 2026 22:31:16 +0800 Subject: [PATCH 36/62] chore(docker): make DR compose standalone, not an overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Edwin's workflow keeps ./spp + docker-compose.yml as the SP control surface. The DR file should not modify SP services or share their lifecycle — it's a separate container the operator launches and tears down independently. Changes: - Project name set to "openspp-dr" so it doesn't merge with the SP's default "openspp2" project. - Drops the dr profile and the SP file's volume/build inheritance — the DR uses its own openspp_dr_data volume and reuses the existing openspp-dev image without rebuilding. - Joins the SP project's network as an external network (openspp2_openspp by default, OPENSPP_NETWORK overrides) so SP and DR containers can still resolve each other by service name. - container_name + hostname pinned to openspp-dr so the SP's preset (base_url http://openspp-dr:8069) reaches it predictably. New launch flow: ./spp start # SP as before docker compose -f docker-compose.dr.yml up -d # DR alongside --- docker-compose.dr.yml | 119 ++++++++++++++----------- spp_dci_openspp_dr/readme/CONFIGURE.md | 17 ++++ 2 files changed, 82 insertions(+), 54 deletions(-) diff --git a/docker-compose.dr.yml b/docker-compose.dr.yml index 12db2bce..69cb374f 100644 --- a/docker-compose.dr.yml +++ b/docker-compose.dr.yml @@ -1,61 +1,67 @@ -# OpenSPP-DR docker-compose overlay +# OpenSPP-DR standalone docker-compose # -# Adds a second OpenSPP container that plays the Disability Registry -# role in the federated demo topology (ADR-024). The SP-side container -# (defined in docker-compose.yml under the `openspp` service) speaks -# DCI to this container at http://openspp-dr:8069 — the service name -# doubles as the in-network hostname. +# 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. # -# Both containers share the same PostgreSQL container but use different -# databases (`openspp` for SP, `openspp_dr` for DR) so neither sees the -# other's data. +# 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: # -# # Launch SP + DR together -# docker compose \ -# -f docker-compose.yml \ -# -f docker-compose.dr.yml \ -# --profile ui --profile dr up -d +# # Prereq: SP is up (network + db exist) +# ./spp start +# +# # Launch DR +# docker compose -f docker-compose.dr.yml up -d # # # Access: -# # SP UI: http://localhost:8069 (admin/admin) -# # DR UI: http://localhost:8070 (admin/admin) -# # SP -> DR (in-network): http://openspp-dr:8069 +# # 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 # -# # Stop everything -# docker compose \ -# -f docker-compose.yml \ -# -f docker-compose.dr.yml \ -# --profile ui --profile dr down +# # 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 # -# # Clean restart (drops DR database too) -# docker compose \ -# -f docker-compose.yml \ -# -f docker-compose.dr.yml \ -# --profile ui --profile dr down -v +# # 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). # -# - SP container should install `spp_dci_openspp_dr` + `spp_dci_openg2p` -# (set ODOO_INIT_MODULES env var, see CONFIGURE.md in each preset) -# - DR container ships `spp_dci_server_disability` via ODOO_INIT_MODULES -# below — operators can override. +# 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 - build: - context: . - dockerfile: docker/Dockerfile - target: dev - profiles: - - dr - depends_on: - db: - condition: service_healthy + # 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: - # Distinct database so SP and DR don't share registry data. + # 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 @@ -69,23 +75,21 @@ services: ODOO_WORKERS: "0" ODOO_CRON_THREADS: "0" - # Default to the server-side DR module + a registry foundation - # (spp_registry seeds the partner / reg_id stack that - # DisabilitySearchService queries against). Operators can - # override to install a richer module set for demo data. + # 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: - # Different host port so both SP (8069) and DR (8070) are - # reachable from the host. The in-network port stays 8069 — - # SP-side OpenSPPDRService talks to http://openspp-dr:8069. + # 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 - - odoo_dr_data:/var/lib/odoo + - openspp_dr_data:/var/lib/odoo healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8069/web/health"] interval: 10s @@ -93,10 +97,17 @@ services: start_period: 90s retries: 10 networks: - - openspp + - sp-shared volumes: - # Separate filestore so the DR's attachments don't collide with the - # SP's. Inherits the existing top-level `volumes` declaration in - # docker-compose.yml (postgres_data, odoo_data, e2e_snapshots). - odoo_dr_data: + 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/spp_dci_openspp_dr/readme/CONFIGURE.md b/spp_dci_openspp_dr/readme/CONFIGURE.md index d4ec40f2..11eca997 100644 --- a/spp_dci_openspp_dr/readme/CONFIGURE.md +++ b/spp_dci_openspp_dr/readme/CONFIGURE.md @@ -1,3 +1,20 @@ +### 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/disability/registry/sync/search` (the docker-compose default for the demo). From aa8eb7df2af9d1fb9d315851aec7bb2e9a2c3797 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 15 May 2026 00:18:41 +0800 Subject: [PATCH 37/62] fix(spp_dci_openspp_dr): drop colliding UIN seed; install no longer fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When both spp_dci_openg2p and spp_dci_openspp_dr install on the same SP database, their independent UIN seeds collide on UNIQUE(vocabulary_id, code) in the system urn:openspp:vocab:id-type vocabulary, raising ParseError "Code 'UIN' already exists" on the second preset's install. In the federated topology this collision is unavoidable — the SP needs both presets to drive SR (OpenG2P) and DR (OpenSPP-DR) routing. The OpenG2P preset's seed stays as the canonical source for SP-side UIN; this preset relies on it. Changes: - Drop data/openspp_dr_id_types.xml from the manifest and delete the file. The previous xmlid spp_dci_openspp_dr.id_type_uin_sp is gone. - Tests previously calling env.ref on that xmlid now use a new get_or_create_uin_code helper (tests/common.py) that calls spp.vocabulary.code.get_or_create_local — the ADR-016 supported path for adding codes to system vocabularies at runtime. The helper makes the tests self-contained whether or not spp_dci_openg2p is also installed in the test DB. - test_install drops the two assertions that depended on the xmlid; test_service_priority_first_is_uin replaces them by checking the IDENTIFIER_PRIORITY tuple's first entry directly. For a SP database that wants to use only spp_dci_openspp_dr without spp_dci_openg2p (not the demo scenario but a sensible deployment), operators can seed UIN manually or call the same get_or_create_local helper from a deployment script. --- spp_dci_openspp_dr/__manifest__.py | 1 - .../data/openspp_dr_id_types.xml | 30 ------------------- spp_dci_openspp_dr/tests/common.py | 21 +++++++++++++ .../tests/test_dispatcher_routing.py | 4 ++- spp_dci_openspp_dr/tests/test_install.py | 13 ++++---- .../tests/test_openspp_dr_service.py | 4 ++- 6 files changed, 32 insertions(+), 41 deletions(-) delete mode 100644 spp_dci_openspp_dr/data/openspp_dr_id_types.xml create mode 100644 spp_dci_openspp_dr/tests/common.py diff --git a/spp_dci_openspp_dr/__manifest__.py b/spp_dci_openspp_dr/__manifest__.py index 98957a36..ccb1b916 100644 --- a/spp_dci_openspp_dr/__manifest__.py +++ b/spp_dci_openspp_dr/__manifest__.py @@ -19,7 +19,6 @@ "external_dependencies": {"python": []}, "data": [ "security/ir.model.access.csv", - "data/openspp_dr_id_types.xml", "data/openspp_dr_data_source.xml", "data/openspp_dr_data_provider.xml", "data/openspp_dr_cel_variable.xml", diff --git a/spp_dci_openspp_dr/data/openspp_dr_id_types.xml b/spp_dci_openspp_dr/data/openspp_dr_id_types.xml deleted file mode 100644 index b73ddb45..00000000 --- a/spp_dci_openspp_dr/data/openspp_dr_id_types.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - UIN - UIN (Universal Identification Number) - individual - Universal Identification Number — used to tag SP-side registrants for DCI lookups against the OpenSPP-DR. - 20 - - 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 index 581c75a2..6911bdfe 100644 --- a/spp_dci_openspp_dr/tests/test_dispatcher_routing.py +++ b/spp_dci_openspp_dr/tests/test_dispatcher_routing.py @@ -7,6 +7,8 @@ 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.""" @@ -42,7 +44,7 @@ class TestDispatcherRoutesOpenSPPDR(TransactionCase): def setUpClass(cls): super().setUpClass() - cls.id_type_uin = cls.env.ref("spp_dci_openspp_dr.id_type_uin_sp") + 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} ) diff --git a/spp_dci_openspp_dr/tests/test_install.py b/spp_dci_openspp_dr/tests/test_install.py index f3b8761a..c9c86afc 100644 --- a/spp_dci_openspp_dr/tests/test_install.py +++ b/spp_dci_openspp_dr/tests/test_install.py @@ -9,18 +9,15 @@ class TestOpenSPPDRPresetInstall(TransactionCase): """Smoke test: the preset records exist after install and are linked correctly.""" - def test_uin_id_type_vocab_code_present(self): - code = self.env.ref("spp_dci_openspp_dr.id_type_uin_sp") - self.assertEqual(code.code, "UIN") - self.assertEqual(code.target_type, "individual") - - def test_uin_code_matches_service_priority_first(self): + 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, ) - code = self.env.ref("spp_dci_openspp_dr.id_type_uin_sp") - self.assertEqual(IDENTIFIER_PRIORITY[0], code.code) + self.assertEqual(IDENTIFIER_PRIORITY[0], "UIN") def test_data_source_present(self): source = self.env.ref("spp_dci_openspp_dr.openspp_dr_source") diff --git a/spp_dci_openspp_dr/tests/test_openspp_dr_service.py b/spp_dci_openspp_dr/tests/test_openspp_dr_service.py index 6333b8d1..2abc1c55 100644 --- a/spp_dci_openspp_dr/tests/test_openspp_dr_service.py +++ b/spp_dci_openspp_dr/tests/test_openspp_dr_service.py @@ -8,6 +8,8 @@ OpenSPPDRService, ) +from .common import get_or_create_uin_code + def make_dr_response(reg_records): return { @@ -67,7 +69,7 @@ def setUpClass(cls): } ) - cls.id_type_uin = cls.env.ref("spp_dci_openspp_dr.id_type_uin_sp") + 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} ) From 78782e77a32ecf8e132c76bf6b080ce6b7334c42 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 15 May 2026 01:06:38 +0800 Subject: [PATCH 38/62] fix(spp_dci_server_disability): read real fields, not stale field names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DisabilitySearchService was reading is_person_with_disability, disability_certified, and disability_percentage from res.partner. None of those fields exist on res.partner — the actual disability data model in spp_disability_registry exposes has_disability (Boolean related to current approved assessment) plus severity/review metadata. The result: every DR response returned has_disability=False regardless of the partner's actual state, defeating the SP-side eligibility check. Rewrites the reg_record builder to read: - has_disability (boolean, real field) - disability_severity_code (vocabulary code via severity_id) - disability_review_category (selection) - disability_next_review (date ISO string) Drops disability_certified and disability_percentage entirely — neither has a counterpart on the data model. Tests: - setUpClass stamps has_disability via raw UPDATE since the field is computed-stored and skipping the assessment chain is simpler in isolation than creating a full approved assessment graph. - test_reg_record_carries_wire_format_keys now asserts the new key set instead of just absence of legacy names. --- .../services/disability_search_service.py | 38 +++++++++++-------- .../tests/test_disability_search_service.py | 33 +++++++++++----- 2 files changed, 47 insertions(+), 24 deletions(-) diff --git a/spp_dci_server_disability/services/disability_search_service.py b/spp_dci_server_disability/services/disability_search_service.py index 48b2204f..5cd9494a 100644 --- a/spp_dci_server_disability/services/disability_search_service.py +++ b/spp_dci_server_disability/services/disability_search_service.py @@ -225,24 +225,32 @@ def _find_partner_by_identifier(self, identifier_value: str): def _build_reg_record(partner) -> dict: """Produce the wire-format reg_record dict from a res.partner. - The CEL-bridge SP side reads ``has_disability`` (not the local - field name ``is_person_with_disability``). Mapping happens here. - - Disability-related fields are read defensively — modules that - define them are not strict dependencies of this server module, - so the fields may be missing on the partner record. Missing - fields are reported as ``False`` / ``None`` rather than raising. + 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, "is_person_with_disability", False) - ), - "disability_certified": bool( - getattr(partner, "disability_certified", False) - ), - "disability_percentage": getattr( - partner, "disability_percentage", None + "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/tests/test_disability_search_service.py b/spp_dci_server_disability/tests/test_disability_search_service.py index 3f512e32..728c2f72 100644 --- a/spp_dci_server_disability/tests/test_disability_search_service.py +++ b/spp_dci_server_disability/tests/test_disability_search_service.py @@ -95,10 +95,19 @@ def setUpClass(cls): "value": "UIN-DR-1", } ) - # Stamp the disability flag if the field exists on res.partner; - # otherwise the service's defensive read returns False. - if "is_person_with_disability" in cls.env["res.partner"]._fields: - cls.partner_pwd.is_person_with_disability = True + # 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( { @@ -201,8 +210,8 @@ def test_unknown_identifier_returns_register_not_found(self): self.assertIsNone(item.data) def test_partner_without_disability_field_returns_false(self): - """If is_person_with_disability is not set / not present, the wire - format key has_disability is reported as False — the SP side then + """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( @@ -227,9 +236,12 @@ def test_response_envelope_carries_dr_reg_type_constants(self): self.assertEqual(item.data.reg_type, DR_REG_TYPE) self.assertEqual(item.data.reg_record_type, DR_REG_RECORD_TYPE) - def test_reg_record_uses_wire_format_keys_not_local_field_names(self): - """SP side reads `has_disability` (not `is_person_with_disability`) - — this test locks the wire-format mapping in place.""" + 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") @@ -237,6 +249,9 @@ def test_reg_record_uses_wire_format_keys_not_local_field_names(self): 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) # ------------------------------------------------------------------ From 816659ad2eaf99feb4a78f5b0ccf9a7496549834 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 15 May 2026 01:29:21 +0800 Subject: [PATCH 39/62] =?UTF-8?q?fix(spp=5Fdci=5Fopenspp=5Fdr):=20correct?= =?UTF-8?q?=20DR=20endpoint=20path=20=E2=80=94=20root=5Fpath=20is=20/dci?= =?UTF-8?q?=5Fapi/v1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fastapi.endpoint record in spp_dci_server registers the DCI app under root_path=/dci_api/v1, not /dci as I'd assumed. With the wrong prefix, the SP's data source was POSTing to /dci/disability/... which Odoo's website dispatcher 404s on (HTML response, "Server: Werkzeug"). Corrects: - spp_dci_openspp_dr/data/openspp_dr_data_source.xml — search_endpoint - Service docstring, tests, both readme files in both modules Operators with an existing data source need to manually update the search_endpoint field on their openspp_dr record (noupdate=1 means the XML change won't apply on upgrade). --- spp_dci_openspp_dr/data/openspp_dr_data_source.xml | 7 +++++-- spp_dci_openspp_dr/readme/CONFIGURE.md | 2 +- spp_dci_openspp_dr/services/openspp_dr_service.py | 2 +- spp_dci_openspp_dr/tests/test_install.py | 2 +- spp_dci_openspp_dr/tests/test_openspp_dr_service.py | 2 +- spp_dci_server_disability/readme/CONFIGURE.md | 4 ++-- spp_dci_server_disability/readme/DESCRIPTION.md | 4 ++-- 7 files changed, 13 insertions(+), 10 deletions(-) diff --git a/spp_dci_openspp_dr/data/openspp_dr_data_source.xml b/spp_dci_openspp_dr/data/openspp_dr_data_source.xml index 749be28d..55927a93 100644 --- a/spp_dci_openspp_dr/data/openspp_dr_data_source.xml +++ b/spp_dci_openspp_dr/data/openspp_dr_data_source.xml @@ -5,7 +5,10 @@ Defaults target a sibling OpenSPP instance running spp_dci_server_disability and exposing the canonical DCI - endpoint at /dci/disability/registry/sync/search. + endpoint at /dci_api/v1/disability/registry/sync/search (the + `fastapi.endpoint` record in spp_dci_server is registered with + root_path=/dci_api/v1; our disability router adds the + /disability/registry prefix and the POST /sync/search handler). registry_type='DR' so the bridge dispatcher routes through _handler_dr. @@ -28,7 +31,7 @@ DR openspp http://openspp-dr:8069 - /dci/disability/registry/sync/search + /dci_api/v1/disability/registry/sync/search none openspp-sp.demo openspp-dr.demo diff --git a/spp_dci_openspp_dr/readme/CONFIGURE.md b/spp_dci_openspp_dr/readme/CONFIGURE.md index 11eca997..ad5fb729 100644 --- a/spp_dci_openspp_dr/readme/CONFIGURE.md +++ b/spp_dci_openspp_dr/readme/CONFIGURE.md @@ -17,7 +17,7 @@ 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/disability/registry/sync/search` (the docker-compose default for the demo). +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. diff --git a/spp_dci_openspp_dr/services/openspp_dr_service.py b/spp_dci_openspp_dr/services/openspp_dr_service.py index 00ab9f5c..0c1b2a71 100644 --- a/spp_dci_openspp_dr/services/openspp_dr_service.py +++ b/spp_dci_openspp_dr/services/openspp_dr_service.py @@ -1,7 +1,7 @@ """OpenSPP-DR Disability Registry client service. Queries the sibling OpenSPP-DR instance over DCI (``spp_dci_server_disability`` -endpoint at ``/dci/disability/registry/sync/search``) and returns the raw +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 diff --git a/spp_dci_openspp_dr/tests/test_install.py b/spp_dci_openspp_dr/tests/test_install.py index c9c86afc..9d6f7bdf 100644 --- a/spp_dci_openspp_dr/tests/test_install.py +++ b/spp_dci_openspp_dr/tests/test_install.py @@ -24,7 +24,7 @@ def test_data_source_present(self): self.assertEqual(source.code, "openspp_dr") self.assertEqual(source.registry_type, "DR") self.assertEqual(source.vendor, "openspp") - self.assertEqual(source.search_endpoint, "/dci/disability/registry/sync/search") + self.assertEqual(source.search_endpoint, "/dci_api/v1/disability/registry/sync/search") self.assertTrue(source.active) def test_provider_links_to_data_source(self): diff --git a/spp_dci_openspp_dr/tests/test_openspp_dr_service.py b/spp_dci_openspp_dr/tests/test_openspp_dr_service.py index 2abc1c55..adad5f35 100644 --- a/spp_dci_openspp_dr/tests/test_openspp_dr_service.py +++ b/spp_dci_openspp_dr/tests/test_openspp_dr_service.py @@ -62,7 +62,7 @@ def setUpClass(cls): "registry_type": "DR", "vendor": "openspp", "base_url": "http://openspp-dr.test:8069", - "search_endpoint": "/dci/disability/registry/sync/search", + "search_endpoint": "/dci_api/v1/disability/registry/sync/search", "auth_type": "none", "our_sender_id": "openspp-sp.test", "receiver_id": "openspp-dr.test", diff --git a/spp_dci_server_disability/readme/CONFIGURE.md b/spp_dci_server_disability/readme/CONFIGURE.md index fd180a7f..57863ce0 100644 --- a/spp_dci_server_disability/readme/CONFIGURE.md +++ b/spp_dci_server_disability/readme/CONFIGURE.md @@ -1,10 +1,10 @@ ### After installing this module -The endpoint is live at `https:///dci/disability/registry/sync/search` (the `/dci` prefix comes from the FastAPI endpoint configuration on `spp_dci_server`). +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/disability/registry/sync/search` should now return HTTP 200 with a real SearchResponse (not 501). +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 diff --git a/spp_dci_server_disability/readme/DESCRIPTION.md b/spp_dci_server_disability/readme/DESCRIPTION.md index 770ae12b..f74e7017 100644 --- a/spp_dci_server_disability/readme/DESCRIPTION.md +++ b/spp_dci_server_disability/readme/DESCRIPTION.md @@ -1,4 +1,4 @@ -Server-side DCI Disability Registry implementation. Replaces the 501 stub at `/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. +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. @@ -6,7 +6,7 @@ This module turns an OpenSPP deployment into a DCI-compliant Disability Registry | Component | Purpose | | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | -| `routers/disability_router.py` | Real `/disability/registry/sync/search` handler; signs and returns a DCI envelope | +| `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 | From cd4ae4784934637a2465005adde1313c1d5aa337 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 15 May 2026 02:19:15 +0800 Subject: [PATCH 40/62] fix(spp_cel_dci_bridge): surface vendor field on data source views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Phase 6 hoist of the `vendor` selection field from spp_dci_openg2p to spp_cel_dci_bridge made the field available on every spp.dci.data.source record, but no view extension surfaced it on the form or list. Operators had no way to set vendor through the UI — manual psql or developer-mode field editing was the only path. Adds two inherited views in spp_cel_dci_bridge/views/dci_data_source_views.xml: - Form: inserts the vendor field after auth_type (the natural neighbor; both are "which adapter does the bridge use" decisions). - Tree: inserts a vendor column after registry_type with optional=show. Both presets' selection_add entries (openg2p, openspp) appear in the resulting dropdown automatically. --- spp_cel_dci_bridge/__manifest__.py | 1 + .../views/dci_data_source_views.xml | 39 +++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 spp_cel_dci_bridge/views/dci_data_source_views.xml diff --git a/spp_cel_dci_bridge/__manifest__.py b/spp_cel_dci_bridge/__manifest__.py index 0a183472..dc84e713 100644 --- a/spp_cel_dci_bridge/__manifest__.py +++ b/spp_cel_dci_bridge/__manifest__.py @@ -24,6 +24,7 @@ "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, 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..04f40817 --- /dev/null +++ b/spp_cel_dci_bridge/views/dci_data_source_views.xml @@ -0,0 +1,39 @@ + + + + + spp.dci.data.source.form.inherit.vendor + spp.dci.data.source + + + + + + + + + + spp.dci.data.source.tree.inherit.vendor + spp.dci.data.source + + + + + + + + From 7195b824ddfd27722ed9b01553ebd351f9814117 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 15 May 2026 02:58:13 +0800 Subject: [PATCH 41/62] fix(spp_dci_server_disability): sudo() registry lookups for public-user requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DCI FastAPI endpoint runs as base.public_user (spp_dci_server's fastapi_endpoint_data.xml). When a DCI search request arrives, DisabilitySearchService._find_partner_by_identifier raises AccessError trying to read spp.registry.id — public has no Registry permissions. Authentication is upstream (DCI signature + bearer token middleware), so once the request reaches the service the sender is trusted. The service queries reg_ids and the matched partner via sudo() to bypass the public-user ACL, mirroring the pattern in spp_dci_server/routers/search.py for signing-key reads. No tests change — test setUpClass runs as admin so ACLs were never exercised; the bug only surfaces against the live endpoint. --- .../services/disability_search_service.py | 29 +++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/spp_dci_server_disability/services/disability_search_service.py b/spp_dci_server_disability/services/disability_search_service.py index 5cd9494a..c4476970 100644 --- a/spp_dci_server_disability/services/disability_search_service.py +++ b/spp_dci_server_disability/services/disability_search_service.py @@ -209,13 +209,32 @@ def _find_partner_by_identifier(self, identifier_value: str): 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. """ - reg_id = self.env["spp.registry.id"].search( - [("value", "=", identifier_value)], - order="partner_id asc", - limit=1, + reg_id = ( + self.env["spp.registry.id"] + .sudo() # nosemgrep: odoo-sudo-without-context + .search( + [("value", "=", identifier_value)], + order="partner_id asc", + limit=1, + ) + ) + return ( + reg_id.partner_id.sudo() # nosemgrep: odoo-sudo-without-context + if reg_id + else self.env["res.partner"].sudo().browse() # nosemgrep: odoo-sudo-without-context ) - return reg_id.partner_id if reg_id else self.env["res.partner"].browse() # ------------------------------------------------------------------ # Reg-record construction From 96868aa3e5c7c980fbb72afe87414b5230a23fea Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 15 May 2026 03:58:25 +0800 Subject: [PATCH 42/62] feat(spp_dci_openg2p): rebind is_poor to income_level; park dependent var Verified against partner-nsr.play.openg2p.org on 2026-05-15: OpenG2P's SR reg_record exposes neither is_poor nor has_dependent_under_school_age as top-level fields. The closest poverty signal is `income_level` (string: "low" / "medium" / "high"); no signal for under-school-age dependents exists at all. Changes: - var_is_poor: value_type boolean -> string; dci_attribute_path is_poor -> income_level. CEL rules now read `is_poor == "low"` rather than `== true`. Variable name kept semantic. - var_has_dependent_under_school_age: parked state=inactive, active=False. Record stays registered as a deferred-feature placeholder so revival is a config-only change once OpenG2P exposes the data (or once we wire a secondary household-search call). Implementation: - post_init_hook's _PRESET_VARIABLES table extended to carry per-variable value_type and state. Hook now re-asserts both the active is_poor binding AND the inactive placeholder on every -i/-u, preventing UI edits from silently re-activating the deferred variable. - _EXPECTED_BINDING_FIELDS gains value_type so type drift is caught. - Tests updated: test_var_is_poor_bound_to_dci_provider asserts the new string/income_level pair; test_var_has_dependent_under_school_age_ parked_inactive replaces the prior active-state assertion; test_post_init_hook_parks_deferred_variable_inactive locks the hook's drag-back-to-inactive behaviour. - Dispatcher routing test now mocks {"income_level": "low", ...} and asserts the dispatcher surfaces the raw string to CEL. Docs: CONFIGURE.md gains a "Deferred features" table documenting why has_dependent_under_school_age is inactive and the path to revive it. --- spp_dci_openg2p/__init__.py | 65 +++++++++++++------ .../data/openg2p_cel_variables.xml | 52 +++++++++++---- spp_dci_openg2p/readme/CONFIGURE.md | 27 ++++++++ .../tests/test_dispatcher_routing.py | 11 +++- spp_dci_openg2p/tests/test_install.py | 38 +++++++---- 5 files changed, 148 insertions(+), 45 deletions(-) diff --git a/spp_dci_openg2p/__init__.py b/spp_dci_openg2p/__init__.py index b124ec6f..7fee72c2 100644 --- a/spp_dci_openg2p/__init__.py +++ b/spp_dci_openg2p/__init__.py @@ -13,6 +13,7 @@ "source_field", "external_provider_id", "dci_attribute_path", + "value_type", "cache_strategy", "cache_ttl_seconds", "external_failure_policy", @@ -21,16 +22,37 @@ ) -# (xml_id, dci_attribute_path) tuples for every CEL variable this preset binds -# to the OpenG2P SR provider. Add a row here when introducing a new -# SR-sourced variable; the rest of the hook handles drift correction -# uniformly. +# 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 = ( - ("spp_dci_openg2p.var_is_poor", "is_poor"), - ( - "spp_dci_openg2p.var_has_dependent_under_school_age", - "has_dependent_under_school_age", - ), + { + "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", + }, ) @@ -80,7 +102,8 @@ def post_init_hook(env): ) return - for xml_id, attribute_path in _PRESET_VARIABLES: + 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( @@ -91,23 +114,27 @@ def post_init_hook(env): ) continue + is_active = binding["state"] == "active" expected = { "source_type": "external", "source_field": False, "external_provider_id": provider.id, - "dci_attribute_path": attribute_path, + "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 + # state + active control whether the variable participates # in the resolver / precompute pipeline: - # - state='active' is the workflow status (Draft / Active - # / Inactive) used by spp_studio's lifecycle and CEL - # symbol visibility - # - active=True is the standard Odoo archived/unarchived - # flag used by precompute_cached_variables' search domain - "state": "active", - "active": True, + # - 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 = {} diff --git a/spp_dci_openg2p/data/openg2p_cel_variables.xml b/spp_dci_openg2p/data/openg2p_cel_variables.xml index ca2a18ef..3206f0ad 100644 --- a/spp_dci_openg2p/data/openg2p_cel_variables.xml +++ b/spp_dci_openg2p/data/openg2p_cel_variables.xml @@ -17,7 +17,7 @@ raise this. external_failure_policy=null: if OpenG2P is unreachable, the - subject evaluates against null (does not match `is_poor == true`). + subject evaluates against null (does not match the CEL filter). Compliance-critical rules can override to 'fail' on the variable. State + active are set explicitly so the variable goes from DRAFT @@ -31,23 +31,51 @@ NOTE: spp_studio.var_has_disability is intentionally NOT overridden here. Disability data lives in a separate OpenSPP-DR instance over - its own DCI link (ADR-024); the DR-side preset (spp_dci_openspp_dr, - Phase 6) binds has_disability to the DR provider. + its own DCI link (ADR-024); the DR-side preset (spp_dci_openspp_dr) + binds has_disability to the DR provider. + + FIELD MAPPING — OpenG2P SR record shape: + + The OpenG2P SR `reg_records[0]` exposes (verified against the + partner-nsr.play.openg2p.org playground on 2026-05-15): + + - member_identifier[] — JSON-LD identifier list + - demographic_info — name, sex, birth_date, contact + - is_disabled — Boolean, self-reported + - self_id_disability — Integer flag + - marital_status — String selection + - employment_status — String selection + - occupation — String + - income_level — String: "low" / "medium" / "high" + - additional_attributes[] — household_id, displacement_status, etc. + + No field named `is_poor` exists; `income_level == "low"` is the + closest available signal. This preset binds `is_poor` to + `income_level` (as a string) and the program's CEL rule uses + `is_poor == "low"`. + + No field captures `has_dependent_under_school_age` — OpenG2P's + per-individual record does not embed household composition with + dependent birth dates. Reviving this variable would require a + secondary OpenG2P endpoint call (household-search) or an OpenG2P + schema extension. The record stays here in inactive state as a + deferred-feature placeholder; see DESCRIPTION.md "Deferred + features". --> is_poor - Is Poor (OpenG2P SR) + Is Poor (OpenG2P SR income_level) Whether the registrant is classified as poor in the Social Registry. Sourced from OpenG2P over DCI; see ADR-024 for the federated topology. + >Poverty proxy sourced from OpenG2P SR'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. - boolean + string external res.partner is_poor individual - is_poor + income_level ttl 300 null @@ -60,10 +88,12 @@ model="spp.cel.variable" > has_dependent_under_school_age - Has Dependent Under School Age (OpenG2P SR) + Has Dependent Under School Age (DEFERRED — OpenG2P does not expose) Whether the registrant has at least one dependent below school-entry age. Sourced from OpenG2P over DCI; see ADR-024 for the federated topology. + >Deferred: OpenG2P's SR `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. ttl 300 null - active - + inactive + True diff --git a/spp_dci_openg2p/readme/CONFIGURE.md b/spp_dci_openg2p/readme/CONFIGURE.md index 08718d43..871d70f7 100644 --- a/spp_dci_openg2p/readme/CONFIGURE.md +++ b/spp_dci_openg2p/readme/CONFIGURE.md @@ -39,6 +39,33 @@ Partners with no matching identifier are recorded in `spp.dci.fetch.audit` as `r 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**). diff --git a/spp_dci_openg2p/tests/test_dispatcher_routing.py b/spp_dci_openg2p/tests/test_dispatcher_routing.py index c00d342b..65e247c6 100644 --- a/spp_dci_openg2p/tests/test_dispatcher_routing.py +++ b/spp_dci_openg2p/tests/test_dispatcher_routing.py @@ -88,10 +88,15 @@ def test_data_source_has_vendor_openg2p_and_registry_type_sr(self): @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).""" + ``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": [{"is_poor": True}]} + {"IND-NSR-0001": [{"income_level": "low", "is_disabled": False}]} ) mock_client_class.return_value = mock_client @@ -99,7 +104,7 @@ def test_openg2p_handler_extracts_attribute_path_from_reg_record(self, mock_clie self.variable, [self.partner_in_sr.id], "current" ) - self.assertEqual(result, {self.partner_in_sr.id: True}) + 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): diff --git a/spp_dci_openg2p/tests/test_install.py b/spp_dci_openg2p/tests/test_install.py index 73a32a63..9586f9f5 100644 --- a/spp_dci_openg2p/tests/test_install.py +++ b/spp_dci_openg2p/tests/test_install.py @@ -75,16 +75,23 @@ def test_var_is_poor_bound_to_dci_provider(self): self.assertEqual(variable.name, "is_poor") self.assertEqual(variable.cel_accessor, "is_poor") self.assertEqual(variable.source_type, "external") - self.assertEqual(variable.value_type, "boolean") + # 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, "is_poor") + 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_bound_to_dci_provider(self): + 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") @@ -96,8 +103,8 @@ def test_var_has_dependent_under_school_age_bound_to_dci_provider(self): variable.dci_attribute_path, "has_dependent_under_school_age", ) - self.assertEqual(variable.state, "active") - self.assertTrue(variable.active) + 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 @@ -163,24 +170,29 @@ def test_post_init_hook_re_asserts_after_reset(self): variable.invalidate_recordset() self.assertEqual(variable.source_type, "external") self.assertEqual(variable.external_provider_id, provider) - self.assertEqual(variable.dci_attribute_path, "is_poor") + 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_re_asserts_all_preset_variables(self): - """Both preset variables (is_poor AND has_dependent_under_school_age) - must be re-asserted by a single hook run. Resetting one and not the - other still produces a healthy state after the hook runs.""" + 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": "draft", + "state": "active", + "active": True, } ) @@ -192,7 +204,9 @@ def test_post_init_hook_re_asserts_all_preset_variables(self): self.assertEqual( var_dep.dci_attribute_path, "has_dependent_under_school_age" ) - self.assertEqual(var_dep.state, "active") + # 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 From 2ce3b83097219de4ff2827ecc3f9b7a01849f723 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 15 May 2026 04:42:45 +0800 Subject: [PATCH 43/62] docs: bring all federated-demo docs in line with the shipped implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audits ADR-023, ADR-024, the federated demo plan, and every module's readme fragments against the as-shipped code. Corrects stale facts and adds operational guidance discovered during end-to-end demo wiring on 2026-05-15. Key corrections: - ADR statuses: Proposed -> Accepted on both ADR-023 (bridge shipped) and ADR-024 (federated demo wired end-to-end). ADR-024 gains a resolution block for the original Open Items: is_poor binds to income_level (string compare); has_dependent_under_school_age is permanently deferred (no field on OpenG2P's per-individual record). - Federated demo plan: prepended a "Post-shipment deltas" section capturing every place the body drifted from implementation — OpenG2P host (partner-nsr vs partner-registry), DR endpoint path (/dci_api/v1 prefix), DR-side dev-mode flags, real DR field names, UIN seed ownership, standalone DR docker-compose project. - spp_dci_server_disability DESCRIPTION/CONFIGURE: replaced the fabricated wire-format JSON sample and Disability-fields table. Actual fields read are has_disability (Boolean from assessment chain), disability_severity_code, disability_review_category, disability_next_review — NOT is_person_with_disability / disability_certified / disability_percentage. Added the sudo() rationale and the dev-mode flag table. - spp_dci_openspp_dr CONFIGURE: documented both required dev-mode flags (dci.allow_unsigned_requests + dci.bypass_bearer_auth) and the UIN-seed dependency on spp_dci_openg2p (no longer ships its own UIN). - spp_dci_openg2p CONFIGURE: called out the partner-nsr host override that operators must apply manually (noupdate=1 means the XML default cannot be rewritten on upgrade). Added vendor field selection guidance. - spp_cel_dci_bridge DESCRIPTION/USAGE: documented the hoisted vendor field, the dci_data_source_views.xml, and the eager pre-warm behaviour with the inactive-variable opt-out. - spp_cel_dci_bridge/readme/USAGE.md: cel code-fence retitled to plain text (Pygments has no cel lexer; was blocking README.rst regeneration). Operator-facing example uses CEL && operator. Regenerated README.rst and static/description/index.html for every preset whose readme fragment changed (oca-gen-addon-readme). --- spp_cel_dci_bridge/README.rst | 109 ++- spp_cel_dci_bridge/readme/DESCRIPTION.md | 22 +- spp_cel_dci_bridge/readme/USAGE.md | 17 +- .../static/description/index.html | 695 ++++++++++++++++++ spp_dci_openg2p/README.rst | 283 ++++--- spp_dci_openg2p/readme/CONFIGURE.md | 11 +- spp_dci_openg2p/static/description/index.html | 283 ++++--- spp_dci_openspp_dr/README.rst | 230 ++++++ spp_dci_openspp_dr/readme/CONFIGURE.md | 17 + .../static/description/index.html | 574 +++++++++++++++ spp_dci_server_disability/README.rst | 236 ++++++ spp_dci_server_disability/readme/CONFIGURE.md | 28 +- .../readme/DESCRIPTION.md | 14 +- .../static/description/index.html | 612 +++++++++++++++ 14 files changed, 2865 insertions(+), 266 deletions(-) create mode 100644 spp_cel_dci_bridge/static/description/index.html create mode 100644 spp_dci_openspp_dr/README.rst create mode 100644 spp_dci_openspp_dr/static/description/index.html create mode 100644 spp_dci_server_disability/README.rst create mode 100644 spp_dci_server_disability/static/description/index.html diff --git a/spp_cel_dci_bridge/README.rst b/spp_cel_dci_bridge/README.rst index 5bf1cd74..da0f96f1 100644 --- a/spp_cel_dci_bridge/README.rst +++ b/spp_cel_dci_bridge/README.rst @@ -68,23 +68,59 @@ Key Models 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 | -+-----------------------+-----------------------------+----------------------------------+ ++-------------------------+-----------------------------+----------------------------------+ +| 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 ~~~~~~~~~~~~ @@ -145,9 +181,9 @@ 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. -.. code:: cel +:: - has_disability == true and age_years(r.birthdate) >= 18 + 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 @@ -158,9 +194,15 @@ 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. Create a ``spp.data.provider`` and set ``dci_data_source_id`` to the +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. -3. Create or repurpose a ``spp.cel.variable``: +4. Create or repurpose a ``spp.cel.variable``: - ``source_type = 'external'`` - ``external_provider_id`` = the provider @@ -172,8 +214,29 @@ Configuring a DCI-backed variable manually production) - ``external_failure_policy`` = null / last_known / fail -For typical OpenG2P deployments, install ``spp_dci_openg2p`` instead of -doing the above by hand — it ships a permanent preset. +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 ~~~~~~~~~~~~~~~~ diff --git a/spp_cel_dci_bridge/readme/DESCRIPTION.md b/spp_cel_dci_bridge/readme/DESCRIPTION.md index 19b04752..2d2a442c 100644 --- a/spp_cel_dci_bridge/readme/DESCRIPTION.md +++ b/spp_cel_dci_bridge/readme/DESCRIPTION.md @@ -18,12 +18,22 @@ Bridges OpenSPP's CEL expression engine to external DCI registries. CEL eligibil ### 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 | +| 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 diff --git a/spp_cel_dci_bridge/readme/USAGE.md b/spp_cel_dci_bridge/readme/USAGE.md index 170738f9..1e18dc38 100644 --- a/spp_cel_dci_bridge/readme/USAGE.md +++ b/spp_cel_dci_bridge/readme/USAGE.md @@ -2,8 +2,8 @@ CEL accessors are **vendor-neutral**. The eligibility rule reads the semantic concept; the vendor identity lives in configuration records. -```cel -has_disability == true and age_years(r.birthdate) >= 18 +``` +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. @@ -11,8 +11,9 @@ The bridge does not change CEL syntax. To switch from one DCI registry to anothe ### 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. Create a `spp.data.provider` and set `dci_data_source_id` to the source above. -3. Create or repurpose a `spp.cel.variable`: +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`) @@ -20,7 +21,13 @@ The bridge does not change CEL syntax. To switch from one DCI registry to anothe - `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` instead of doing the above by hand — it ships a permanent preset. +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 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_dci_openg2p/README.rst b/spp_dci_openg2p/README.rst index 48fb2b67..d15a2724 100644 --- a/spp_dci_openg2p/README.rst +++ b/spp_dci_openg2p/README.rst @@ -25,46 +25,65 @@ OpenSPP DCI - OpenG2P Preset 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 Disability Registry gets the wiring out of the box. Config-only in -v1 — zero Python code. +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`` | DCI data source: base URL, | -| 'openg2p_dr' | sender ID, registry_type=DR | -+-----------------------------------+----------------------------------+ -| ``spp.data.provider`` | CEL-side provider linked to the | -| 'openg2p_dr' | DCI source | -+-----------------------------------+----------------------------------+ -| ``spp_studio.var_has_disability`` | The semantic ``has_disability`` | -| (override) | CEL accessor, repointed at the | -| | DCI provider | -+-----------------------------------+----------------------------------+ - -The CEL accessor name stays vendor-neutral (``has_disability``, per -ADR-023 §1a). The OpenG2P-ness lives only in the data source and -provider records. Repointing at a different DCI Disability Registry is a -configuration change on the data source, never a CEL change. ++--------------------------------------+------------------------------------+ +| 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 - ``has_disability`` CEL accessor) -- Python code (any OpenG2P-specific behavioural quirk that emerges in - the future would be added here as adapter code; v1 stays pure config) +- 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 registry-type DCI -client (``spp_dci_client_dr``), not a DCI client itself: +``spp_dci_openg2p`` is a vendor preset on top of the bridge, not a DCI +client itself: :: @@ -72,19 +91,17 @@ client (``spp_dci_client_dr``), not a DCI client itself: depends on spp_cel_dci_bridge (registry-agnostic CEL <-> DCI infrastructure) depends on - spp_dci_client_dr (DCI client for the Disability Registry type) - depends on spp_dci_client (base DCI client) -Other DCI Disability Registries (e.g., a national DR) would ship as -separate sibling preset modules (``spp_dci_``), reusing -``spp_cel_dci_bridge`` and ``spp_dci_client_dr``. +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 @@ -104,20 +121,33 @@ After installing this module ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The preset auto-creates a DCI data source, CEL provider, and CEL -variable wired against the OpenG2P playground at -``partner-registry.play.openg2p.org``. The playground does not require -authentication for the demo — the bridge can call it out of the box. +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. -3. Verify (or adjust) **Base URL** — defaults to - ``https://partner-registry.play.openg2p.org``. -4. The **Search Endpoint** is set to ``/dci/registry/sync/search`` - (OpenG2P uses the ``/dci`` prefix). +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. Click **Test Connection**. State should flip to ``Active``. +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``, @@ -125,96 +155,133 @@ to ``oauth2`` and populate ``oauth2_token_url``, ``oauth2_client_id``, > Configuration > Signing Keys** if the deployment requires signed messages. -FR-as-DR pretense (demo-only) -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -The OpenG2P playground exposes a **Farmer Registry** (FR), not a -Disability Registry (DR). Per the SPDCI schema: - -:: +OpenG2P plays the Social Registry role +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - reg_type: ns:org:RegistryType:Social - reg_record_type: spdci-extensions-dci:Farmer +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). -Until OpenG2P publishes a real DR endpoint, this preset treats FR as a -DR stand-in: +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: -- The data source is configured with ``registry_type='DR'`` so the - bridge dispatcher routes to the standard ``_handler_dr``. -- ``vendor='openg2p'`` on the data source triggers the preset's - dispatcher override, which uses ``OpenG2PFRService`` instead of - upstream ``DRService``. -- ``OpenG2PFRService`` queries OpenG2P's Farmer Registry. **Presence of - any farmer record for a partner → ``has_disability=True``**. Absence - (or ``REG-ERR-001 REGISTER_NOT_FOUND``) → null → fails the eligibility - filter. -- The CEL surface stays exactly ``has_disability == true``. Only this - service's interpretation is the pretense. +- ``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``) -Audience-facing this looks like a real DR lookup. Operationally it tests -the full DCI round-trip with OpenG2P's actual playground. +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 identifiers that exist in their Farmer -Registry. Configure your test partners with those identifiers (under -their **External Identifiers** / ``reg_ids``), and the dispatcher's -``OpenG2PFRService._get_partner_identifier`` priority order will pick -them up: +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 -``has_disability == true`` matches. - -Migration plan — when OpenG2P publishes a real Disability Registry -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -The migration is purely configuration; no code or data changes: - -+----------------------------------+-------------------------------------------------------------------+ -| Step | What to change | -+==================================+===================================================================+ -| 1. Point at the new URL | Edit ``base_url`` on ``openg2p_dr`` data source (UI) | -+----------------------------------+-------------------------------------------------------------------+ -| 2. Switch from FR pretense to | Clear the ``vendor`` field on the data source (set blank). The | -| real DR | dispatcher's override falls through to the standard | -| | ``_handler_dr`` → upstream ``DRService``. | -+----------------------------------+-------------------------------------------------------------------+ -| 3. Verify OpenG2P's DR conforms | Run a search; if you get | -| to standard DCI shapes | ``rjct.search_criteria.invalid: query.value.id_type is required`` | -| | or response unwrap fails, OpenG2P's DR has the same | -| | query/response quirks as their FR. Keep ``vendor='openg2p'`` set | -| | and extend ``OpenG2PFRService`` to query the DR | -| | ``reg_record_type``. Track this in ADR-023 v2 work. | -+----------------------------------+-------------------------------------------------------------------+ -| 4. The CEL accessor stays | No CEL rule changes. Cached values will become real | -| ``has_disability`` | ``has_disability`` booleans from the DR record. | -+----------------------------------+-------------------------------------------------------------------+ - -In words: clear one field on the data source, and OpenSPP starts reading -real disability data from OpenG2P with no other edits anywhere. +``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 the -``has_disability`` variable so the DCI round-trip is visible during -demos. For production, raise to 86400 (24h) or higher via the -``spp_studio.var_has_disability`` form. +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 DCI Disability Registry vendor -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Switching to a different SR vendor +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -If you target a non-OpenG2P registry, the preset is the wrong starting -point — clone it as ``spp_dci_`` and adjust: +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 ``OpenG2PFRService`` for that vendor's +- The service class (mirror ``OpenG2PSocialService`` for that vendor's quirks) - The dispatcher override's branch diff --git a/spp_dci_openg2p/readme/CONFIGURE.md b/spp_dci_openg2p/readme/CONFIGURE.md index 871d70f7..5ee9bc38 100644 --- a/spp_dci_openg2p/readme/CONFIGURE.md +++ b/spp_dci_openg2p/readme/CONFIGURE.md @@ -1,13 +1,14 @@ ### After installing this module -The preset auto-creates a DCI data source, CEL provider, and CEL variable wired against the OpenG2P playground at `partner-registry.play.openg2p.org`. The playground does not require authentication for the demo — the bridge can call it out of the box. +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). -3. Verify (or adjust) **Base URL** — defaults to `https://partner-registry.play.openg2p.org`. -4. The **Search Endpoint** is set to `/dci/registry/sync/search` (OpenG2P uses the `/dci` prefix). +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. Click **Test Connection**. State should flip to `Active`. +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. diff --git a/spp_dci_openg2p/static/description/index.html b/spp_dci_openg2p/static/description/index.html index e82ca92e..2377b404 100644 --- a/spp_dci_openg2p/static/description/index.html +++ b/spp_dci_openg2p/static/description/index.html @@ -373,8 +373,9 @@

OpenSPP DCI - OpenG2P Preset

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 Disability Registry gets the wiring out of the box. Config-only in -v1 — zero Python code.

+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

@@ -388,62 +389,78 @@

What this module ships

- - + + - + - - + + + + + + + + + + +
spp.dci.data.source -‘openg2p_dr’DCI data source: base URL, -sender ID, registry_type=DR
spp.dci.data.source ‘openg2p_dr’DCI data source: base URL, sender +ID, registry_type=SR
spp.data.provider -‘openg2p_dr’
spp.data.provider ‘openg2p_dr’ CEL-side provider linked to the DCI source
spp_studio.var_has_disability -(override)The semantic has_disability -CEL accessor, repointed at the -DCI provider
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]
-

The CEL accessor name stays vendor-neutral (has_disability, per -ADR-023 §1a). The OpenG2P-ness lives only in the data source and -provider records. Repointing at a different DCI Disability Registry is a -configuration change on the data source, never a CEL change.

+

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 -has_disability CEL accessor)
  • -
  • Python code (any OpenG2P-specific behavioural quirk that emerges in -the future would be added here as adapter code; v1 stays pure config)
  • +
  • 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 registry-type DCI -client (spp_dci_client_dr), not a DCI client itself:

+

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_dr      (DCI client for the Disability Registry type)
-    depends on
 spp_dci_client         (base DCI client)
 
-

Other DCI Disability Registries (e.g., a national DR) would ship as -separate sibling preset modules (spp_dci_<vendor>), reusing -spp_cel_dci_bridge and spp_dci_client_dr.

+

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
@@ -465,19 +482,32 @@

Configuration

After installing this module

The preset auto-creates a DCI data source, CEL provider, and CEL -variable wired against the OpenG2P playground at -partner-registry.play.openg2p.org. The playground does not require -authentication for the demo — the bridge can call it out of the box.

+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.
  4. -
  5. Verify (or adjust) Base URL — defaults to -https://partner-registry.play.openg2p.org.
  6. -
  7. The Search Endpoint is set to /dci/registry/sync/search -(OpenG2P uses the /dci prefix).
  8. +
  9. 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.
  10. +
  11. 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.
  12. +
  13. The Search Endpoint is /dci/registry/sync/search (OpenG2P +uses the /dci prefix).
  14. Sender ID / Receiver ID — placeholder values are pre-populated. Replace with what the OpenG2P operator expects from your deployment.
  15. +
  16. 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.
  17. Click Test Connection. State should flip to Active.

For real OpenG2P deployments (not the playground), change auth_type @@ -486,105 +516,140 @@

After installing this module

> Configuration > Signing Keys if the deployment requires signed messages.

-
-

FR-as-DR pretense (demo-only)

-

The OpenG2P playground exposes a Farmer Registry (FR), not a -Disability Registry (DR). Per the SPDCI schema:

-
-reg_type:        ns:org:RegistryType:Social
-reg_record_type: spdci-extensions-dci:Farmer
-
-

Until OpenG2P publishes a real DR endpoint, this preset treats FR as a -DR stand-in:

+
+

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:

    -
  • The data source is configured with registry_type='DR' so the -bridge dispatcher routes to the standard _handler_dr.
  • -
  • vendor='openg2p' on the data source triggers the preset’s -dispatcher override, which uses OpenG2PFRService instead of -upstream DRService.
  • -
  • OpenG2PFRService queries OpenG2P’s Farmer Registry. Presence of -any farmer record for a partner → ``has_disability=True``. Absence -(or REG-ERR-001 REGISTER_NOT_FOUND) → null → fails the eligibility -filter.
  • -
  • The CEL surface stays exactly has_disability == true. Only this -service’s interpretation is the pretense.
  • +
  • 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)
-

Audience-facing this looks like a real DR lookup. Operationally it tests -the full DCI round-trip with OpenG2P’s actual playground.

+

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 identifiers that exist in their Farmer -Registry. Configure your test partners with those identifiers (under -their External Identifiers / reg_ids), and the dispatcher’s -OpenG2PFRService._get_partner_identifier priority order will pick -them up:

+

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 -has_disability == true matches.

+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.

-
-

Migration plan — when OpenG2P publishes a real Disability Registry

-

The migration is purely configuration; no code or data changes:

+
+

Deferred features

--+++ - - + + + - - - - - - - - - - - + + +
StepWhat to change
VariableReasonPath to revive
    -
  1. Point at the new URL
  2. -
-
Edit base_url on openg2p_dr data source (UI)
2. Switch from FR pretense to -real DRClear the vendor field on the data source (set blank). The -dispatcher’s override falls through to the standard -_handler_dr → upstream DRService.
3. Verify OpenG2P’s DR conforms -to standard DCI shapesRun a search; if you get -rjct.search_criteria.invalid: query.value.id_type is required -or response unwrap fails, OpenG2P’s DR has the same -query/response quirks as their FR. Keep vendor='openg2p' set -and extend OpenG2PFRService to query the DR -reg_record_type. Track this in ADR-023 v2 work.
4. The CEL accessor stays -has_disabilityNo CEL rule changes. Cached values will become real -has_disability booleans from the DR record.
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.
-

In words: clear one field on the data source, and OpenSPP starts reading -real disability data from OpenG2P with no other edits anywhere.

+

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 the -has_disability variable so the DCI round-trip is visible during -demos. For production, raise to 86400 (24h) or higher via the -spp_studio.var_has_disability form.

+

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 DCI Disability Registry vendor

-

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

+
+

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 OpenG2PFRService for that vendor’s +
  • The service class (mirror OpenG2PSocialService for that vendor’s quirks)
  • The dispatcher override’s branch
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/readme/CONFIGURE.md b/spp_dci_openspp_dr/readme/CONFIGURE.md index ad5fb729..70dd54e1 100644 --- a/spp_dci_openspp_dr/readme/CONFIGURE.md +++ b/spp_dci_openspp_dr/readme/CONFIGURE.md @@ -27,6 +27,23 @@ The preset auto-creates a DCI data source, CEL provider, and `has_disability` va 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: 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_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/readme/CONFIGURE.md b/spp_dci_server_disability/readme/CONFIGURE.md index 57863ce0..2be1ff9b 100644 --- a/spp_dci_server_disability/readme/CONFIGURE.md +++ b/spp_dci_server_disability/readme/CONFIGURE.md @@ -19,12 +19,26 @@ The service looks up partners by `spp.registry.id.value == search_text`. Match t ### Disability fields -The service reads three fields from `res.partner`: +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]` | -| ---------------------------- | ----------------------------------- | -| `is_person_with_disability` | `has_disability` | -| `disability_certified` | `disability_certified` | -| `disability_percentage` | `disability_percentage` | +| 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 — modules that define these fields are not strict dependencies of this server module. +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 index f74e7017..41b602d5 100644 --- a/spp_dci_server_disability/readme/DESCRIPTION.md +++ b/spp_dci_server_disability/readme/DESCRIPTION.md @@ -17,14 +17,22 @@ Each successful response item carries `reg_records[0]` as: ```json { "has_disability": true, - "disability_certified": false, - "disability_percentage": 50.0, + "disability_severity_code": "moderate", + "disability_review_category": "annual", + "disability_next_review": "2027-01-15", "partner_name": "Maria Santos", "partner_uid": 12345 } ``` -The CEL-bridge SP side reads `has_disability` (not the local field name `is_person_with_disability`) — the mapping happens here. +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 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.

+
+
+
+
+ + From af64ddb5e2a04ef099f84d11f1202679b40d2d73 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 15 May 2026 04:44:39 +0800 Subject: [PATCH 44/62] chore: add pyproject.toml stubs for the two new federated-demo modules Matches the whool-based packaging stub every other spp_* module ships. Auto-generated; included to keep the new modules consistent with the rest of the repo's Python-packaging convention. --- spp_dci_openspp_dr/pyproject.toml | 3 +++ spp_dci_server_disability/pyproject.toml | 3 +++ 2 files changed, 6 insertions(+) create mode 100644 spp_dci_openspp_dr/pyproject.toml create mode 100644 spp_dci_server_disability/pyproject.toml 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_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" From 9d688a5b462c8eb9b21f62819af565abbad3c030 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 15 May 2026 16:52:43 +0800 Subject: [PATCH 45/62] demo(scripts): one-shot seed for SPDCI federated dry-run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creates 4 demo personas (Maria Widow, Kim Lee, Priya Rivera, Noah Rivera) on both sides of the federated topology. UINs match real OpenG2P SR seeds (IND-NSR-0001/0002/0003/0007) so the SP-side is_poor lookup returns a live income_level. DR side gets approved disability assessments for the two flagged personas. NOT A MODULE. Production module installs create zero registrants — this is an out-of-band seed script run ONLY before a demo. Designed to be deleted along with its partner records after the demo. Usage: docker compose exec openspp-dev odoo shell -d openspp --no-http \ < scripts/demo/setup_federated_demo.py docker compose -f docker-compose.dr.yml exec openspp-dr \ odoo shell -d openspp_dr --no-http \ < scripts/demo/setup_federated_demo.py Idempotent on rerun. Cleanup recipe included in the script docstring. --- scripts/demo/setup_federated_demo.py | 165 +++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 scripts/demo/setup_federated_demo.py diff --git a/scripts/demo/setup_federated_demo.py b/scripts/demo/setup_federated_demo.py new file mode 100644 index 00000000..46c1442e --- /dev/null +++ b/scripts/demo/setup_federated_demo.py @@ -0,0 +1,165 @@ +# ============================================================================ +# 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_federated_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_federated_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: 4 res.partner records with UIN reg_ids matching OpenG2P +# SR seeds (IND-NSR-0001, 0002, 0003, 0007). +# - DR side: same 4 partners (same UINs) PLUS approved disability +# assessments for two of them so their res.partner.has_disability +# computes to True. +# +# DEMO MATRIX (after running on both sides): +# +# | Persona | UIN | SR income_level | DR assessment | Expected verdict | +# |--------------------|--------------|-----------------|---------------|------------------| +# | Maria Widow | IND-NSR-0001 | low | approved | ENROLLED | +# | Kim Lee | IND-NSR-0007 | medium | approved | not eligible | +# | Priya Rivera | IND-NSR-0002 | low | none | not eligible | +# | Noah Rivera | IND-NSR-0003 | (empty) | none | not eligible | +# +# With CEL rule `has_disability == true && is_poor == "low"`: +# - Only Maria passes (both registries return the right value) +# - Kim fails is_poor (medium != low) +# - Priya fails has_disability (no approved DR assessment) +# - Noah fails both +# ============================================================================ + +import logging +from odoo import fields + +_logger = logging.getLogger("setup_federated_demo") + +# (UIN, given_name, surname, has_dr_assessment) +DEMO_PERSONAS = [ + ("IND-NSR-0001", "Maria", "Widow", True), # eligible + ("IND-NSR-0007", "Kim", "Lee", True), # not poor (medium) + ("IND-NSR-0002", "Priya", "Rivera", False), # poor but not disabled + ("IND-NSR-0003", "Noah", "Rivera", False), # neither +] + +# 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"] + +for uin, given, surname, has_dr_assessment in DEMO_PERSONAS: + # Idempotent: skip if a partner already has this UIN + existing = RegId.search([("value", "=", uin), ("id_type_id", "=", uin_code.id)], limit=1) + if existing: + partner = existing.partner_id + print(f" ↻ {uin} already exists on this side as partner.id={partner.id} ({partner.name})") + else: + partner = Partner.create({ + "name": f"{given} {surname}", + "given_name": given, + "family_name": surname, + "is_registrant": True, + "is_group": False, + "birthdate": "1990-01-01", + }) + 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})") + + # 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})") + +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 = ['IND-NSR-0001', 'IND-NSR-0007', 'IND-NSR-0002', 'IND-NSR-0003']") +print(" >>> partners = RegId.search([('value', 'in', uins)]).mapped('partner_id')") +print(" >>> partners.unlink()") +print(" >>> env.cr.commit()") From b28b4197ef45616c573c552f765f7721132050f2 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 15 May 2026 16:55:16 +0800 Subject: [PATCH 46/62] chore(demo): rename seed script to setup_spdci_demo.py --- scripts/demo/{setup_federated_demo.py => setup_spdci_demo.py} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename scripts/demo/{setup_federated_demo.py => setup_spdci_demo.py} (98%) diff --git a/scripts/demo/setup_federated_demo.py b/scripts/demo/setup_spdci_demo.py similarity index 98% rename from scripts/demo/setup_federated_demo.py rename to scripts/demo/setup_spdci_demo.py index 46c1442e..b43be450 100644 --- a/scripts/demo/setup_federated_demo.py +++ b/scripts/demo/setup_spdci_demo.py @@ -9,12 +9,12 @@ # # RUN ON SP: # docker compose exec openspp-dev odoo shell -d openspp --no-http \ -# < scripts/demo/setup_federated_demo.py +# < 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_federated_demo.py +# < scripts/demo/setup_spdci_demo.py # # WHAT THIS IS NOT: # - Not a module. Production installs of any spp_* module create zero From c363af87d0355fbe508802ee5d0e8b96ddcc515f Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 15 May 2026 16:58:12 +0800 Subject: [PATCH 47/62] demo(scripts): extend SPDCI seed to all 15 OpenG2P SR personas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Was creating 4 partners; OpenG2P has 15 seeded records (IND-NSR-0001.. IND-NSR-0015). Wiring every UIN gives the demo a complete eligibility matrix: - 4 partners ENROLLED (poor + disabled across both registries) - 3 partners fail has_disability (poor on SR, no DR assessment) - 4 partners fail is_poor (disabled on DR, non-low income on SR) - 4 partners fail both Names mirror OpenG2P's actual seed values (Alex Rivera, Morgan Cole, Taylor Brooks, etc.) so the federation story stays honest — an SP audit row tagged "Alex Rivera" matches what OpenG2P returns on probe. Cleanup recipe updated to iterate the full 15-UIN range. --- scripts/demo/setup_spdci_demo.py | 76 ++++++++++++++++++++++---------- 1 file changed, 53 insertions(+), 23 deletions(-) diff --git a/scripts/demo/setup_spdci_demo.py b/scripts/demo/setup_spdci_demo.py index b43be450..93daea40 100644 --- a/scripts/demo/setup_spdci_demo.py +++ b/scripts/demo/setup_spdci_demo.py @@ -25,39 +25,69 @@ # cleanly, first delete the partners (see CLEANUP at the bottom). # # WHAT IT CREATES: -# - SP side: 4 res.partner records with UIN reg_ids matching OpenG2P -# SR seeds (IND-NSR-0001, 0002, 0003, 0007). -# - DR side: same 4 partners (same UINs) PLUS approved disability -# assessments for two of them so their res.partner.has_disability -# computes to True. +# - 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). +# - 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 (after running on both sides): +# DEMO MATRIX (with CEL rule `has_disability == true && is_poor == "low"`): # -# | Persona | UIN | SR income_level | DR assessment | Expected verdict | -# |--------------------|--------------|-----------------|---------------|------------------| -# | Maria Widow | IND-NSR-0001 | low | approved | ENROLLED | -# | Kim Lee | IND-NSR-0007 | medium | approved | not eligible | -# | Priya Rivera | IND-NSR-0002 | low | none | not eligible | -# | Noah Rivera | IND-NSR-0003 | (empty) | none | not eligible | +# | 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** | # -# With CEL rule `has_disability == true && is_poor == "low"`: -# - Only Maria passes (both registries return the right value) -# - Kim fails is_poor (medium != low) -# - Priya fails has_disability (no approved DR assessment) -# - Noah fails both +# * = 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. # ============================================================================ import logging from odoo import fields -_logger = logging.getLogger("setup_federated_demo") +_logger = logging.getLogger("setup_spdci_demo") -# (UIN, given_name, surname, has_dr_assessment) +# 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", "Maria", "Widow", True), # eligible - ("IND-NSR-0007", "Kim", "Lee", True), # not poor (medium) - ("IND-NSR-0002", "Priya", "Rivera", False), # poor but not disabled + ("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 @@ -159,7 +189,7 @@ 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 = ['IND-NSR-0001', 'IND-NSR-0007', 'IND-NSR-0002', 'IND-NSR-0003']") +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()") From 024698d532f0ef8193047af27190ff4df6703515 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 15 May 2026 17:04:15 +0800 Subject: [PATCH 48/62] demo(scripts): rename in-place + auto-enroll into demo program MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two operational tweaks for tonight's dry-run: 1. RENAME instead of skip when a UIN reg_id already exists on the partner. Edwin's existing IND-NSR-0001 partner is already attached to programs (memberships, change requests) so unlink would orphan that state. Writing name/given_name/family_name in-place rebrands the existing record while preserving its FK relationships. 2. SP-side only: add every demo partner as a draft membership of spp.program record id=DEMO_PROGRAM_ID (default 1). This lets the operator demo Enroll Eligible directly — no need to walk through the change-request flow to register members first (a colleague demonstrates that on a separate instance). Memberships start in state='draft'; eligibility evaluation flips them based on the CEL rule. DEMO_PROGRAM_ID is a named constant at the top of the script so operators with a non-1 program id can override before running. --- scripts/demo/setup_spdci_demo.py | 80 +++++++++++++++++++++++++++----- 1 file changed, 69 insertions(+), 11 deletions(-) diff --git a/scripts/demo/setup_spdci_demo.py b/scripts/demo/setup_spdci_demo.py index 93daea40..8884b484 100644 --- a/scripts/demo/setup_spdci_demo.py +++ b/scripts/demo/setup_spdci_demo.py @@ -29,7 +29,15 @@ # 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). +# 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. @@ -67,6 +75,12 @@ _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 @@ -121,27 +135,39 @@ 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: - # Idempotent: skip if a partner already has this UIN + # 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 - print(f" ↻ {uin} already exists on this side as partner.id={partner.id} ({partner.name})") + 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({ - "name": f"{given} {surname}", - "given_name": given, - "family_name": surname, - "is_registrant": True, - "is_group": False, - "birthdate": "1990-01-01", - }) + 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. @@ -169,6 +195,38 @@ 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 From fc37f77d1a11c19847def69c7b1989ebfbee3f6f Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 15 May 2026 17:12:50 +0800 Subject: [PATCH 49/62] demo(scripts): add reset script for repeated SPDCI demo runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resets the 15 demo memberships on program id=1 back to state='draft' and wipes the DCI value cache for the demo partners. Lets the operator re-run Enroll Eligible multiple times during a presentation without manually resetting each membership through the UI. WIPE_DCI_CACHE constant at the top toggles whether the script also flushes the cache (default True). With cache wiped, the next eligibility click fires fresh DCI queries to OpenG2P SR and OpenSPP-DR — useful when the demo audience should see the live round-trip in the SP log. Same scripts/demo/ pattern as setup_spdci_demo.py — out-of-band, not a module, not shipped to production. --- scripts/demo/reset_spdci_demo.py | 89 ++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 scripts/demo/reset_spdci_demo.py diff --git a/scripts/demo/reset_spdci_demo.py b/scripts/demo/reset_spdci_demo.py new file mode 100644 index 00000000..df70f53d --- /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.) +# ============================================================================ + +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} " + f"{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") From 503e7fd7b27e8857c63948b32c6ae1116dda6859 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 15 May 2026 17:41:46 +0800 Subject: [PATCH 50/62] fix(spp_cel_domain): AND all metric SQL overrides on compound expressions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CEL executor's top-level compile_and_preview took only the FIRST override_domain from metrics_info, breaking compound expressions of the form `metric('a', me) == X and metric('b', me) == Y`. Each MetricCompare's SQL fast path successfully built its subquery clause and pushed it to metrics_info, but only the first was used in the final domain — silently dropping the AND'd second clause. Symptom in tonight's SPDCI dry-run: rule `has_disability == true && is_poor == "low"` evaluated as just `has_disability == true`, so 8 partners enrolled (all with DR assessments) instead of the expected 4 (intersection with low-income). Fix: collect ALL override_domains from metrics_info and AND them on final_domain. Single-metric case is unchanged (one clause is identical to "take the first"). Known limitation: an OR of metric clauses still mis-composes — the per- clause SQL subqueries cannot be UNION'd through Odoo domain syntax, so ids materialization in _exec_metric would be needed for correctness. Not addressed here; OR-of-metrics is uncommon in eligibility rules and not in scope for the federated demo. 573 spp_cel_domain tests + 67 spp_cel_dci_bridge tests still pass. --- spp_cel_domain/models/cel_executor.py | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/spp_cel_domain/models/cel_executor.py b/spp_cel_domain/models/cel_executor.py index 79ab7770..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 From 4fc85447751a8bfa204b5973c590b5b36ec62545 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 15 May 2026 18:06:04 +0800 Subject: [PATCH 51/62] docs(demo): add SPDCI demo briefing sheet for the presentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single-page reference covering: - The 15-persona demo matrix with each registrant's UIN, OpenG2P income_level, OpenSPP-DR has_disability, and expected eligibility verdict — the four ENROLLED rows are highlighted. - ASCII topology diagram showing SP, DR, and OpenG2P SR with the DCI search-sync flows between them. - Glossary of every acronym used in the demo (SPDCI, DCI, search-sync, SR, DR, CRVS, IBR, FR, CEL, CEL accessor, metric() call, spp.dci.data.source / data.provider / dci.dispatcher / data.value / dci.fetch.audit, vendor adapter, MOSIP, eSignet, OpenG2P). - Cross-references to ADRs and demo scripts. Located alongside the seed/reset scripts in scripts/demo/ so the operator's presentation kit is in one place. --- scripts/demo/SPDCI_DEMO_BRIEFING.md | 139 ++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 scripts/demo/SPDCI_DEMO_BRIEFING.md diff --git a/scripts/demo/SPDCI_DEMO_BRIEFING.md b/scripts/demo/SPDCI_DEMO_BRIEFING.md new file mode 100644 index 00000000..6c0ed00f --- /dev/null +++ b/scripts/demo/SPDCI_DEMO_BRIEFING.md @@ -0,0 +1,139 @@ +# 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) From ef2049d7f6ad27c7fe941e5cb1a8e457ec63e2f5 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 15 May 2026 18:19:46 +0800 Subject: [PATCH 52/62] chore: pre-commit autofixes + linter compliance pass on branch Sweep of all touched files against the project's pre-commit suite: - ruff-format + prettier formatting (consistent line-length and multi-line wrapping across services, tests, demo scripts, view XMLs). - C8107 translation-required: wrap user-facing UserError / ValidationError messages in self.env._() so they participate in Odoo's translation pipeline. Applied in OpenG2PSocialService and OpenSPPDRService. - Updated the stale top-of-file docstring on spp_dci_server_disability/services/disability_search_service.py to describe the actual fields read (has_disability boolean from the approved assessment + severity/review-category/next-review) instead of the deleted is_person_with_disability/disability_certified/ disability_percentage names. - Repositioned the sudo() nosemgrep markers in disability_search_service._find_partner_by_identifier so the odoo-sudo-on-sensitive-models rule also recognizes them. Added an inline authorization-context comment block explaining why sudo() is the right call here (upstream signature/bearer middleware is the real auth boundary; the service surface is read-only). All 705 tests across the five touched modules still pass (spp_cel_domain 573, spp_cel_dci_bridge 67, spp_dci_openg2p 34, spp_dci_openspp_dr 20, spp_dci_server_disability 11). Pre-commit overall returns exit 0. --- scripts/demo/SPDCI_DEMO_BRIEFING.md | 157 ++++++++++++------ scripts/demo/reset_spdci_demo.py | 16 +- scripts/demo/setup_spdci_demo.py | 108 +++++++----- .../views/dci_data_source_views.xml | 10 +- .../data/openg2p_cel_variables.xml | 10 +- .../services/openg2p_social_service.py | 7 +- spp_dci_openg2p/tests/test_install.py | 9 +- spp_dci_openspp_dr/__init__.py | 8 +- .../data/openspp_dr_data_source.xml | 4 +- .../services/openspp_dr_service.py | 7 +- .../tests/test_dispatcher_routing.py | 12 +- .../tests/test_openspp_dr_service.py | 4 +- .../routers/disability_router.py | 4 +- .../services/disability_search_service.py | 54 +++--- .../tests/test_disability_search_service.py | 28 +--- 15 files changed, 230 insertions(+), 208 deletions(-) diff --git a/scripts/demo/SPDCI_DEMO_BRIEFING.md b/scripts/demo/SPDCI_DEMO_BRIEFING.md index 6c0ed00f..b96cda28 100644 --- a/scripts/demo/SPDCI_DEMO_BRIEFING.md +++ b/scripts/demo/SPDCI_DEMO_BRIEFING.md @@ -4,7 +4,11 @@ A one-page reference for the live demo of OpenSPP V2's SPDCI federated-eligibili ## 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. +> 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 @@ -12,36 +16,41 @@ A one-page reference for the live demo of OpenSPP V2's SPDCI federated-eligibili 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` | +| 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. +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. +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 @@ -68,67 +77,115 @@ Each persona on the SP carries a UIN reg_id matching an OpenG2P SR seed identifi 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. +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. +**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. +**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). +**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**. +**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`. +**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`. +**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`. +**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`. +**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`. +**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: +**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. +**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. +**`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`. +**`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.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.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. +**`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). +**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.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. +**`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. +**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. +**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. +**OpenG2P** — Open-source social-protection platform. Provides the SR +(`partner-nsr.play.openg2p.org`) used in this demo's federated eligibility flow. ## See Also diff --git a/scripts/demo/reset_spdci_demo.py b/scripts/demo/reset_spdci_demo.py index df70f53d..6870b2b5 100644 --- a/scripts/demo/reset_spdci_demo.py +++ b/scripts/demo/reset_spdci_demo.py @@ -39,9 +39,7 @@ 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." - ) + raise RuntimeError("No demo partners found. Run scripts/demo/setup_spdci_demo.py first.") print(f" Demo partners: {len(partner_ids)} (ids={partner_ids})") @@ -51,17 +49,14 @@ 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)] -) +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} " - f"{before_states[m.id]!r} -> 'draft'") + 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: @@ -70,8 +65,7 @@ [ ("subject_model", "=", "res.partner"), ("subject_id", "in", partner_ids), - ("variable_name", "in", ["has_disability", "is_poor", - "has_dependent_under_school_age"]), + ("variable_name", "in", ["has_disability", "is_poor", "has_dependent_under_school_age"]), ] ) n_cache = len(cache_rows) @@ -84,6 +78,6 @@ 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('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 index 8884b484..ccbec9e6 100644 --- a/scripts/demo/setup_spdci_demo.py +++ b/scripts/demo/setup_spdci_demo.py @@ -71,6 +71,7 @@ # ============================================================================ import logging + from odoo import fields _logger = logging.getLogger("setup_spdci_demo") @@ -87,29 +88,28 @@ # 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) + ("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. ===", + "=== DEMO SEED: setting up %d federated-demo partners on the %s side. DO NOT use this in production. ===", len(DEMO_PERSONAS), side_label, ) @@ -122,7 +122,8 @@ Code = env["spp.vocabulary.code"] uin_code = Code.with_context(active_test=False).search( - [("vocabulary_id", "=", vocab_id_type.id), ("code", "=", "UIN")], limit=1, + [("vocabulary_id", "=", vocab_id_type.id), ("code", "=", "UIN")], + limit=1, ) if not uin_code: uin_code = Code.get_or_create_local( @@ -161,11 +162,13 @@ 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, - }) + 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 @@ -174,26 +177,31 @@ 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, + [("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 - }) + 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})") + 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 @@ -212,20 +220,25 @@ already = 0 for partner in demo_partners: existing_mem = Membership.search( - [("partner_id", "=", partner.id), ("program_id", "=", program.id)], limit=1, + [("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", - }) + 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).") + 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() @@ -241,8 +254,11 @@ 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( + "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')") diff --git a/spp_cel_dci_bridge/views/dci_data_source_views.xml b/spp_cel_dci_bridge/views/dci_data_source_views.xml index 04f40817..a7259a68 100644 --- a/spp_cel_dci_bridge/views/dci_data_source_views.xml +++ b/spp_cel_dci_bridge/views/dci_data_source_views.xml @@ -12,10 +12,7 @@ spp.dci.data.source.form.inherit.vendor spp.dci.data.source - + @@ -26,10 +23,7 @@ spp.dci.data.source.tree.inherit.vendor spp.dci.data.source - + diff --git a/spp_dci_openg2p/data/openg2p_cel_variables.xml b/spp_dci_openg2p/data/openg2p_cel_variables.xml index 3206f0ad..5e44a8e4 100644 --- a/spp_dci_openg2p/data/openg2p_cel_variables.xml +++ b/spp_dci_openg2p/data/openg2p_cel_variables.xml @@ -83,10 +83,7 @@ True - + has_dependent_under_school_age Deferred: OpenG2P's SR `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 diff --git a/spp_dci_openg2p/services/openg2p_social_service.py b/spp_dci_openg2p/services/openg2p_social_service.py index ccf307d6..fb430741 100644 --- a/spp_dci_openg2p/services/openg2p_social_service.py +++ b/spp_dci_openg2p/services/openg2p_social_service.py @@ -29,9 +29,10 @@ import logging -from odoo.addons.spp_dci.schemas import QueryType from odoo.exceptions import UserError, ValidationError +from odoo.addons.spp_dci.schemas import QueryType + from .openg2p_dci_client import OpenG2PDCIClient _logger = logging.getLogger(__name__) @@ -81,7 +82,7 @@ def get_partner_record(self, partner) -> dict | None: them as audit ``result=error`` rows. """ if not partner: - raise ValidationError("Partner is required") + raise ValidationError(self.env._("Partner is required")) search_text = self._get_partner_search_text(partner) if not search_text: @@ -111,7 +112,7 @@ def get_partner_record(self, partner) -> dict | None: ) except Exception as e: _logger.error("OpenG2P SR fetch failed: %s", e, exc_info=True) - raise UserError(f"Failed to query OpenG2P: {e}") from e + raise UserError(self.env._("Failed to query OpenG2P: %s", e)) from e return self._extract_first_record(response) diff --git a/spp_dci_openg2p/tests/test_install.py b/spp_dci_openg2p/tests/test_install.py index 9586f9f5..03ddf607 100644 --- a/spp_dci_openg2p/tests/test_install.py +++ b/spp_dci_openg2p/tests/test_install.py @@ -4,7 +4,6 @@ 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", @@ -183,9 +182,7 @@ def test_post_init_hook_parks_deferred_variable_inactive(self): 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" - ) + var_dep = self.env.ref("spp_dci_openg2p.var_has_dependent_under_school_age") # Simulate someone activating it var_dep.write( { @@ -201,9 +198,7 @@ def test_post_init_hook_parks_deferred_variable_inactive(self): 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" - ) + 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) diff --git a/spp_dci_openspp_dr/__init__.py b/spp_dci_openspp_dr/__init__.py index 14a4351c..64a5802b 100644 --- a/spp_dci_openspp_dr/__init__.py +++ b/spp_dci_openspp_dr/__init__.py @@ -105,13 +105,9 @@ def post_init_hook(env): # 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)", + "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." - ) + _logger.info("spp_studio.var_has_disability DCI binding already correct; no changes.") diff --git a/spp_dci_openspp_dr/data/openspp_dr_data_source.xml b/spp_dci_openspp_dr/data/openspp_dr_data_source.xml index 55927a93..c951a470 100644 --- a/spp_dci_openspp_dr/data/openspp_dr_data_source.xml +++ b/spp_dci_openspp_dr/data/openspp_dr_data_source.xml @@ -31,7 +31,9 @@ DR openspp http://openspp-dr:8069 - /dci_api/v1/disability/registry/sync/search + /dci_api/v1/disability/registry/sync/search none openspp-sp.demo openspp-dr.demo diff --git a/spp_dci_openspp_dr/services/openspp_dr_service.py b/spp_dci_openspp_dr/services/openspp_dr_service.py index 0c1b2a71..0f04dfde 100644 --- a/spp_dci_openspp_dr/services/openspp_dr_service.py +++ b/spp_dci_openspp_dr/services/openspp_dr_service.py @@ -19,9 +19,10 @@ import logging -from odoo.addons.spp_dci_client.services import DCIClient 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 @@ -63,7 +64,7 @@ def get_partner_record(self, partner) -> dict | None: them as audit ``result=error`` rows. """ if not partner: - raise ValidationError("Partner is required") + raise ValidationError(self.env._("Partner is required")) identifier = self._get_partner_identifier(partner) if not identifier: @@ -91,7 +92,7 @@ def get_partner_record(self, partner) -> dict | None: ) except Exception as e: _logger.error("OpenSPP-DR fetch failed: %s", e, exc_info=True) - raise UserError(f"Failed to query OpenSPP-DR: {e}") from e + raise UserError(self.env._("Failed to query OpenSPP-DR: %s", e)) from e return self._extract_first_record(response) diff --git a/spp_dci_openspp_dr/tests/test_dispatcher_routing.py b/spp_dci_openspp_dr/tests/test_dispatcher_routing.py index 6911bdfe..5070559f 100644 --- a/spp_dci_openspp_dr/tests/test_dispatcher_routing.py +++ b/spp_dci_openspp_dr/tests/test_dispatcher_routing.py @@ -77,9 +77,7 @@ def test_data_source_has_vendor_openspp_and_registry_type_dr(self): 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.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( @@ -116,13 +114,9 @@ def test_clearing_vendor_falls_back_to_upstream_dr_handler(self, mock_client_cla # 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: + 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_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( diff --git a/spp_dci_openspp_dr/tests/test_openspp_dr_service.py b/spp_dci_openspp_dr/tests/test_openspp_dr_service.py index adad5f35..62b75c8e 100644 --- a/spp_dci_openspp_dr/tests/test_openspp_dr_service.py +++ b/spp_dci_openspp_dr/tests/test_openspp_dr_service.py @@ -133,9 +133,7 @@ def test_returns_none_when_partner_has_no_identifier(self): 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}] - ) + 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) diff --git a/spp_dci_server_disability/routers/disability_router.py b/spp_dci_server_disability/routers/disability_router.py index 750fde49..39fc559d 100644 --- a/spp_dci_server_disability/routers/disability_router.py +++ b/spp_dci_server_disability/routers/disability_router.py @@ -159,9 +159,7 @@ async def disability_sync_search( 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) - ) + _logger.warning("Failed to sign DR response: %s — continuing unsigned", str(e)) response_signature = "" return DCIEnvelope( diff --git a/spp_dci_server_disability/services/disability_search_service.py b/spp_dci_server_disability/services/disability_search_service.py index c4476970..c28163cd 100644 --- a/spp_dci_server_disability/services/disability_search_service.py +++ b/spp_dci_server_disability/services/disability_search_service.py @@ -1,8 +1,7 @@ """DCI Disability Registry search service. Looks up local res.partner records by the incoming ``search_text`` and -returns disability data (``has_disability``, ``disability_certified``, -``disability_percentage``) in a DCI SearchResponse envelope. +returns disability data in a DCI SearchResponse envelope. The service is intentionally narrow — it owns: @@ -10,10 +9,10 @@ types (``idtype-value``, ``expression``). - Partner lookup: searches ``spp.registry.id.value`` against the extracted search_text. The first matching partner wins. - - Disability extraction: reads ``is_person_with_disability``, - ``disability_certified``, and ``disability_percentage`` from the - partner and returns them under the wire-format key - ``has_disability`` (plus the others verbatim). + - 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. @@ -115,8 +114,7 @@ def _handle_search_item(self, req_item) -> SearchResponseItem: status="rjct", status_reason_code=REGISTER_NOT_FOUND_CODE, status_reason_message=( - f"{REGISTER_NOT_FOUND_MESSAGE}: " - f"No registrant found for identifier '{search_text}'" + f"{REGISTER_NOT_FOUND_MESSAGE}: No registrant found for identifier '{search_text}'" ), locale=req_item.locale, ) @@ -187,9 +185,7 @@ def _extract_search_text(criteria) -> str | None: eq = search_text.get("$eq") if eq: return str(eq) - raise ValueError( - "expression query missing 'search_text.$eq'" - ) + raise ValueError("expression query missing 'search_text.$eq'") if isinstance(search_text, str): return search_text return None @@ -221,20 +217,24 @@ def _find_partner_by_identifier(self, identifier_value: str): spp_dci_server/routers/search.py where signing-key reads use sudo() for the same reason. """ - reg_id = ( - self.env["spp.registry.id"] - .sudo() # nosemgrep: odoo-sudo-without-context - .search( - [("value", "=", identifier_value)], - order="partner_id asc", - limit=1, - ) + # 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 return ( - reg_id.partner_id.sudo() # nosemgrep: odoo-sudo-without-context - if reg_id - else self.env["res.partner"].sudo().browse() # nosemgrep: odoo-sudo-without-context - ) + self.env["res.partner"].sudo().browse() + ) # nosemgrep: odoo-sudo-without-context,odoo-sudo-on-sensitive-models # ------------------------------------------------------------------ # Reg-record construction @@ -264,12 +264,8 @@ def _build_reg_record(partner) -> dict: 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, + "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/tests/test_disability_search_service.py b/spp_dci_server_disability/tests/test_disability_search_service.py index 728c2f72..d50d9432 100644 --- a/spp_dci_server_disability/tests/test_disability_search_service.py +++ b/spp_dci_server_disability/tests/test_disability_search_service.py @@ -130,9 +130,7 @@ def setUpClass(cls): def test_extracts_search_text_from_expression_query(self): service = DisabilitySearchService(self.env) - request = _make_request( - QueryType.EXPRESSION.value, _expression_query("UIN-DR-1") - ) + 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") @@ -141,9 +139,7 @@ def test_extracts_search_text_from_expression_query(self): 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") - ) + 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") @@ -200,9 +196,7 @@ def test_expression_query_without_eq_is_rejected(self): def test_unknown_identifier_returns_register_not_found(self): service = DisabilitySearchService(self.env) - request = _make_request( - QueryType.EXPRESSION.value, _expression_query("UIN-UNKNOWN") - ) + 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") @@ -214,9 +208,7 @@ def test_partner_without_disability_field_returns_false(self): 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") - ) + 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") @@ -228,9 +220,7 @@ def test_partner_without_disability_field_returns_false(self): 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") - ) + 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) @@ -243,9 +233,7 @@ def test_reg_record_carries_wire_format_keys(self): 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") - ) + 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) @@ -296,9 +284,7 @@ def test_multiple_items_processed_independently(self): def test_correlation_id_is_set_on_response(self): service = DisabilitySearchService(self.env) - request = _make_request( - QueryType.EXPRESSION.value, _expression_query("UIN-DR-1") - ) + 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") From 166155e78447833278423b8dbe25c53cc3a6beaf Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 15 May 2026 18:42:12 +0800 Subject: [PATCH 53/62] fix(spp_dci_server_disability): keep nosemgrep marker on the sudo() line The previous nosemgrep marker landed on the closing-paren line after ruff-format wrapped the return statement across three lines, so semgrep's per-line suppression didn't apply to line 236 where the actual self.env["res.partner"].sudo() call lives. CI semgrep flagged both odoo-sudo-on-sensitive-models (critical) and odoo-sudo-without-context (warning) on this line. Fix: extract the sudoed env reference to a named local on the same line as its nosemgrep marker; the .browse() call moves to its own unmarked statement (no sudo() on it). Functionally identical; this is purely about the marker placement. --- .../services/disability_search_service.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/spp_dci_server_disability/services/disability_search_service.py b/spp_dci_server_disability/services/disability_search_service.py index c28163cd..e4628898 100644 --- a/spp_dci_server_disability/services/disability_search_service.py +++ b/spp_dci_server_disability/services/disability_search_service.py @@ -232,9 +232,8 @@ def _find_partner_by_identifier(self, identifier_value: str): ) if reg_id: return reg_id.partner_id.sudo() # nosemgrep: odoo-sudo-without-context,odoo-sudo-on-sensitive-models - return ( - self.env["res.partner"].sudo().browse() - ) # 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 From 84328e8a0f4c1343fff885bd952005b2da7008de Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 15 May 2026 19:27:19 +0800 Subject: [PATCH 54/62] fix(lint): clear remaining pre-commit + Semgrep OSS findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three classes of fixes: 1. scripts/lint/check_naming.py: anchor the deprecated g2p_/g2p. import rule to a path-segment boundary so it doesn't false-positive against `openg2p_*` (the OpenG2P platform's distinct namespace). Previous substring check ("g2p_" in module_name) flagged `.openg2p_dci_client` as deprecated even though it's the local module in spp_dci_openg2p. 2. scripts/demo/{setup,reset}_spdci_demo.py: add file-scope linter directives. These are interactive Odoo-shell scripts — env is injected at runtime (so ruff's F821 is wrong), print() is the right output channel for an operator at a REPL (so pylint_odoo's W8116 is wrong), and the summary loop in setup intentionally unpacks the full row (B007). Directives: ruff: noqa: F821, B007 pylint: disable=print-used CI was failing on: - Semgrep OSS (already fixed in 166155e7 — nosemgrep marker placement on the actual sudo() line) - pre-commit's full-repo run flagging this branch's check_naming.py false positive and the demo scripts' linter mismatches Local `pre-commit run --all-files` now returns exit 0. All 705 tests across the five touched modules still pass. --- scripts/demo/reset_spdci_demo.py | 6 ++++++ scripts/demo/setup_spdci_demo.py | 12 ++++++++++++ scripts/lint/check_naming.py | 24 +++++++++++++++++++++--- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/scripts/demo/reset_spdci_demo.py b/scripts/demo/reset_spdci_demo.py index 6870b2b5..e15b6768 100644 --- a/scripts/demo/reset_spdci_demo.py +++ b/scripts/demo/reset_spdci_demo.py @@ -22,6 +22,12 @@ # < 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 diff --git a/scripts/demo/setup_spdci_demo.py b/scripts/demo/setup_spdci_demo.py index ccbec9e6..480da5e6 100644 --- a/scripts/demo/setup_spdci_demo.py +++ b/scripts/demo/setup_spdci_demo.py @@ -69,6 +69,18 @@ # 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 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( From f49a4439b33958fe38bf8fcf461ff53dd87741a6 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Mon, 18 May 2026 10:33:47 +0800 Subject: [PATCH 55/62] docs(demo): add OpenG2P demo data CSV with 15 personas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures every IND-NSR-0001..IND-NSR-0015 record as returned by partner-nsr.play.openg2p.org on 2026-05-15. Columns include: - identity: uin, given_name, surname, sex, birth_date - SR data: marital_status, employment_status, occupation, income_level, education_level - disability: openg2p_is_disabled, openg2p_self_id_disability (informational — the demo's has_disability comes from the OpenSPP-DR, not OpenG2P) - household: household_id, relationship_to_head, citizenship_category, displacement_status - demo: demo_dr_has_disability (will the script approve a DR assessment for this UIN?), demo_expected_verdict (eligibility outcome under has_disability == true && is_poor == "low") Useful for the SPDCI presentation — drop into a spreadsheet or slide table to show the source-of-truth data and the expected demo outcomes. --- scripts/demo/openg2p_demo_data.csv | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 scripts/demo/openg2p_demo_data.csv diff --git a/scripts/demo/openg2p_demo_data.csv b/scripts/demo/openg2p_demo_data.csv new file mode 100644 index 00000000..fce92c3b --- /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,income_level,education_level,openg2p_is_disabled,openg2p_self_id_disability,household_id,relationship_to_head,citizenship_category,displacement_status,demo_dr_has_disability,demo_expected_verdict +IND-NSR-0001,Alex,Rivera,male,1984-04-10,married,self employed,wage labourer,low,,False,0,HH-NSR-0001,self,citizen,host_community,true,ENROLLED +IND-NSR-0002,Priya,Rivera,female,1986-06-22,married,self employed,petty trader,low,,False,0,HH-NSR-0001,spouse,citizen,host_community,false,not eligible +IND-NSR-0003,Noah,Rivera,male,2017-01-15,single,student,student,,,False,0,HH-NSR-0001,child,citizen,host_community,false,not eligible +IND-NSR-0004,Morgan,Cole,female,1968-09-02,widowed,unemployed,small-plot farmer,low,,True,1,HH-NSR-0002,self,citizen,host_community,true,ENROLLED +IND-NSR-0005,Leah,Cole,female,1998-02-10,single,employed,shop assistant,low,,False,0,HH-NSR-0002,child,citizen,host_community,false,not eligible +IND-NSR-0006,Nia,Cole,female,2019-11-01,single,student,student,,,False,0,HH-NSR-0002,other_relative,citizen,host_community,true,not eligible +IND-NSR-0007,Kim,Lee,male,1981-07-18,married,employed,clerk,medium,,False,0,HH-NSR-0003,self,citizen,host_community,true,not eligible +IND-NSR-0008,Jun,Lee,female,1982-12-04,married,self employed,tailor,medium,,False,0,HH-NSR-0003,spouse,citizen,host_community,false,not eligible +IND-NSR-0009,Rin,Lee,female,1953-05-30,widowed,retired,retired,,,True,1,HH-NSR-0003,parent,citizen,host_community,true,not eligible +IND-NSR-0010,Taylor,Brooks,others,1990-03-25,single,unemployed,none,low,,True,1,HH-NSR-0004,self,refugee,idp,true,ENROLLED +IND-NSR-0011,Iris,Brooks,female,1957-11-12,widowed,retired,retired,,,False,0,HH-NSR-0004,parent,refugee,idp,false,not eligible +IND-NSR-0012,Reyn,Brooks,male,2015-08-19,single,student,student,,,False,0,HH-NSR-0004,sibling,refugee,idp,false,not eligible +IND-NSR-0013,Sam,Hayes,female,1987-01-05,married,self employed,livestock keeper,low,,False,0,HH-NSR-0005,self,citizen,host_community,true,ENROLLED +IND-NSR-0014,Dev,Hayes,male,1984-07-20,married,self employed,livestock keeper,low,,False,0,HH-NSR-0005,spouse,citizen,host_community,false,not eligible +IND-NSR-0015,Asha,Hayes,female,2011-06-14,single,student,student,,,False,0,HH-NSR-0005,child,citizen,host_community,true,not eligible From 3bca0cf4ffffedd092db82755549ee8b8f3a6008 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Mon, 18 May 2026 10:37:45 +0800 Subject: [PATCH 56/62] docs(demo): trim OpenG2P demo CSV to 8 identity/demographic columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keeps uin, given_name, surname, sex, birth_date, marital_status, employment_status, occupation — the presentation-ready subset. The SR-specific (income_level, education_level), disability, household, and demo-annotation columns are documented elsewhere in SPDCI_DEMO_BRIEFING.md and don't need to clutter the audience-facing CSV. --- scripts/demo/openg2p_demo_data.csv | 32 +++++++++++++++--------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/scripts/demo/openg2p_demo_data.csv b/scripts/demo/openg2p_demo_data.csv index fce92c3b..b990b4be 100644 --- a/scripts/demo/openg2p_demo_data.csv +++ b/scripts/demo/openg2p_demo_data.csv @@ -1,16 +1,16 @@ -uin,given_name,surname,sex,birth_date,marital_status,employment_status,occupation,income_level,education_level,openg2p_is_disabled,openg2p_self_id_disability,household_id,relationship_to_head,citizenship_category,displacement_status,demo_dr_has_disability,demo_expected_verdict -IND-NSR-0001,Alex,Rivera,male,1984-04-10,married,self employed,wage labourer,low,,False,0,HH-NSR-0001,self,citizen,host_community,true,ENROLLED -IND-NSR-0002,Priya,Rivera,female,1986-06-22,married,self employed,petty trader,low,,False,0,HH-NSR-0001,spouse,citizen,host_community,false,not eligible -IND-NSR-0003,Noah,Rivera,male,2017-01-15,single,student,student,,,False,0,HH-NSR-0001,child,citizen,host_community,false,not eligible -IND-NSR-0004,Morgan,Cole,female,1968-09-02,widowed,unemployed,small-plot farmer,low,,True,1,HH-NSR-0002,self,citizen,host_community,true,ENROLLED -IND-NSR-0005,Leah,Cole,female,1998-02-10,single,employed,shop assistant,low,,False,0,HH-NSR-0002,child,citizen,host_community,false,not eligible -IND-NSR-0006,Nia,Cole,female,2019-11-01,single,student,student,,,False,0,HH-NSR-0002,other_relative,citizen,host_community,true,not eligible -IND-NSR-0007,Kim,Lee,male,1981-07-18,married,employed,clerk,medium,,False,0,HH-NSR-0003,self,citizen,host_community,true,not eligible -IND-NSR-0008,Jun,Lee,female,1982-12-04,married,self employed,tailor,medium,,False,0,HH-NSR-0003,spouse,citizen,host_community,false,not eligible -IND-NSR-0009,Rin,Lee,female,1953-05-30,widowed,retired,retired,,,True,1,HH-NSR-0003,parent,citizen,host_community,true,not eligible -IND-NSR-0010,Taylor,Brooks,others,1990-03-25,single,unemployed,none,low,,True,1,HH-NSR-0004,self,refugee,idp,true,ENROLLED -IND-NSR-0011,Iris,Brooks,female,1957-11-12,widowed,retired,retired,,,False,0,HH-NSR-0004,parent,refugee,idp,false,not eligible -IND-NSR-0012,Reyn,Brooks,male,2015-08-19,single,student,student,,,False,0,HH-NSR-0004,sibling,refugee,idp,false,not eligible -IND-NSR-0013,Sam,Hayes,female,1987-01-05,married,self employed,livestock keeper,low,,False,0,HH-NSR-0005,self,citizen,host_community,true,ENROLLED -IND-NSR-0014,Dev,Hayes,male,1984-07-20,married,self employed,livestock keeper,low,,False,0,HH-NSR-0005,spouse,citizen,host_community,false,not eligible -IND-NSR-0015,Asha,Hayes,female,2011-06-14,single,student,student,,,False,0,HH-NSR-0005,child,citizen,host_community,true,not eligible +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 From 27aecd006db5780d4fd69da74abe784aea875f32 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Mon, 18 May 2026 17:42:37 +0800 Subject: [PATCH 57/62] docs(demo): walkthrough of CEL-to-DCI execution for presentation End-to-end technical narrative for explaining how `has_disability == true && is_poor == "low"` triggers two federated DCI calls and composes the eligibility decision. Eleven steps, code-path references at each layer: 1. Enroll Eligible button -> eligibility manager 2. Pre-warm fans out every active DCI-backed CEL variable 3. Dispatcher routes by registry_type + vendor adapter 4. Vendor service builds and POSTs a DCI envelope 5. Remote registry answers (OpenSPP-DR or OpenG2P SR) 6. Service unwraps reg_records[0], dispatcher extracts via dci_attribute_path, audit row written 7. Cache write to spp.data.value 8. CEL parser -> translator -> plan (AND[MetricCompare, MetricCompare]) 9. Executor builds per-clause SQL subqueries 10. PostgreSQL composes the final WHERE 11. Memberships flip to enrolled/not_eligible Plus ASCII overview diagram, wire-format JSON envelope snippets for both DR and SR sides, a "why this matters for SPDCI" framing block, and the demo verification commands. --- scripts/demo/SPDCI_CEL_TO_DCI_FLOW.md | 361 ++++++++++++++++++++++++++ 1 file changed, 361 insertions(+) create mode 100644 scripts/demo/SPDCI_CEL_TO_DCI_FLOW.md 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. From cdbce38a13aa3dd9522372df03afe9c0434b6a04 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Mon, 18 May 2026 18:31:03 +0800 Subject: [PATCH 58/62] feat(spp_dci_openg2p): add SR-import wizard for operator-driven registrant ingest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the manual setup_spdci_demo.py script with a UI flow under Registry → Import from External Registry (DCI). The wizard fires DCI search-sync requests against the configured OpenG2P SR data source, previews matched records, and imports the selected ones as res.partner + spp.registry.id rows on the SP — optionally enrolling them into a target program in one step. Discovery semantics: SPDCI search-sync is lookup-only (no "list all registrants" operation), so the wizard offers two practical modes: - Range sweep: contiguous identifier range (e.g., IND-NSR-0001.. IND-NSR-0015). Works against the OpenG2P playground. - Identifier list: paste/type identifiers one per line. Matches the production-shaped workflow where the SR operator hands over a partner list out of band. Scope: captures only the bare minimum partner fields (given_name, surname, sex, birth_date) plus a UIN reg_id. Eligibility rules continue to read income_level etc. on demand via the CEL ↔ DCI bridge — this wizard is NOT a full SR replica. Implementation: - spp.dci.sr.import.wizard (TransientModel) with three-state form (configure → preview → done) - spp.dci.sr.import.wizard.line for preview rows; carries already_exists + existing_partner_id so the operator can see which UINs are already on the SP - View renders all states on one form, gated by `invisible="state != 'X'"` per pane - Menuitem under spp_registry.spp_main_menu_root, sequence=90 Tests: - 10 new tests in test_sr_import_wizard.py covering identifier collection (range padding, list dedup/comments, empty-input validation), preview (matched/not_found/error/already_exists), import (selected only, skip-existing, auto-enroll), and back-to-configure state reset. - Module total: 44 tests passing. Module manifest gains spp_registry as a dependency for the menu parent xmlid; security/ir.model.access.csv added for the two TransientModel records. --- spp_dci_openg2p/__init__.py | 1 + spp_dci_openg2p/__manifest__.py | 3 + spp_dci_openg2p/security/ir.model.access.csv | 3 + spp_dci_openg2p/tests/__init__.py | 1 + .../tests/test_sr_import_wizard.py | 279 ++++++++++++ .../views/sr_import_wizard_views.xml | 129 ++++++ spp_dci_openg2p/wizards/__init__.py | 1 + spp_dci_openg2p/wizards/sr_import_wizard.py | 426 ++++++++++++++++++ 8 files changed, 843 insertions(+) create mode 100644 spp_dci_openg2p/security/ir.model.access.csv create mode 100644 spp_dci_openg2p/tests/test_sr_import_wizard.py create mode 100644 spp_dci_openg2p/views/sr_import_wizard_views.xml create mode 100644 spp_dci_openg2p/wizards/__init__.py create mode 100644 spp_dci_openg2p/wizards/sr_import_wizard.py diff --git a/spp_dci_openg2p/__init__.py b/spp_dci_openg2p/__init__.py index 7fee72c2..790d999c 100644 --- a/spp_dci_openg2p/__init__.py +++ b/spp_dci_openg2p/__init__.py @@ -2,6 +2,7 @@ from . import models from . import services +from . import wizards _logger = logging.getLogger(__name__) diff --git a/spp_dci_openg2p/__manifest__.py b/spp_dci_openg2p/__manifest__.py index 30e77c25..53a48e1d 100644 --- a/spp_dci_openg2p/__manifest__.py +++ b/spp_dci_openg2p/__manifest__.py @@ -12,13 +12,16 @@ "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, 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/tests/__init__.py b/spp_dci_openg2p/tests/__init__.py index ea1bd6c5..711ce97a 100644 --- a/spp_dci_openg2p/tests/__init__.py +++ b/spp_dci_openg2p/tests/__init__.py @@ -2,3 +2,4 @@ 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_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..b3efcfe1 --- /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..23903646 --- /dev/null +++ b/spp_dci_openg2p/wizards/sr_import_wizard.py @@ -0,0 +1,426 @@ +"""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', '=', 'SR'), ('vendor', '=', 'openg2p'), ('active', '=', True)]", + default=lambda self: self._default_data_source(), + help="DCI data source to query. Restricted to vendor=openg2p SR " + "sources — this wizard uses OpenG2P-specific request semantics.", + ) + + 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", "=", "SR"), + ("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.") From 2983985e7e8f99f244dbd9abc2b3c9b7a3891f5a Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Mon, 18 May 2026 18:45:57 +0800 Subject: [PATCH 59/62] refactor(spp_dci_openg2p): relabel user-facing strings from "OpenG2P" to "Social Registry" Operators see neutral terminology in the SR-import wizard and the DCI configuration screens; vendor identity stays in technical slugs (xml ids, vendor='openg2p' dispatcher key, code='openg2p_dr', Python class names) so the routing layer is unaffected. Touched UI strings only: data source / data provider names, CEL variable labels and descriptions, UIN id-type definition, wizard form title, and the Source Registry field's help tooltip. --- spp_dci_openg2p/data/openg2p_cel_variables.xml | 8 ++++---- spp_dci_openg2p/data/openg2p_data_provider.xml | 2 +- spp_dci_openg2p/data/openg2p_data_source.xml | 4 ++-- spp_dci_openg2p/data/openg2p_id_types.xml | 2 +- spp_dci_openg2p/views/sr_import_wizard_views.xml | 2 +- spp_dci_openg2p/wizards/sr_import_wizard.py | 5 +++-- 6 files changed, 12 insertions(+), 11 deletions(-) diff --git a/spp_dci_openg2p/data/openg2p_cel_variables.xml b/spp_dci_openg2p/data/openg2p_cel_variables.xml index 5e44a8e4..8f3a2d59 100644 --- a/spp_dci_openg2p/data/openg2p_cel_variables.xml +++ b/spp_dci_openg2p/data/openg2p_cel_variables.xml @@ -64,10 +64,10 @@ --> is_poor - Is Poor (OpenG2P SR income_level) + Is Poor (Social Registry income_level) Poverty proxy sourced from OpenG2P SR'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. + >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 @@ -87,10 +87,10 @@ has_dependent_under_school_age Has Dependent Under School Age (DEFERRED — OpenG2P does not expose) + >Has Dependent Under School Age (DEFERRED — Social Registry does not expose) Deferred: OpenG2P's SR `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. + >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 diff --git a/spp_dci_openg2p/data/openg2p_data_provider.xml b/spp_dci_openg2p/data/openg2p_data_provider.xml index 29f24d38..efb86186 100644 --- a/spp_dci_openg2p/data/openg2p_data_provider.xml +++ b/spp_dci_openg2p/data/openg2p_data_provider.xml @@ -8,7 +8,7 @@ of 300s for the demo; production deployments may raise this. --> - OpenG2P Disability Registry + Social Registry openg2p_dr diff --git a/spp_dci_openg2p/data/openg2p_data_source.xml b/spp_dci_openg2p/data/openg2p_data_source.xml index 109b0cf5..39e5ffb8 100644 --- a/spp_dci_openg2p/data/openg2p_data_source.xml +++ b/spp_dci_openg2p/data/openg2p_data_source.xml @@ -21,7 +21,7 @@ (e.g., is_poor, has_dependent_under_school_age) into CEL. --> - OpenG2P Social Registry + Social Registry openg2p_dr SR openg2p @@ -45,6 +45,6 @@ draft OpenG2P playground preset (Social Registry role). 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. + >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 index 43b6025a..8b3ae6ee 100644 --- a/spp_dci_openg2p/data/openg2p_id_types.xml +++ b/spp_dci_openg2p/data/openg2p_id_types.xml @@ -25,7 +25,7 @@ individual Universal Identification Number — the canonical SPDCI identifier sent to OpenG2P's DCI search endpoint as the `id_type` field. + >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/views/sr_import_wizard_views.xml b/spp_dci_openg2p/views/sr_import_wizard_views.xml index b3efcfe1..f7d86f24 100644 --- a/spp_dci_openg2p/views/sr_import_wizard_views.xml +++ b/spp_dci_openg2p/views/sr_import_wizard_views.xml @@ -10,7 +10,7 @@ spp.dci.sr.import.wizard.form spp.dci.sr.import.wizard -
+ diff --git a/spp_dci_openg2p/wizards/sr_import_wizard.py b/spp_dci_openg2p/wizards/sr_import_wizard.py index 23903646..b20ac995 100644 --- a/spp_dci_openg2p/wizards/sr_import_wizard.py +++ b/spp_dci_openg2p/wizards/sr_import_wizard.py @@ -62,8 +62,9 @@ class SppDciSrImportWizard(models.TransientModel): required=True, domain="[('registry_type', '=', 'SR'), ('vendor', '=', 'openg2p'), ('active', '=', True)]", default=lambda self: self._default_data_source(), - help="DCI data source to query. Restricted to vendor=openg2p SR " - "sources — this wizard uses OpenG2P-specific request semantics.", + 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( From 50c9b590531ec50b9ebfb553c798f5a19e108d55 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Mon, 18 May 2026 18:54:30 +0800 Subject: [PATCH 60/62] fix(spp_dci_openg2p): use canonical registry_type URI in SR-import wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wizard's data_source_id domain filtered on the short alias 'SR', which only exists in the dispatcher's URI-to-alias routing map — not as a stored field value. spp.dci.data.source.registry_type is a Selection over URIs from spp_dci.schemas.constants.RegistryType, so the filter never matched and the Source Registry dropdown rendered empty. Switch the domain, the _default_data_source search, and the seeded data XML to 'ns:org:RegistryType:Social' so the dropdown picks up the Social Registry source on fresh installs and the operator sees the configured source pre-filled. --- spp_dci_openg2p/data/openg2p_data_source.xml | 2 +- spp_dci_openg2p/wizards/sr_import_wizard.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/spp_dci_openg2p/data/openg2p_data_source.xml b/spp_dci_openg2p/data/openg2p_data_source.xml index 39e5ffb8..968c3e77 100644 --- a/spp_dci_openg2p/data/openg2p_data_source.xml +++ b/spp_dci_openg2p/data/openg2p_data_source.xml @@ -23,7 +23,7 @@ Social Registry openg2p_dr - SR + ns:org:RegistryType:Social openg2p https://partner-registry.play.openg2p.org /dci/registry/sync/search diff --git a/spp_dci_openg2p/wizards/sr_import_wizard.py b/spp_dci_openg2p/wizards/sr_import_wizard.py index b20ac995..98ea6cbe 100644 --- a/spp_dci_openg2p/wizards/sr_import_wizard.py +++ b/spp_dci_openg2p/wizards/sr_import_wizard.py @@ -60,7 +60,7 @@ class SppDciSrImportWizard(models.TransientModel): "spp.dci.data.source", string="Source Registry", required=True, - domain="[('registry_type', '=', 'SR'), ('vendor', '=', 'openg2p'), ('active', '=', 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 " @@ -136,7 +136,7 @@ def _default_data_source(self): """ return self.env["spp.dci.data.source"].search( [ - ("registry_type", "=", "SR"), + ("registry_type", "=", "ns:org:RegistryType:Social"), ("vendor", "=", "openg2p"), ("active", "=", True), ], From 02ea9936f8ffea41aafef06677b3f6945604dac9 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Mon, 18 May 2026 20:29:25 +0800 Subject: [PATCH 61/62] docs(demo): add SPDCI demo modules + reset procedure reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures the leaf modules to install on each side (SP: spp_dci_openspp_dr + spp_dci_openg2p; DR: spp_dci_server_disability), the dependency tree they pull in, the full down/wipe/re-init flow, post-install wiring (DR base_url, demo seeding, optional auth bypass), and a sanity-check table — so the federated demo can be rebuilt from scratch without rediscovering the steps. --- scripts/demo/SPDCI_DEMO_MODULES.md | 238 +++++++++++++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 scripts/demo/SPDCI_DEMO_MODULES.md diff --git a/scripts/demo/SPDCI_DEMO_MODULES.md b/scripts/demo/SPDCI_DEMO_MODULES.md new file mode 100644 index 00000000..46b2f3d0 --- /dev/null +++ b/scripts/demo/SPDCI_DEMO_MODULES.md @@ -0,0 +1,238 @@ +# SPDCI Demo — Modules & Reset Procedure + +Reference sheet for resetting both OpenSPP instances (SP + DR) from scratch 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`). + +--- + +## 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 +``` + +--- + +## Full reset procedure + +### 1. Stop and wipe both instances + +```bash +# Stop the DR first (depends on SP's shared network) +docker compose -f docker-compose.dr.yml down -v + +# Stop SP + jobworker + db (the -v wipes the SP filestore volume) +./spp stop +docker compose down -v +``` + +### 2. Drop the DR database + +`docker compose down -v` removes the SP filestore volume but the `db` Postgres container +is shared and only the `openspp` database is re-created by the SP boot. The DR's +`openspp_dr` database lives in the same Postgres and needs an explicit drop: + +```bash +./spp start # brings up db + SP +docker compose exec db dropdb -U odoo --if-exists openspp_dr +``` + +### 3. 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 " +``` + +### 4. Re-init the DR + +```bash +# DR uses a separate compose. Its default init module is exactly what +# we need; override only if you want to add demo registrants alongside +# the server endpoint. +docker compose -f docker-compose.dr.yml up -d +``` + +Default init is `spp_dci_server_disability` (see `docker-compose.dr.yml` line 80). +Override with `ODOO_DR_INIT_MODULES=...` only if you need additional modules — for the +federated demo, the default is enough because the demo-setup script seeds the partners + +disability assessments after boot. + +--- + +## Post-install wiring + +After both containers are up, the SP needs a couple of records the data XML does not +seed automatically (because the SP doesn't know your DR's URL or your demo CEL rule): + +### 4a. 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 +``` + +### 4b. Seed demo registrants on both sides + +Edit the persona list in `scripts/demo/setup_spdci_demo.py` if needed, then run it +inside each container. The script is idempotent (re-runs update existing partners by +UIN). + +```bash +# SP side: 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 + +# DR side: creates approved disability assessments for 8 of those UINs. +docker compose -f docker-compose.dr.yml exec openspp-dr \ + odoo shell -d openspp_dr --no-http \ + < scripts/demo/setup_spdci_demo.py +``` + +The same file detects which side it's running on by inspecting installed modules — no +flag needed. + +### 4c. (Optional) Allow unsigned DCI requests for the demo + +The DR enforces DCI envelope signature + bearer auth by default. For the demo, relax +both via the system parameters: + +```bash +docker compose -f docker-compose.dr.yml exec openspp-dr \ + odoo shell -d openspp_dr --no-http <<'PY' +P = env["ir.config_parameter"].sudo() +P.set_param("dci.allow_unsigned_requests", "true") +P.set_param("dci.bypass_bearer_auth", "true") +env.cr.commit() +PY +``` + +Production: register the SP's public key in the DR's DCI Sender Registry instead. + +### 4d. Operator-driven SR import (alternative to the seed script) + +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 `setup_spdci_demo.py`, minus the DR-side +assessments (DR seeding still needs the script). + +--- + +## 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. From 5ba1ec49043ea290cb4eaf8a164a82ee96d569ea Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Mon, 18 May 2026 20:31:55 +0800 Subject: [PATCH 62/62] docs(demo): scope reset procedure to SP only The next demo run wipes only the SP; the DR keeps its database, its 8 approved disability assessments, and its DCI-server bypass flags from prior runs. Rewrite the reset section to stop/wipe only the SP container + filestore, leave docker-compose.dr.yml alone, drop the DR-side reseed step from the post-install wiring, and note explicitly that the DR config carries over. --- scripts/demo/SPDCI_DEMO_MODULES.md | 120 +++++++++++++---------------- 1 file changed, 52 insertions(+), 68 deletions(-) diff --git a/scripts/demo/SPDCI_DEMO_MODULES.md b/scripts/demo/SPDCI_DEMO_MODULES.md index 46b2f3d0..5eb94394 100644 --- a/scripts/demo/SPDCI_DEMO_MODULES.md +++ b/scripts/demo/SPDCI_DEMO_MODULES.md @@ -1,14 +1,20 @@ -# SPDCI Demo — Modules & Reset Procedure +# SPDCI Demo — Modules & SP Reset Procedure -Reference sheet for resetting both OpenSPP instances (SP + DR) from scratch and -reinstalling the modules required for the federated CEL ↔ DCI eligibility demo -(ADR-024). +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 @@ -61,31 +67,34 @@ DR container: --- -## Full reset procedure +## SP-only reset procedure -### 1. Stop and wipe both instances +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. -```bash -# Stop the DR first (depends on SP's shared network) -docker compose -f docker-compose.dr.yml down -v +### 1. Stop the SP (keep DR running) -# Stop SP + jobworker + db (the -v wipes the SP filestore volume) +```bash ./spp stop -docker compose down -v +docker compose down -v # removes SP filestore volume ``` -### 2. Drop the DR database +Verify the DR is still up — it shares the network but has its own container and DB: -`docker compose down -v` removes the SP filestore volume but the `db` Postgres container -is shared and only the `openspp` database is re-created by the SP boot. The DR's -`openspp_dr` database lives in the same Postgres and needs an explicit drop: +```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 -./spp start # brings up db + SP -docker compose exec db dropdb -U odoo --if-exists openspp_dr +docker compose -f docker-compose.dr.yml up -d ``` -### 3. Re-init the SP +### 2. Re-init the SP ```bash # Set the SP's init modules and start. The two presets pull every @@ -100,28 +109,14 @@ Watch the boot log; it will exit cleanly when install finishes: docker compose logs -f openspp-dev | grep -E "Modules loaded|ERROR|init " ``` -### 4. Re-init the DR - -```bash -# DR uses a separate compose. Its default init module is exactly what -# we need; override only if you want to add demo registrants alongside -# the server endpoint. -docker compose -f docker-compose.dr.yml up -d -``` - -Default init is `spp_dci_server_disability` (see `docker-compose.dr.yml` line 80). -Override with `ODOO_DR_INIT_MODULES=...` only if you need additional modules — for the -federated demo, the default is enough because the demo-setup script seeds the partners + -disability assessments after boot. - --- -## Post-install wiring +## Post-install wiring (SP side only) -After both containers are up, the SP needs a couple of records the data XML does not -seed automatically (because the SP doesn't know your DR's URL or your demo CEL rule): +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): -### 4a. Point the SP's DR data source at the running DR +### 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: @@ -138,44 +133,23 @@ print(f"DR source -> {src.base_url}") PY ``` -### 4b. Seed demo registrants on both sides +### 2b. Seed SP-side registrants + +Two options — pick one. -Edit the persona list in `scripts/demo/setup_spdci_demo.py` if needed, then run it -inside each container. The script is idempotent (re-runs update existing partners by -UIN). +**Option A: seed script (matches prior demo runs)** ```bash -# SP side: enrolls 15 IND-NSR-XXXX partners into program id=1 as draft. +# 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 - -# DR side: creates approved disability assessments for 8 of those UINs. -docker compose -f docker-compose.dr.yml exec openspp-dr \ - odoo shell -d openspp_dr --no-http \ - < scripts/demo/setup_spdci_demo.py -``` - -The same file detects which side it's running on by inspecting installed modules — no -flag needed. - -### 4c. (Optional) Allow unsigned DCI requests for the demo - -The DR enforces DCI envelope signature + bearer auth by default. For the demo, relax -both via the system parameters: - -```bash -docker compose -f docker-compose.dr.yml exec openspp-dr \ - odoo shell -d openspp_dr --no-http <<'PY' -P = env["ir.config_parameter"].sudo() -P.set_param("dci.allow_unsigned_requests", "true") -P.set_param("dci.bypass_bearer_auth", "true") -env.cr.commit() -PY ``` -Production: register the SP's public key in the DR's DCI Sender Registry instead. +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. -### 4d. Operator-driven SR import (alternative to the seed script) +**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**: @@ -186,8 +160,18 @@ After the SP is up, an operator can populate registrants via the wizard under ** step. - Preview → Import Selected. -This produces the same SP-side state as `setup_spdci_demo.py`, minus the DR-side -assessments (DR seeding still needs the script). +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. ---