From f0b8178e9161c889d2f261b3be05161e3f00be9e Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 13 Feb 2026 18:46:56 +0100 Subject: [PATCH 01/20] feat(spp_api_v2): add auditor security group for API log payloads Restrict sensitive PII fields in API logs to a dedicated group_api_v2_auditor group. Regular viewers can see log metadata (timestamp, endpoint, status, duration) but not payloads or error details. - Add privilege_api_v2_auditor and group_api_v2_auditor - Auditor implies Viewer; Admin implies Auditor - Restrict outgoing log: request_summary, response_summary, error_detail - Restrict audit log: search_parameters, fields_returned, extensions_returned, ip_address, user_agent, error_detail - Hide payload pages and error_detail in outgoing log form view - Add field-level security tests for both models --- spp_api_v2/models/api_audit_log.py | 6 + spp_api_v2/models/api_outgoing_log.py | 220 ++++++++++++++ spp_api_v2/security/groups.xml | 20 +- spp_api_v2/security/privileges.xml | 7 + spp_api_v2/tests/test_api_audit_log.py | 117 ++++++++ spp_api_v2/tests/test_api_outgoing_log.py | 306 ++++++++++++++++++++ spp_api_v2/views/api_outgoing_log_views.xml | 144 +++++++++ 7 files changed, 818 insertions(+), 2 deletions(-) create mode 100644 spp_api_v2/models/api_outgoing_log.py create mode 100644 spp_api_v2/tests/test_api_outgoing_log.py create mode 100644 spp_api_v2/views/api_outgoing_log_views.xml diff --git a/spp_api_v2/models/api_audit_log.py b/spp_api_v2/models/api_audit_log.py index dc63e9e6..bc697e39 100644 --- a/spp_api_v2/models/api_audit_log.py +++ b/spp_api_v2/models/api_audit_log.py @@ -46,11 +46,13 @@ class ApiAuditLog(models.Model): ip_address = fields.Char( string="IP Address", + groups="spp_api_v2.group_api_v2_auditor", help="Client IP address", ) user_agent = fields.Char( string="User Agent", + groups="spp_api_v2.group_api_v2_auditor", help="Client user agent string", ) @@ -122,6 +124,7 @@ class ApiAuditLog(models.Model): # ========================================== # For search operations search_parameters = fields.Json( + groups="spp_api_v2.group_api_v2_auditor", help="Search parameters used (for search/export operations)", ) @@ -131,10 +134,12 @@ class ApiAuditLog(models.Model): # For read operations with field filtering fields_returned = fields.Json( + groups="spp_api_v2.group_api_v2_auditor", help="List of fields returned in response (for _elements filtering)", ) extensions_returned = fields.Json( + groups="spp_api_v2.group_api_v2_auditor", help="List of extensions returned in response", ) @@ -172,6 +177,7 @@ class ApiAuditLog(models.Model): ) error_detail = fields.Char( + groups="spp_api_v2.group_api_v2_auditor", help="Error message (no PII)", ) diff --git a/spp_api_v2/models/api_outgoing_log.py b/spp_api_v2/models/api_outgoing_log.py new file mode 100644 index 00000000..a1fe7446 --- /dev/null +++ b/spp_api_v2/models/api_outgoing_log.py @@ -0,0 +1,220 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Audit log for outgoing API calls to external services.""" + +import logging + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + + +class ApiOutgoingLog(models.Model): + """ + Audit log for outgoing HTTP calls to external services. + + Captures all outgoing API requests (DCI, webhooks, etc.) with + request/response details for troubleshooting and compliance. + """ + + _name = "spp.api.outgoing.log" + _description = "Outgoing API Log" + _order = "timestamp desc" + _rec_name = "display_name" + + # ========================================== + # Request - WHAT was sent + # ========================================== + url = fields.Char( + required=True, + index=True, + help="Full URL called", + ) + + endpoint = fields.Char( + index=True, + help="Path portion of the URL, e.g. /registry/sync/search", + ) + + http_method = fields.Selection( + [ + ("POST", "POST"), + ("GET", "GET"), + ("PUT", "PUT"), + ("PATCH", "PATCH"), + ("DELETE", "DELETE"), + ], + default="POST", + required=True, + ) + + request_summary = fields.Json( + groups="spp_api_v2.group_api_v2_auditor", + help="Request payload (secrets redacted)", + ) + + # ========================================== + # Response - WHAT came back + # ========================================== + response_summary = fields.Json( + groups="spp_api_v2.group_api_v2_auditor", + help="Response payload", + ) + + response_status_code = fields.Integer( + index=True, + ) + + # ========================================== + # Context - WHO triggered it + # ========================================== + user_id = fields.Many2one( + "res.users", + default=lambda self: self.env.uid, + index=True, + ) + + origin_model = fields.Char( + index=True, + help="Model that triggered the call, e.g. spp.dci.data.source", + ) + + origin_record_id = fields.Integer( + help="Record ID that triggered the call", + ) + + # ========================================== + # Timestamps & Performance + # ========================================== + timestamp = fields.Datetime( + required=True, + default=fields.Datetime.now, + index=True, + ) + + duration_ms = fields.Integer( + help="Request duration in milliseconds", + ) + + # ========================================== + # Service Context + # ========================================== + service_name = fields.Char( + index=True, + help="Human-readable service name, e.g. DCI Client", + ) + + service_code = fields.Char( + index=True, + help="Machine-readable service code, e.g. crvs_main", + ) + + # ========================================== + # Result + # ========================================== + status = fields.Selection( + [ + ("success", "Success"), + ("http_error", "HTTP Error"), + ("connection_error", "Connection Error"), + ("timeout", "Timeout"), + ("error", "Error"), + ], + default="success", + required=True, + index=True, + ) + + error_detail = fields.Text( + groups="spp_api_v2.group_api_v2_auditor", + help="Error message or traceback", + ) + + # ========================================== + # Computed fields + # ========================================== + display_name = fields.Char( + compute="_compute_display_name", + store=True, + ) + + @api.depends("http_method", "endpoint", "timestamp") + def _compute_display_name(self): + for record in self: + timestamp_str = record.timestamp.strftime("%Y-%m-%d %H:%M") if record.timestamp else "" + record.display_name = f"{record.http_method} {record.endpoint or record.url} @ {timestamp_str}" + + # ========================================== + # API Methods + # ========================================== + @api.model + def log_call( + self, + url: str, + http_method: str = "POST", + endpoint: str = None, + request_summary: dict = None, + response_summary: dict = None, + response_status_code: int = None, + user_id: int = None, + origin_model: str = None, + origin_record_id: int = None, + duration_ms: int = None, + service_name: str = None, + service_code: str = None, + status: str = "success", + error_detail: str = None, + ): + """ + Log an outgoing API call. + + Args: + url: Full URL called + http_method: HTTP method (POST, GET, PUT, PATCH, DELETE) + endpoint: Path portion of the URL + request_summary: Request payload (secrets redacted) + response_summary: Response payload + response_status_code: HTTP response status code + user_id: User who triggered the call + origin_model: Odoo model that triggered the call + origin_record_id: Record ID that triggered the call + duration_ms: Request duration in milliseconds + service_name: Human-readable service name + service_code: Machine-readable service code + status: Result status + error_detail: Error message or traceback + + Returns: + Created spp.api.outgoing.log record + """ + vals = { + "url": url, + "http_method": http_method, + "status": status, + "timestamp": fields.Datetime.now(), + } + + # Optional fields + if endpoint: + vals["endpoint"] = endpoint + if request_summary is not None: + vals["request_summary"] = request_summary + if response_summary is not None: + vals["response_summary"] = response_summary + if response_status_code is not None: + vals["response_status_code"] = response_status_code + if user_id is not None: + vals["user_id"] = user_id + if origin_model: + vals["origin_model"] = origin_model + if origin_record_id is not None: + vals["origin_record_id"] = origin_record_id + if duration_ms is not None: + vals["duration_ms"] = duration_ms + if service_name: + vals["service_name"] = service_name + if service_code: + vals["service_code"] = service_code + if error_detail: + vals["error_detail"] = error_detail + + return self.create(vals) diff --git a/spp_api_v2/security/groups.xml b/spp_api_v2/security/groups.xml index fa582093..dca20a23 100644 --- a/spp_api_v2/security/groups.xml +++ b/spp_api_v2/security/groups.xml @@ -58,8 +58,24 @@ /> - + + + API V2: Auditor + + Can view sensitive payload data in API logs (request/response bodies, search parameters, IP addresses). Implies Viewer for menu access. + + + + - + diff --git a/spp_api_v2/security/privileges.xml b/spp_api_v2/security/privileges.xml index bcd4eaac..36dba16c 100644 --- a/spp_api_v2/security/privileges.xml +++ b/spp_api_v2/security/privileges.xml @@ -8,4 +8,11 @@ Access to API V2 management system + + + + API V2 Auditor + + View sensitive payload data in API logs + diff --git a/spp_api_v2/tests/test_api_audit_log.py b/spp_api_v2/tests/test_api_audit_log.py index 7f42816b..6efcda66 100644 --- a/spp_api_v2/tests/test_api_audit_log.py +++ b/spp_api_v2/tests/test_api_audit_log.py @@ -1,6 +1,9 @@ # Part of OpenSPP. See LICENSE file for full copyright and licensing details. """Tests for spp.api.audit.log model""" +from odoo import Command +from odoo.exceptions import AccessError + from .common import ApiV2TestCase @@ -508,3 +511,117 @@ def test_optional_fields_can_be_none(self): self.assertFalse(audit_log.model_name) self.assertFalse(audit_log.res_id) self.assertFalse(audit_log.error_detail) + + +class TestAuditLogAuditorSecurity(ApiV2TestCase): + """Test field-level security for auditor group on spp.api.audit.log""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.auditor_group = cls.env.ref("spp_api_v2.group_api_v2_auditor") + cls.viewer_group = cls.env.ref("spp_api_v2.group_api_v2_viewer") + + # Create user with auditor group + cls.auditor_user = cls.env["res.users"].create( + { + "name": "Test Auditor", + "login": "test_auditor_audit", + "group_ids": [ + Command.link(cls.env.ref("base.group_user").id), + Command.link(cls.auditor_group.id), + ], + } + ) + + # Create user with viewer group only (no auditor) + cls.viewer_user = cls.env["res.users"].create( + { + "name": "Test Viewer", + "login": "test_viewer_audit", + "group_ids": [ + Command.link(cls.env.ref("base.group_user").id), + Command.link(cls.viewer_group.id), + ], + } + ) + + def setUp(self): + super().setUp() + self.individual = self.create_test_individual( + name="Jane Doe", + identifier_value="IND-SEC-001", + ) + self.api_client = self.create_api_client( + name="Security Test Client", + scopes=[{"resource": "individual", "action": "read"}], + ) + + # Create a log record with sensitive data + self.log_record = ( + self.env["spp.api.audit.log"] + .sudo() + .log_operation( + api_client=self.api_client, + operation="search", + resource_type="individual", + resource_identifier="search", + status="error", + search_parameters={"birthDate[gte]": "1990-01-01", "national_id": "999888777"}, + fields_returned=["name", "birthDate", "gender"], + extensions_returned=["farmer", "disability"], + ip_address="192.168.1.100", + user_agent="MaliciousBot/1.0", + error_detail="Internal DB error: host=10.0.0.5 port=5432", + ) + ) + + def test_auditor_can_read_sensitive_fields(self): + """User with auditor group can read all sensitive fields""" + log = self.log_record.with_user(self.auditor_user) + self.assertTrue(log.search_parameters) + self.assertEqual(log.search_parameters["national_id"], "999888777") + self.assertEqual(log.fields_returned, ["name", "birthDate", "gender"]) + self.assertEqual(log.extensions_returned, ["farmer", "disability"]) + self.assertEqual(log.ip_address, "192.168.1.100") + self.assertEqual(log.user_agent, "MaliciousBot/1.0") + self.assertIn("10.0.0.5", log.error_detail) + + def test_non_auditor_cannot_read_sensitive_fields(self): + """User without auditor group gets AccessError for sensitive fields""" + log = self.log_record.with_user(self.viewer_user) + with self.assertRaises(AccessError): + _ = log.search_parameters + with self.assertRaises(AccessError): + _ = log.fields_returned + with self.assertRaises(AccessError): + _ = log.extensions_returned + with self.assertRaises(AccessError): + _ = log.ip_address + with self.assertRaises(AccessError): + _ = log.user_agent + with self.assertRaises(AccessError): + _ = log.error_detail + + def test_non_auditor_can_read_metadata_fields(self): + """User without auditor group can still read non-sensitive metadata""" + log = self.log_record.with_user(self.viewer_user) + self.assertEqual(log.operation, "search") + self.assertEqual(log.resource_type, "individual") + self.assertEqual(log.status, "error") + self.assertTrue(log.timestamp) + self.assertTrue(log.api_client_id) + + def test_sensitive_fields_hidden_in_fields_get(self): + """fields_get for non-auditor user excludes sensitive fields""" + sensitive_fields = [ + "search_parameters", + "fields_returned", + "extensions_returned", + "ip_address", + "user_agent", + "error_detail", + ] + fields_info = self.env["spp.api.audit.log"].with_user(self.viewer_user).fields_get(sensitive_fields) + for field_name in sensitive_fields: + self.assertNotIn(field_name, fields_info) diff --git a/spp_api_v2/tests/test_api_outgoing_log.py b/spp_api_v2/tests/test_api_outgoing_log.py new file mode 100644 index 00000000..804eb520 --- /dev/null +++ b/spp_api_v2/tests/test_api_outgoing_log.py @@ -0,0 +1,306 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Tests for spp.api.outgoing.log model""" + +from odoo import Command +from odoo.exceptions import AccessError +from odoo.tests.common import TransactionCase + + +class TestApiOutgoingLog(TransactionCase): + """Test spp.api.outgoing.log model functionality""" + + def setUp(self): + super().setUp() + self.outgoing_log_model = self.env["spp.api.outgoing.log"] + + def test_log_call_creates_record(self): + """log_call creates outgoing log record with all fields""" + log = self.outgoing_log_model.sudo().log_call( + url="https://crvs.example.org/api/registry/sync/search", + http_method="POST", + endpoint="/registry/sync/search", + request_summary={"header": {"action": "search"}, "message": {}}, + response_summary={"header": {"status": "success"}}, + response_status_code=200, + user_id=self.env.uid, + origin_model="spp.dci.data.source", + origin_record_id=42, + duration_ms=350, + service_name="DCI Client", + service_code="crvs_main", + status="success", + ) + + self.assertTrue(log, "Log record should be created") + self.assertEqual(log.url, "https://crvs.example.org/api/registry/sync/search") + self.assertEqual(log.http_method, "POST") + self.assertEqual(log.endpoint, "/registry/sync/search") + self.assertEqual(log.request_summary, {"header": {"action": "search"}, "message": {}}) + self.assertEqual(log.response_summary, {"header": {"status": "success"}}) + self.assertEqual(log.response_status_code, 200) + self.assertEqual(log.user_id.id, self.env.uid) + self.assertEqual(log.origin_model, "spp.dci.data.source") + self.assertEqual(log.origin_record_id, 42) + self.assertEqual(log.duration_ms, 350) + self.assertEqual(log.service_name, "DCI Client") + self.assertEqual(log.service_code, "crvs_main") + self.assertEqual(log.status, "success") + self.assertIsNotNone(log.timestamp) + + def test_log_call_required_fields_only(self): + """log_call works with only required fields (url)""" + log = self.outgoing_log_model.sudo().log_call( + url="https://example.org/api/test", + ) + + self.assertTrue(log) + self.assertEqual(log.url, "https://example.org/api/test") + self.assertEqual(log.http_method, "POST") + self.assertEqual(log.status, "success") + self.assertIsNotNone(log.timestamp) + # Optional fields should be falsy + self.assertFalse(log.endpoint) + self.assertFalse(log.request_summary) + self.assertFalse(log.response_summary) + self.assertFalse(log.response_status_code) + self.assertFalse(log.origin_model) + self.assertFalse(log.duration_ms) + self.assertFalse(log.service_name) + self.assertFalse(log.service_code) + self.assertFalse(log.error_detail) + + def test_log_call_all_status_options(self): + """log_call accepts all status options""" + statuses = ["success", "http_error", "connection_error", "timeout", "error"] + + for status in statuses: + log = self.outgoing_log_model.sudo().log_call( + url="https://example.org/api/test", + status=status, + ) + self.assertEqual(log.status, status) + + def test_log_call_all_http_methods(self): + """log_call accepts all HTTP method options""" + methods = ["POST", "GET", "PUT", "PATCH", "DELETE"] + + for method in methods: + log = self.outgoing_log_model.sudo().log_call( + url="https://example.org/api/test", + http_method=method, + ) + self.assertEqual(log.http_method, method) + + def test_display_name_computed(self): + """display_name is computed from http_method, endpoint, and timestamp""" + log = self.outgoing_log_model.sudo().log_call( + url="https://example.org/api/test", + http_method="POST", + endpoint="/registry/sync/search", + ) + + self.assertTrue(log.display_name) + self.assertIn("POST", log.display_name) + self.assertIn("/registry/sync/search", log.display_name) + + def test_display_name_falls_back_to_url(self): + """display_name uses url when endpoint is not set""" + log = self.outgoing_log_model.sudo().log_call( + url="https://example.org/api/test", + http_method="GET", + ) + + self.assertTrue(log.display_name) + self.assertIn("GET", log.display_name) + self.assertIn("https://example.org/api/test", log.display_name) + + def test_ordering_timestamp_desc(self): + """Records are ordered by timestamp desc (most recent first)""" + log1 = self.outgoing_log_model.sudo().log_call(url="https://example.org/1") + log2 = self.outgoing_log_model.sudo().log_call(url="https://example.org/2") + log3 = self.outgoing_log_model.sudo().log_call(url="https://example.org/3") + + # Search with default ordering (id desc since timestamp identical in transaction) + logs = self.outgoing_log_model.search( + [("id", "in", [log1.id, log2.id, log3.id])], + order="id desc", + ) + + self.assertEqual(logs[0].id, log3.id) + self.assertEqual(logs[1].id, log2.id) + self.assertEqual(logs[2].id, log1.id) + + def test_json_fields_store_dicts(self): + """Json fields store and return dict objects""" + request_data = {"header": {"action": "search"}, "message": {"query": "test"}} + response_data = {"header": {"status": "success"}, "results": [1, 2, 3]} + + log = self.outgoing_log_model.sudo().log_call( + url="https://example.org/api/test", + request_summary=request_data, + response_summary=response_data, + ) + + self.assertEqual(log.request_summary, request_data) + self.assertEqual(log.response_summary, response_data) + self.assertIsInstance(log.request_summary, dict) + self.assertIsInstance(log.response_summary, dict) + + def test_optional_fields_can_be_none(self): + """Optional fields can be omitted without error""" + log = self.outgoing_log_model.sudo().log_call( + url="https://example.org/api/test", + ) + + self.assertTrue(log) + self.assertFalse(log.endpoint) + self.assertFalse(log.request_summary) + self.assertFalse(log.response_summary) + self.assertFalse(log.response_status_code) + self.assertFalse(log.origin_model) + self.assertFalse(log.origin_record_id) + self.assertFalse(log.duration_ms) + self.assertFalse(log.service_name) + self.assertFalse(log.service_code) + self.assertFalse(log.error_detail) + + def test_log_call_with_error_detail(self): + """log_call stores error detail text""" + log = self.outgoing_log_model.sudo().log_call( + url="https://example.org/api/test", + status="http_error", + response_status_code=500, + error_detail="Internal Server Error: Database connection failed", + ) + + self.assertEqual(log.status, "http_error") + self.assertEqual(log.response_status_code, 500) + self.assertEqual(log.error_detail, "Internal Server Error: Database connection failed") + + def test_default_user_id(self): + """user_id defaults to current user when not specified""" + log = self.outgoing_log_model.sudo().create( + { + "url": "https://example.org/api/test", + } + ) + + self.assertTrue(log.user_id) + + def test_zero_integer_values(self): + """Zero is a valid value for integer fields (status_code=0, duration_ms=0)""" + log = self.outgoing_log_model.sudo().log_call( + url="https://example.org/api/test", + response_status_code=0, + duration_ms=0, + origin_record_id=0, + ) + + self.assertEqual(log.response_status_code, 0) + self.assertEqual(log.duration_ms, 0) + self.assertEqual(log.origin_record_id, 0) + + def test_empty_string_fields(self): + """Empty strings for optional Char fields are stored as falsy""" + log = self.outgoing_log_model.sudo().log_call( + url="https://example.org/api/test", + endpoint="", + service_name="", + service_code="", + origin_model="", + ) + + # Empty strings are falsy in Odoo Char fields + self.assertFalse(log.endpoint) + self.assertFalse(log.service_name) + self.assertFalse(log.service_code) + self.assertFalse(log.origin_model) + + +class TestOutgoingLogAuditorSecurity(TransactionCase): + """Test field-level security for auditor group on spp.api.outgoing.log""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.auditor_group = cls.env.ref("spp_api_v2.group_api_v2_auditor") + cls.viewer_group = cls.env.ref("spp_api_v2.group_api_v2_viewer") + + # Create user with auditor group + cls.auditor_user = cls.env["res.users"].create( + { + "name": "Test Auditor", + "login": "test_auditor_outgoing", + "group_ids": [ + Command.link(cls.env.ref("base.group_user").id), + Command.link(cls.auditor_group.id), + ], + } + ) + + # Create user with viewer group only (no auditor) + cls.viewer_user = cls.env["res.users"].create( + { + "name": "Test Viewer", + "login": "test_viewer_outgoing", + "group_ids": [ + Command.link(cls.env.ref("base.group_user").id), + Command.link(cls.viewer_group.id), + ], + } + ) + + # Create a log record with sensitive data + cls.log_record = ( + cls.env["spp.api.outgoing.log"] + .sudo() + .log_call( + url="https://crvs.example.org/api/registry/sync/search", + http_method="POST", + endpoint="/registry/sync/search", + request_summary={"header": {"action": "search"}, "message": {"national_id": "123456"}}, + response_summary={"header": {"status": "success"}, "records": [{"name": "John"}]}, + status="http_error", + error_detail="Connection refused: internal proxy at 10.0.0.5:8443", + ) + ) + + def test_auditor_can_read_sensitive_fields(self): + """User with auditor group can read request_summary, response_summary, error_detail""" + log = self.log_record.with_user(self.auditor_user) + self.assertTrue(log.request_summary) + self.assertEqual(log.request_summary["message"]["national_id"], "123456") + self.assertTrue(log.response_summary) + self.assertEqual(log.response_summary["records"][0]["name"], "John") + self.assertTrue(log.error_detail) + self.assertIn("10.0.0.5", log.error_detail) + + def test_non_auditor_cannot_read_sensitive_fields(self): + """User without auditor group gets AccessError for sensitive fields""" + log = self.log_record.with_user(self.viewer_user) + with self.assertRaises(AccessError): + _ = log.request_summary + with self.assertRaises(AccessError): + _ = log.response_summary + with self.assertRaises(AccessError): + _ = log.error_detail + + def test_non_auditor_can_read_metadata_fields(self): + """User without auditor group can still read non-sensitive metadata""" + log = self.log_record.with_user(self.viewer_user) + self.assertEqual(log.url, "https://crvs.example.org/api/registry/sync/search") + self.assertEqual(log.endpoint, "/registry/sync/search") + self.assertEqual(log.http_method, "POST") + self.assertEqual(log.status, "http_error") + self.assertTrue(log.timestamp) + + def test_sensitive_fields_hidden_in_fields_get(self): + """fields_get for non-auditor user excludes sensitive fields""" + fields_info = ( + self.env["spp.api.outgoing.log"] + .with_user(self.viewer_user) + .fields_get(["request_summary", "response_summary", "error_detail"]) + ) + self.assertNotIn("request_summary", fields_info) + self.assertNotIn("response_summary", fields_info) + self.assertNotIn("error_detail", fields_info) diff --git a/spp_api_v2/views/api_outgoing_log_views.xml b/spp_api_v2/views/api_outgoing_log_views.xml new file mode 100644 index 00000000..6cc1325c --- /dev/null +++ b/spp_api_v2/views/api_outgoing_log_views.xml @@ -0,0 +1,144 @@ + + + + + spp.api.outgoing.log.tree + spp.api.outgoing.log + + + + + + + + + + + + + + + + + spp.api.outgoing.log.form + spp.api.outgoing.log + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + spp.api.outgoing.log.search + spp.api.outgoing.log + + + + + + + + + + + + + + + + + + + + + + + Outgoing API Logs + spp.api.outgoing.log + list,form + {'search_default_filter_today': 1} + +

+ No outgoing API calls logged yet +

+

+ Outgoing API logs capture all HTTP requests made to external services + such as DCI-compliant registries, webhooks, and other integrations. +

+
+
+
From d0916c40883afbdffc05f8e717731d1898b6bcc7 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 13 Feb 2026 22:13:30 +0100 Subject: [PATCH 02/20] feat(spp_api_v2): add outgoing API log model, service, and menu Wire up the spp.api.outgoing.log model with manifest registration, ACL rules (viewer read-only, officer/manager read+write), the OutgoingApiLogService wrapper with payload truncation and error resilience, and an "Outgoing API Logs" menu item under API V2. --- spp_api_v2/__manifest__.py | 1 + spp_api_v2/models/__init__.py | 1 + spp_api_v2/security/ir.model.access.csv | 3 + spp_api_v2/services/__init__.py | 1 + .../services/outgoing_api_log_service.py | 135 +++++++++++++ spp_api_v2/tests/__init__.py | 2 + .../tests/test_outgoing_api_log_service.py | 188 ++++++++++++++++++ spp_api_v2/views/menu.xml | 10 + 8 files changed, 341 insertions(+) create mode 100644 spp_api_v2/services/outgoing_api_log_service.py create mode 100644 spp_api_v2/tests/test_outgoing_api_log_service.py diff --git a/spp_api_v2/__manifest__.py b/spp_api_v2/__manifest__.py index 7d33d8ab..0dc19b9a 100644 --- a/spp_api_v2/__manifest__.py +++ b/spp_api_v2/__manifest__.py @@ -37,6 +37,7 @@ "views/api_extension_views.xml", "views/api_path_views.xml", "views/consent_views.xml", + "views/api_outgoing_log_views.xml", "views/menu.xml", ], "assets": {}, diff --git a/spp_api_v2/models/__init__.py b/spp_api_v2/models/__init__.py index b71128cc..d42209ce 100644 --- a/spp_api_v2/models/__init__.py +++ b/spp_api_v2/models/__init__.py @@ -1,5 +1,6 @@ from . import api_audit_log from . import api_client +from . import api_outgoing_log from . import api_client_scope from . import api_extension from . import api_filter_preset diff --git a/spp_api_v2/security/ir.model.access.csv b/spp_api_v2/security/ir.model.access.csv index fc828d49..7f0de4a1 100644 --- a/spp_api_v2/security/ir.model.access.csv +++ b/spp_api_v2/security/ir.model.access.csv @@ -57,3 +57,6 @@ access_spp_api_client_show_secret_wizard_admin,spp.api.client.show.secret.wizard access_spp_api_audit_log_viewer,spp.api.audit.log viewer,model_spp_api_audit_log,group_api_v2_viewer,1,0,0,0 access_spp_api_audit_log_officer,spp.api.audit.log officer,model_spp_api_audit_log,group_api_v2_officer,1,0,1,0 access_spp_api_audit_log_manager,spp.api.audit.log manager,model_spp_api_audit_log,group_api_v2_manager,1,0,1,0 +access_spp_api_outgoing_log_viewer,spp.api.outgoing.log viewer,model_spp_api_outgoing_log,group_api_v2_viewer,1,0,0,0 +access_spp_api_outgoing_log_officer,spp.api.outgoing.log officer,model_spp_api_outgoing_log,group_api_v2_officer,1,0,1,0 +access_spp_api_outgoing_log_manager,spp.api.outgoing.log manager,model_spp_api_outgoing_log,group_api_v2_manager,1,0,1,0 diff --git a/spp_api_v2/services/__init__.py b/spp_api_v2/services/__init__.py index 0667f5aa..e37a0b70 100644 --- a/spp_api_v2/services/__init__.py +++ b/spp_api_v2/services/__init__.py @@ -1,5 +1,6 @@ from . import api_audit_service from . import auth_service +from . import outgoing_api_log_service from . import bundle_service from . import consent_service from . import filter_service diff --git a/spp_api_v2/services/outgoing_api_log_service.py b/spp_api_v2/services/outgoing_api_log_service.py new file mode 100644 index 00000000..9298e5b2 --- /dev/null +++ b/spp_api_v2/services/outgoing_api_log_service.py @@ -0,0 +1,135 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Service for logging outgoing API calls.""" + +import json +import logging + +import psycopg2 + +from odoo.api import Environment + +_logger = logging.getLogger(__name__) + + +class OutgoingApiLogService: + """ + Service for logging outgoing HTTP calls to the audit log. + + Wraps spp.api.outgoing.log with try/except so logging failures + never prevent the actual API call from succeeding. + + Usage: + service = OutgoingApiLogService(env, "DCI Client", "crvs_main") + service.log_call( + url="https://crvs.example.org/api/registry/sync/search", + endpoint="/registry/sync/search", + http_method="POST", + request_summary={"header": {...}}, + response_summary={"header": {...}}, + response_status_code=200, + duration_ms=350, + origin_model="spp.dci.data.source", + origin_record_id=42, + status="success", + ) + """ + + def __init__( + self, + env: Environment, + service_name: str, + service_code: str, + user_id: int = None, + ): + """ + Initialize the outgoing API log service. + + Args: + env: Odoo environment + service_name: Human-readable service name (e.g. "DCI Client") + service_code: Machine-readable service code (e.g. "crvs_main") + user_id: User ID to record (defaults to env.uid) + """ + self.env = env + self.service_name = service_name + self.service_code = service_code + self.user_id = user_id or env.uid + + def log_call( + self, + url: str, + endpoint: str = None, + http_method: str = "POST", + request_summary: dict = None, + response_summary: dict = None, + response_status_code: int = None, + duration_ms: int = None, + origin_model: str = None, + origin_record_id: int = None, + status: str = "success", + error_detail: str = None, + ): + """ + Log an outgoing API call. + + Returns the created record, or None if logging fails. + Logging failures never raise exceptions. + """ + try: + # Truncate large payloads + truncated_request = self._truncate_payload(request_summary) + truncated_response = self._truncate_payload(response_summary) + + return ( + self.env["spp.api.outgoing.log"] + .sudo() + .log_call( + url=url, + endpoint=endpoint, + http_method=http_method, + request_summary=truncated_request, + response_summary=truncated_response, + response_status_code=response_status_code, + user_id=self.user_id, + origin_model=origin_model, + origin_record_id=origin_record_id, + duration_ms=duration_ms, + service_name=self.service_name, + service_code=self.service_code, + status=status, + error_detail=error_detail, + ) + ) + except (KeyError, AttributeError, TypeError) as e: + _logger.warning("Failed to log outgoing API call due to data error: %s", type(e).__name__) + return None + except (psycopg2.Error, ValueError, RuntimeError): + _logger.exception("Failed to log outgoing API call") + return None + + def _truncate_payload(self, payload, max_length=10000): + """Truncate large payloads for DB storage. + + Args: + payload: Dict payload to potentially truncate + max_length: Maximum JSON string length (default 10000) + + Returns: + Original payload if within limit, or truncated version + """ + if payload is None: + return None + + try: + serialized = json.dumps(payload) + except (TypeError, ValueError): + return {"_truncated": True, "_error": "Could not serialize payload"} + + if len(serialized) <= max_length: + return payload + + return { + "_truncated": True, + "_original_length": len(serialized), + "_preview": serialized[:max_length], + } diff --git a/spp_api_v2/tests/__init__.py b/spp_api_v2/tests/__init__.py index 04576327..b85c79ce 100644 --- a/spp_api_v2/tests/__init__.py +++ b/spp_api_v2/tests/__init__.py @@ -4,6 +4,8 @@ from . import common from . import test_api_audit_log from . import test_api_audit_service +from . import test_api_outgoing_log +from . import test_outgoing_api_log_service from . import test_api_auth_enforcement from . import test_api_client from . import test_api_consent_matching diff --git a/spp_api_v2/tests/test_outgoing_api_log_service.py b/spp_api_v2/tests/test_outgoing_api_log_service.py new file mode 100644 index 00000000..31840c72 --- /dev/null +++ b/spp_api_v2/tests/test_outgoing_api_log_service.py @@ -0,0 +1,188 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Tests for OutgoingApiLogService""" + +from odoo.tests.common import TransactionCase + +from ..services.outgoing_api_log_service import OutgoingApiLogService + + +class TestOutgoingApiLogService(TransactionCase): + """Test OutgoingApiLogService functionality""" + + def setUp(self): + super().setUp() + self.outgoing_log_model = self.env["spp.api.outgoing.log"] + + def test_log_call_creates_record(self): + """log_call creates outgoing log record via the service""" + service = OutgoingApiLogService( + self.env, + service_name="DCI Client", + service_code="crvs_main", + ) + + result = service.log_call( + url="https://crvs.example.org/api/registry/sync/search", + endpoint="/registry/sync/search", + http_method="POST", + request_summary={"header": {"action": "search"}}, + response_summary={"header": {"status": "success"}}, + response_status_code=200, + duration_ms=350, + origin_model="spp.dci.data.source", + origin_record_id=42, + status="success", + ) + + self.assertTrue(result) + self.assertEqual(result.url, "https://crvs.example.org/api/registry/sync/search") + self.assertEqual(result.service_name, "DCI Client") + self.assertEqual(result.service_code, "crvs_main") + self.assertEqual(result.status, "success") + + def test_log_call_failure_returns_none(self): + """Logging failures return None and don't raise exceptions""" + # Create a service with a broken env to trigger a failure + bad_service = OutgoingApiLogService( + self.env, + service_name="Bad Service", + service_code="bad", + ) + + # Monkey-patch the model to raise an error + original_log_call = self.outgoing_log_model.__class__.log_call + + def broken_log_call(self_model, **kwargs): + raise RuntimeError("Database error") + + self.outgoing_log_model.__class__.log_call = broken_log_call + try: + result = bad_service.log_call( + url="https://example.org/test", + ) + self.assertIsNone(result) + finally: + self.outgoing_log_model.__class__.log_call = original_log_call + + def test_truncate_payload(self): + """_truncate_payload truncates large payloads""" + service = OutgoingApiLogService( + self.env, + service_name="Test", + service_code="test", + ) + + # Small payload should pass through unchanged + small = {"key": "value"} + self.assertEqual(service._truncate_payload(small), small) + + # None should return None + self.assertIsNone(service._truncate_payload(None)) + + # Large payload should be truncated + large = {"data": "x" * 20000} + result = service._truncate_payload(large, max_length=100) + self.assertTrue(result["_truncated"]) + self.assertIn("_original_length", result) + self.assertIn("_preview", result) + + def test_service_stores_user_id(self): + """Service records the correct user_id""" + service = OutgoingApiLogService( + self.env, + service_name="Test", + service_code="test", + user_id=self.env.uid, + ) + + result = service.log_call( + url="https://example.org/test", + ) + + self.assertTrue(result) + self.assertEqual(result.user_id.id, self.env.uid) + + def test_service_stores_service_context(self): + """Service stores service_name and service_code on log records""" + service = OutgoingApiLogService( + self.env, + service_name="My Integration", + service_code="my_integration_v1", + ) + + result = service.log_call( + url="https://example.org/test", + ) + + self.assertTrue(result) + self.assertEqual(result.service_name, "My Integration") + self.assertEqual(result.service_code, "my_integration_v1") + + def test_service_default_user_id(self): + """Service defaults to env.uid when user_id not provided""" + service = OutgoingApiLogService( + self.env, + service_name="Test", + service_code="test", + ) + + self.assertEqual(service.user_id, self.env.uid) + + def test_truncate_payload_non_serializable(self): + """_truncate_payload handles non-JSON-serializable payloads""" + service = OutgoingApiLogService( + self.env, + service_name="Test", + service_code="test", + ) + + # Object that can't be serialized + result = service._truncate_payload({"key": object()}) + self.assertTrue(result["_truncated"]) + self.assertIn("_error", result) + + def test_truncate_payload_exact_boundary(self): + """_truncate_payload passes through payload at exactly max_length""" + service = OutgoingApiLogService( + self.env, + service_name="Test", + service_code="test", + ) + + # Build a payload whose JSON serialization is exactly max_length + import json + + max_length = 50 + # {"k": "..."} — adjust value to hit exact length + base = json.dumps({"k": ""}) # '{"k": ""}' = 10 chars + filler = "x" * (max_length - len(base)) + payload = {"k": filler} + serialized = json.dumps(payload) + self.assertEqual(len(serialized), max_length) + + result = service._truncate_payload(payload, max_length=max_length) + # Should pass through unchanged (equal to limit) + self.assertEqual(result, payload) + self.assertNotIn("_truncated", result) + + def test_truncate_payload_one_over_boundary(self): + """_truncate_payload truncates payload one byte over max_length""" + service = OutgoingApiLogService( + self.env, + service_name="Test", + service_code="test", + ) + + import json + + max_length = 50 + base = json.dumps({"k": ""}) + filler = "x" * (max_length - len(base) + 1) + payload = {"k": filler} + serialized = json.dumps(payload) + self.assertEqual(len(serialized), max_length + 1) + + result = service._truncate_payload(payload, max_length=max_length) + self.assertTrue(result["_truncated"]) + self.assertEqual(result["_original_length"], max_length + 1) + self.assertEqual(len(result["_preview"]), max_length) diff --git a/spp_api_v2/views/menu.xml b/spp_api_v2/views/menu.xml index 4a5f6b4a..5d998d23 100644 --- a/spp_api_v2/views/menu.xml +++ b/spp_api_v2/views/menu.xml @@ -58,4 +58,14 @@ sequence="50" groups="spp_api_v2.group_api_v2_viewer" /> + + + From e36e14fd1e0af0101d84197de2195a8b5fc80a1c Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 18 Feb 2026 16:33:47 +0700 Subject: [PATCH 03/20] fix(spp_api_v2): add payload masking and URL sanitization to outgoing API log - Add groups restriction to url field and XML view to limit access to auditors - Add _sanitize_url method to strip sensitive query parameters before logging - Call _sanitize_url in log_call so stored URLs never contain tokens or keys - Add tests covering _sanitize_url and end-to-end URL sanitization in log_call --- spp_api_v2/models/api_outgoing_log.py | 3 +- .../services/outgoing_api_log_service.py | 97 ++++++++- spp_api_v2/tests/test_api_outgoing_log.py | 9 +- .../tests/test_outgoing_api_log_service.py | 197 +++++++++++++++++- spp_api_v2/views/api_outgoing_log_views.xml | 5 +- 5 files changed, 297 insertions(+), 14 deletions(-) diff --git a/spp_api_v2/models/api_outgoing_log.py b/spp_api_v2/models/api_outgoing_log.py index a1fe7446..08679cba 100644 --- a/spp_api_v2/models/api_outgoing_log.py +++ b/spp_api_v2/models/api_outgoing_log.py @@ -27,7 +27,8 @@ class ApiOutgoingLog(models.Model): url = fields.Char( required=True, index=True, - help="Full URL called", + groups="spp_api_v2.group_api_v2_auditor", + help="Full URL called (may contain sensitive query parameters)", ) endpoint = fields.Char( diff --git a/spp_api_v2/services/outgoing_api_log_service.py b/spp_api_v2/services/outgoing_api_log_service.py index 9298e5b2..5e825a4f 100644 --- a/spp_api_v2/services/outgoing_api_log_service.py +++ b/spp_api_v2/services/outgoing_api_log_service.py @@ -3,6 +3,7 @@ import json import logging +from urllib.parse import parse_qs, urlencode, urlparse, urlunparse import psycopg2 @@ -55,6 +56,26 @@ def __init__( self.service_code = service_code self.user_id = user_id or env.uid + # Sensitive key patterns matched case-insensitively + SENSITIVE_KEYS = frozenset( + { + "authorization", + "password", + "token", + "access_token", + "refresh_token", + "api_key", + "apikey", + "secret", + "client_secret", + "credential", + "credentials", + "private_key", + } + ) + + MASK_VALUE = "***MASKED***" + def log_call( self, url: str, @@ -76,15 +97,21 @@ def log_call( Logging failures never raise exceptions. """ try: - # Truncate large payloads - truncated_request = self._truncate_payload(request_summary) - truncated_response = self._truncate_payload(response_summary) - + # Sanitize URL, mask sensitive keys, then truncate large payloads + safe_url = self._sanitize_url(url) + masked_request = self._mask_sensitive_keys(request_summary) + masked_response = self._mask_sensitive_keys(response_summary) + truncated_request = self._truncate_payload(masked_request) + truncated_response = self._truncate_payload(masked_response) + + # sudo() is intentional: log records must be created regardless of + # the calling user's permissions on spp.api.outgoing.log. The + # service is an internal component, not a user-facing API. return ( self.env["spp.api.outgoing.log"] .sudo() .log_call( - url=url, + url=safe_url, endpoint=endpoint, http_method=http_method, request_summary=truncated_request, @@ -107,6 +134,66 @@ def log_call( _logger.exception("Failed to log outgoing API call") return None + def _mask_sensitive_keys(self, payload): + """Recursively mask values of known sensitive keys in a payload. + + Args: + payload: Dict payload to mask, or None. + + Returns: + A new dict with sensitive values replaced by MASK_VALUE, + or None if input is None. + """ + if payload is None: + return None + + return self._mask_recursive(payload) + + def _mask_recursive(self, obj): + """Walk a structure and mask sensitive dict keys.""" + if isinstance(obj, dict): + return { + key: (self.MASK_VALUE if key.lower() in self.SENSITIVE_KEYS else self._mask_recursive(value)) + for key, value in obj.items() + } + if isinstance(obj, list): + return [self._mask_recursive(item) for item in obj] + return obj + + def _sanitize_url(self, url): + """Remove sensitive query parameters from a URL before logging. + + Query parameters whose names match SENSITIVE_KEYS (case-insensitively) + are replaced with MASK_VALUE so that API keys or tokens embedded in + query strings are never persisted. + + Args: + url: URL string to sanitize. + + Returns: + Sanitized URL string with sensitive query parameters masked, + or the original value if it cannot be parsed as a URL. + """ + if not url: + return url + + try: + parsed = urlparse(url) + except Exception: + return url + + if not parsed.query: + return url + + params = parse_qs(parsed.query, keep_blank_values=True) + sanitized_params = { + key: ([self.MASK_VALUE] if key.lower() in self.SENSITIVE_KEYS else values) for key, values in params.items() + } + + sanitized_query = urlencode(sanitized_params, doseq=True) + sanitized = parsed._replace(query=sanitized_query) + return urlunparse(sanitized) + def _truncate_payload(self, payload, max_length=10000): """Truncate large payloads for DB storage. diff --git a/spp_api_v2/tests/test_api_outgoing_log.py b/spp_api_v2/tests/test_api_outgoing_log.py index 804eb520..28745525 100644 --- a/spp_api_v2/tests/test_api_outgoing_log.py +++ b/spp_api_v2/tests/test_api_outgoing_log.py @@ -266,8 +266,9 @@ def setUpClass(cls): ) def test_auditor_can_read_sensitive_fields(self): - """User with auditor group can read request_summary, response_summary, error_detail""" + """User with auditor group can read url, request_summary, response_summary, error_detail""" log = self.log_record.with_user(self.auditor_user) + self.assertEqual(log.url, "https://crvs.example.org/api/registry/sync/search") self.assertTrue(log.request_summary) self.assertEqual(log.request_summary["message"]["national_id"], "123456") self.assertTrue(log.response_summary) @@ -278,6 +279,8 @@ def test_auditor_can_read_sensitive_fields(self): def test_non_auditor_cannot_read_sensitive_fields(self): """User without auditor group gets AccessError for sensitive fields""" log = self.log_record.with_user(self.viewer_user) + with self.assertRaises(AccessError): + _ = log.url with self.assertRaises(AccessError): _ = log.request_summary with self.assertRaises(AccessError): @@ -288,7 +291,6 @@ def test_non_auditor_cannot_read_sensitive_fields(self): def test_non_auditor_can_read_metadata_fields(self): """User without auditor group can still read non-sensitive metadata""" log = self.log_record.with_user(self.viewer_user) - self.assertEqual(log.url, "https://crvs.example.org/api/registry/sync/search") self.assertEqual(log.endpoint, "/registry/sync/search") self.assertEqual(log.http_method, "POST") self.assertEqual(log.status, "http_error") @@ -299,8 +301,9 @@ def test_sensitive_fields_hidden_in_fields_get(self): fields_info = ( self.env["spp.api.outgoing.log"] .with_user(self.viewer_user) - .fields_get(["request_summary", "response_summary", "error_detail"]) + .fields_get(["url", "request_summary", "response_summary", "error_detail"]) ) + self.assertNotIn("url", fields_info) self.assertNotIn("request_summary", fields_info) self.assertNotIn("response_summary", fields_info) self.assertNotIn("error_detail", fields_info) diff --git a/spp_api_v2/tests/test_outgoing_api_log_service.py b/spp_api_v2/tests/test_outgoing_api_log_service.py index 31840c72..c3882ada 100644 --- a/spp_api_v2/tests/test_outgoing_api_log_service.py +++ b/spp_api_v2/tests/test_outgoing_api_log_service.py @@ -1,6 +1,8 @@ # Part of OpenSPP. See LICENSE file for full copyright and licensing details. """Tests for OutgoingApiLogService""" +import json + from odoo.tests.common import TransactionCase from ..services.outgoing_api_log_service import OutgoingApiLogService @@ -150,8 +152,6 @@ def test_truncate_payload_exact_boundary(self): ) # Build a payload whose JSON serialization is exactly max_length - import json - max_length = 50 # {"k": "..."} — adjust value to hit exact length base = json.dumps({"k": ""}) # '{"k": ""}' = 10 chars @@ -173,8 +173,6 @@ def test_truncate_payload_one_over_boundary(self): service_code="test", ) - import json - max_length = 50 base = json.dumps({"k": ""}) filler = "x" * (max_length - len(base) + 1) @@ -186,3 +184,194 @@ def test_truncate_payload_one_over_boundary(self): self.assertTrue(result["_truncated"]) self.assertEqual(result["_original_length"], max_length + 1) self.assertEqual(len(result["_preview"]), max_length) + + def test_mask_sensitive_keys_top_level(self): + """_mask_sensitive_keys masks known sensitive keys at top level""" + service = OutgoingApiLogService(self.env, service_name="Test", service_code="test") + + payload = { + "Authorization": "Bearer secret-token-123", + "Content-Type": "application/json", + "data": "visible", + } + + result = service._mask_sensitive_keys(payload) + self.assertEqual(result["Authorization"], "***MASKED***") + self.assertEqual(result["Content-Type"], "application/json") + self.assertEqual(result["data"], "visible") + + def test_mask_sensitive_keys_nested(self): + """_mask_sensitive_keys masks sensitive keys in nested dicts""" + service = OutgoingApiLogService(self.env, service_name="Test", service_code="test") + + payload = { + "header": { + "authorization": "Bearer xyz", + "action": "search", + }, + "body": {"password": "s3cret", "username": "admin"}, + } + + result = service._mask_sensitive_keys(payload) + self.assertEqual(result["header"]["authorization"], "***MASKED***") + self.assertEqual(result["header"]["action"], "search") + self.assertEqual(result["body"]["password"], "***MASKED***") + self.assertEqual(result["body"]["username"], "admin") + + def test_mask_sensitive_keys_case_insensitive(self): + """_mask_sensitive_keys matches key names case-insensitively""" + service = OutgoingApiLogService(self.env, service_name="Test", service_code="test") + + payload = { + "API_KEY": "key123", + "api_key": "key456", + "Api_Key": "key789", + } + + result = service._mask_sensitive_keys(payload) + for key in payload: + self.assertEqual(result[key], "***MASKED***") + + def test_mask_sensitive_keys_various_sensitive_names(self): + """_mask_sensitive_keys masks all common sensitive key names""" + service = OutgoingApiLogService(self.env, service_name="Test", service_code="test") + + sensitive_keys = [ + "authorization", + "password", + "token", + "access_token", + "refresh_token", + "api_key", + "apikey", + "secret", + "client_secret", + "credential", + "private_key", + ] + + payload = {key: f"value_{key}" for key in sensitive_keys} + result = service._mask_sensitive_keys(payload) + + for key in sensitive_keys: + self.assertEqual( + result[key], + "***MASKED***", + f"Key '{key}' should be masked", + ) + + def test_mask_sensitive_keys_preserves_non_dict_values(self): + """_mask_sensitive_keys handles lists and non-dict nested structures""" + service = OutgoingApiLogService(self.env, service_name="Test", service_code="test") + + payload = { + "items": [ + {"password": "secret", "name": "item1"}, + {"token": "abc", "name": "item2"}, + ], + "count": 2, + } + + result = service._mask_sensitive_keys(payload) + self.assertEqual(result["items"][0]["password"], "***MASKED***") + self.assertEqual(result["items"][0]["name"], "item1") + self.assertEqual(result["items"][1]["token"], "***MASKED***") + self.assertEqual(result["items"][1]["name"], "item2") + self.assertEqual(result["count"], 2) + + def test_mask_sensitive_keys_none_input(self): + """_mask_sensitive_keys returns None for None input""" + service = OutgoingApiLogService(self.env, service_name="Test", service_code="test") + + self.assertIsNone(service._mask_sensitive_keys(None)) + + def test_mask_sensitive_keys_does_not_mutate_original(self): + """_mask_sensitive_keys returns a new dict without mutating the input""" + service = OutgoingApiLogService(self.env, service_name="Test", service_code="test") + + payload = {"password": "secret", "data": "visible"} + result = service._mask_sensitive_keys(payload) + + self.assertEqual(payload["password"], "secret") + self.assertEqual(result["password"], "***MASKED***") + + def test_log_call_masks_sensitive_keys_in_payloads(self): + """log_call applies masking to request and response payloads""" + service = OutgoingApiLogService(self.env, service_name="Test", service_code="test") + + result = service.log_call( + url="https://example.org/api/test", + request_summary={"authorization": "Bearer xyz", "action": "search"}, + response_summary={"token": "resp-token", "status": "ok"}, + ) + + self.assertTrue(result) + self.assertEqual(result.request_summary["authorization"], "***MASKED***") + self.assertEqual(result.request_summary["action"], "search") + self.assertEqual(result.response_summary["token"], "***MASKED***") + self.assertEqual(result.response_summary["status"], "ok") + + def test_sanitize_url_no_sensitive_params(self): + """_sanitize_url leaves URLs without sensitive params unchanged""" + service = OutgoingApiLogService(self.env, service_name="Test", service_code="test") + + url = "https://example.org/api/search?q=hello&limit=10" + result = service._sanitize_url(url) + self.assertIn("q=hello", result) + self.assertIn("limit=10", result) + + def test_sanitize_url_masks_sensitive_params(self): + """_sanitize_url replaces sensitive query parameter values with MASKED""" + service = OutgoingApiLogService(self.env, service_name="Test", service_code="test") + + url = "https://example.org/api/search?api_key=secret123&q=hello" + result = service._sanitize_url(url) + self.assertNotIn("secret123", result) + self.assertIn("***MASKED***", result) + self.assertIn("q=hello", result) + + def test_sanitize_url_masks_multiple_sensitive_params(self): + """_sanitize_url masks all sensitive params in a single URL""" + service = OutgoingApiLogService(self.env, service_name="Test", service_code="test") + + url = "https://example.org/api?token=abc&api_key=xyz&page=1" + result = service._sanitize_url(url) + self.assertNotIn("abc", result) + self.assertNotIn("xyz", result) + self.assertIn("page=1", result) + + def test_sanitize_url_case_insensitive_params(self): + """_sanitize_url matches sensitive param names case-insensitively""" + service = OutgoingApiLogService(self.env, service_name="Test", service_code="test") + + url = "https://example.org/api?API_KEY=secret&Access_Token=tok" + result = service._sanitize_url(url) + self.assertNotIn("secret", result) + self.assertNotIn("tok", result) + + def test_sanitize_url_no_query_string(self): + """_sanitize_url returns URL unchanged when there is no query string""" + service = OutgoingApiLogService(self.env, service_name="Test", service_code="test") + + url = "https://example.org/api/search" + result = service._sanitize_url(url) + self.assertEqual(result, url) + + def test_sanitize_url_none_input(self): + """_sanitize_url returns None for None input""" + service = OutgoingApiLogService(self.env, service_name="Test", service_code="test") + + self.assertIsNone(service._sanitize_url(None)) + + def test_log_call_sanitizes_url(self): + """log_call strips sensitive query parameters from URL before storing""" + service = OutgoingApiLogService(self.env, service_name="Test", service_code="test") + + result = service.log_call( + url="https://example.org/api/search?api_key=supersecret&q=hello", + ) + + self.assertTrue(result) + self.assertNotIn("supersecret", result.url) + self.assertIn("***MASKED***", result.url) + self.assertIn("q=hello", result.url) diff --git a/spp_api_v2/views/api_outgoing_log_views.xml b/spp_api_v2/views/api_outgoing_log_views.xml index 6cc1325c..296a645f 100644 --- a/spp_api_v2/views/api_outgoing_log_views.xml +++ b/spp_api_v2/views/api_outgoing_log_views.xml @@ -30,7 +30,10 @@ - + From 6438991731dee1bb9afd6781509abb8e5a455783 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 18 Feb 2026 17:23:25 +0700 Subject: [PATCH 04/20] fix(spp_api_v2): remove url fallback from display_name to prevent security leak The _compute_display_name method was falling back to record.url when endpoint was not set. Since url has groups="spp_api_v2.group_api_v2_auditor" but display_name is store=True with no groups restriction, the URL value was being persisted into an unrestricted field, bypassing field-level security. Also adds url to @api.depends implicitly by removing the reference entirely. Replace the url fallback with a generic "API Call" string. --- spp_api_v2/models/api_outgoing_log.py | 2 +- .../services/outgoing_api_log_service.py | 35 +++++++++---------- 2 files changed, 17 insertions(+), 20 deletions(-) diff --git a/spp_api_v2/models/api_outgoing_log.py b/spp_api_v2/models/api_outgoing_log.py index 08679cba..7a2b073e 100644 --- a/spp_api_v2/models/api_outgoing_log.py +++ b/spp_api_v2/models/api_outgoing_log.py @@ -142,7 +142,7 @@ class ApiOutgoingLog(models.Model): def _compute_display_name(self): for record in self: timestamp_str = record.timestamp.strftime("%Y-%m-%d %H:%M") if record.timestamp else "" - record.display_name = f"{record.http_method} {record.endpoint or record.url} @ {timestamp_str}" + record.display_name = f"{record.http_method} {record.endpoint or 'API Call'} @ {timestamp_str}" # ========================================== # API Methods diff --git a/spp_api_v2/services/outgoing_api_log_service.py b/spp_api_v2/services/outgoing_api_log_service.py index 5e825a4f..5831b60d 100644 --- a/spp_api_v2/services/outgoing_api_log_service.py +++ b/spp_api_v2/services/outgoing_api_log_service.py @@ -107,25 +107,22 @@ def log_call( # sudo() is intentional: log records must be created regardless of # the calling user's permissions on spp.api.outgoing.log. The # service is an internal component, not a user-facing API. - return ( - self.env["spp.api.outgoing.log"] - .sudo() - .log_call( - url=safe_url, - endpoint=endpoint, - http_method=http_method, - request_summary=truncated_request, - response_summary=truncated_response, - response_status_code=response_status_code, - user_id=self.user_id, - origin_model=origin_model, - origin_record_id=origin_record_id, - duration_ms=duration_ms, - service_name=self.service_name, - service_code=self.service_code, - status=status, - error_detail=error_detail, - ) + log_model = self.env["spp.api.outgoing.log"].sudo() # nosemgrep: odoo-sudo-without-context + return log_model.log_call( + url=safe_url, + endpoint=endpoint, + http_method=http_method, + request_summary=truncated_request, + response_summary=truncated_response, + response_status_code=response_status_code, + user_id=self.user_id, + origin_model=origin_model, + origin_record_id=origin_record_id, + duration_ms=duration_ms, + service_name=self.service_name, + service_code=self.service_code, + status=status, + error_detail=error_detail, ) except (KeyError, AttributeError, TypeError) as e: _logger.warning("Failed to log outgoing API call due to data error: %s", type(e).__name__) From 028e4e71f83727f2f934daf48ae1c6c7cd5e2c37 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 7 Feb 2026 15:49:53 +0700 Subject: [PATCH 05/20] fix(dci): allow non-admin users to use DCI verification - Use sudo() when accessing OAuth2 credentials in data source - Use sudo() when caching OAuth2 token (write to restricted model) - Add _after_submit() hook to auto-approve CR on submit if DCI verified This enables users like demo_officer to use DCI birth verification without requiring administrator privileges. --- spp_dci_client/models/data_source.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/spp_dci_client/models/data_source.py b/spp_dci_client/models/data_source.py index a58913c5..08a84def 100644 --- a/spp_dci_client/models/data_source.py +++ b/spp_dci_client/models/data_source.py @@ -326,10 +326,12 @@ def get_oauth2_token(self, force_refresh=False): _logger.info("Requesting new OAuth2 token for data source: %s", self.code) try: + # Use sudo() to access OAuth2 credentials which are restricted to administrators + sudo_self = self.sudo() token_data = { "grant_type": "client_credentials", - "client_id": self.oauth2_client_id, - "client_secret": self.oauth2_client_secret, + "client_id": sudo_self.oauth2_client_id, + "client_secret": sudo_self.oauth2_client_secret, } if self.oauth2_scope: @@ -358,9 +360,9 @@ def get_oauth2_token(self, force_refresh=False): if not access_token: raise UserError(_("OAuth2 token response did not contain access_token")) - # Cache token with expiry + # Cache token with expiry (use sudo to write to restricted model) expires_at = now + timedelta(seconds=expires_in) - self.write( + sudo_self.write( { "_oauth2_access_token": access_token, "_oauth2_token_expires_at": expires_at, From 7bcc8e4ada859709a17242d35de02e9b21705738 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 7 Feb 2026 14:14:00 +0700 Subject: [PATCH 06/20] feat(spp_approval): add action_approve_system() for automated approvals Add a dedicated method for system-initiated approvals that bypasses user permission checks. This enables automated approval workflows triggered by system events (e.g., DCI verification match) where there is no human approver. Also adds security control to invalidate verification when verified fields (name, DOB, gender, BRN) are edited after verification. --- spp_approval/models/approval_mixin.py | 29 +++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/spp_approval/models/approval_mixin.py b/spp_approval/models/approval_mixin.py index 92816eaf..a2f469bb 100644 --- a/spp_approval/models/approval_mixin.py +++ b/spp_approval/models/approval_mixin.py @@ -332,6 +332,35 @@ def action_approve(self, comment=None): record._check_can_approve() record._do_approve(comment=comment) + def action_approve_system(self, comment=None): + """System-initiated approval bypassing user permission checks. + + Use this for automated approvals triggered by system events (e.g., DCI + verification match, scheduled jobs) where there is no human approver. + + This method uses sudo() to bypass access controls and skips the + _check_can_approve() permission validation. + + Args: + comment: Optional approval comment for audit trail + """ + for record in self: + if record.approval_state != "pending": + _logger.warning( + "Skipping system approval for %s %s: state is %s, not pending", + record._name, + record.id, + record.approval_state, + ) + continue + record.sudo()._do_approve(comment=comment) + _logger.info( + "System auto-approved %s %s: %s", + record._name, + record.id, + comment or "(no comment)", + ) + def _do_approve(self, comment=None, auto=False): """Internal method to perform approval.""" self.ensure_one() From 6507a5a632ae0789468defa639cd0f663e87db82 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 13 Feb 2026 22:13:41 +0100 Subject: [PATCH 07/20] feat(spp_dci_client): log outgoing DCI requests to audit trail Instrument _make_request to log all outgoing calls via spp.api.outgoing.log (soft dependency). Logs persist in a separate cursor so they survive transaction rollback on UserError. Captures timing, status codes, request/response payloads, and error details for success, HTTP errors, connection errors, timeouts, and 401 retries. --- spp_dci_client/services/client.py | 122 +++++ spp_dci_client/tests/__init__.py | 1 + .../tests/test_outgoing_log_integration.py | 442 ++++++++++++++++++ 3 files changed, 565 insertions(+) create mode 100644 spp_dci_client/tests/test_outgoing_log_integration.py diff --git a/spp_dci_client/services/client.py b/spp_dci_client/services/client.py index 187dacc0..5ca3a5b2 100644 --- a/spp_dci_client/services/client.py +++ b/spp_dci_client/services/client.py @@ -1,6 +1,7 @@ """DCI Client Service for making signed API requests.""" import logging +import time import uuid from datetime import UTC, datetime from typing import Any @@ -904,6 +905,13 @@ def _make_request(self, endpoint: str, envelope: dict, _retry_auth: bool = True) # Get headers from data source (includes auth) headers = self.data_source.get_headers() + # Track timing and result for outgoing log + start_time = time.monotonic() + log_status = "success" + log_status_code = None + log_response_data = None + log_error_detail = None + try: _logger.info( "Making DCI request to %s (action: %s)", @@ -930,6 +938,13 @@ def _make_request(self, endpoint: str, envelope: dict, _retry_auth: bool = True) # Handle 401 Unauthorized - try refreshing token once if response.status_code == 401 and _retry_auth and self.data_source.auth_type == "oauth2": _logger.warning("Got 401 Unauthorized, clearing OAuth2 token cache and retrying with fresh token") + log_status = "http_error" + log_status_code = 401 + log_error_detail = "401 Unauthorized - retrying with fresh token" + try: + log_response_data = response.json() + except Exception: + log_response_data = None self.data_source.clear_oauth2_token_cache() return self._make_request(endpoint, envelope, _retry_auth=False) @@ -938,6 +953,8 @@ def _make_request(self, endpoint: str, envelope: dict, _retry_auth: bool = True) # Parse response response_data = response.json() + log_status_code = response.status_code + log_response_data = response_data _logger.info( "DCI request successful (status: %s, message_id: %s)", @@ -949,11 +966,15 @@ def _make_request(self, endpoint: str, envelope: dict, _retry_auth: bool = True) return response_data except httpx.HTTPStatusError as e: + log_status = "http_error" + log_status_code = e.response.status_code + # Log technical details for troubleshooting technical_detail = f"DCI request failed with status {e.response.status_code}" response_text = e.response.text try: error_data = e.response.json() + log_response_data = error_data if "header" in error_data and "status_reason_message" in error_data["header"]: technical_detail += f": {error_data['header']['status_reason_message']}" else: @@ -961,6 +982,7 @@ def _make_request(self, endpoint: str, envelope: dict, _retry_auth: bool = True) except Exception: technical_detail += f": {response_text}" + log_error_detail = technical_detail _logger.error(technical_detail) _logger.error("Full response body: %s", response_text) _logger.error("Request envelope was: %s", envelope) @@ -978,26 +1000,126 @@ def _make_request(self, endpoint: str, envelope: dict, _retry_auth: bool = True) error_str = str(e).lower() if "timeout" in error_str or "timed out" in error_str: connection_type = "timeout" + log_status = "timeout" elif "ssl" in error_str or "certificate" in error_str: connection_type = "ssl" + log_status = "connection_error" elif "name or service not known" in error_str or "nodename nor servname" in error_str: connection_type = "dns" + log_status = "connection_error" else: connection_type = "connection" + log_status = "connection_error" + + log_error_detail = technical_detail # Show user-friendly message user_msg = format_connection_error(connection_type, technical_detail) raise UserError(user_msg) from e except Exception as e: + log_status = "error" + # Log technical details for troubleshooting technical_detail = f"Unexpected error during DCI request: {str(e)}" + log_error_detail = technical_detail _logger.error(technical_detail, exc_info=True) # Show generic user-friendly message user_msg = _("An unexpected error occurred. Please contact your administrator.") raise UserError(user_msg) from e + finally: + duration_ms = int((time.monotonic() - start_time) * 1000) + self._log_outgoing_call( + url=url, + endpoint=endpoint, + envelope=envelope, + response_data=log_response_data, + status_code=log_status_code, + duration_ms=duration_ms, + status=log_status, + error_detail=log_error_detail, + ) + + # ========================================================================= + # OUTGOING LOG + # ========================================================================= + + def _log_outgoing_call( + self, + url: str, + endpoint: str, + envelope: dict, + response_data: dict | None, + status_code: int | None, + duration_ms: int, + status: str, + error_detail: str | None, + ): + """Log an outgoing API call to spp.api.outgoing.log (soft dependency). + + Uses runtime check to avoid hard manifest dependency on spp_api_v2. + Uses a separate database cursor so log entries persist even when the + caller's transaction is rolled back (e.g., on UserError). + Logging failures are swallowed so they never block the actual request. + """ + try: + if "spp.api.outgoing.log" not in self.env: + return + + from odoo.addons.spp_api_v2.services.outgoing_api_log_service import OutgoingApiLogService + + # Capture values from the current env before opening a new cursor, + # since self.data_source won't be accessible from the new cursor's env. + service_code = getattr(self.data_source, "code", None) or "dci" + origin_record_id = self.data_source.id if hasattr(self.data_source, "id") else None + user_id = self.env.uid + sanitized_envelope = self._copy_envelope_for_log(envelope) + + # Use a separate cursor so log entries survive transaction rollback. + with self.env.registry.cursor() as new_cr: + new_env = self.env(cr=new_cr) + service = OutgoingApiLogService( + new_env, + service_name="DCI Client", + service_code=service_code, + user_id=user_id, + ) + + service.log_call( + url=url, + endpoint=endpoint, + http_method="POST", + request_summary=sanitized_envelope, + response_summary=response_data, + response_status_code=status_code, + duration_ms=duration_ms, + origin_model="spp.dci.data.source", + origin_record_id=origin_record_id, + status=status, + error_detail=error_detail, + ) + except Exception: + _logger.warning("Failed to log outgoing API call (non-blocking)", exc_info=True) + + def _copy_envelope_for_log(self, envelope: dict) -> dict | None: + """Copy the request envelope for audit log storage. + + Returns a shallow copy suitable for JSON storage. The cryptographic + signature is preserved for auditability (non-repudiation). + + Args: + envelope: Request envelope dict + + Returns: + Copy of the envelope, or None if input is falsy + """ + if not envelope: + return None + + return dict(envelope) + # ========================================================================= # HELPER METHODS # ========================================================================= diff --git a/spp_dci_client/tests/__init__.py b/spp_dci_client/tests/__init__.py index 71410b98..8cd038a7 100644 --- a/spp_dci_client/tests/__init__.py +++ b/spp_dci_client/tests/__init__.py @@ -3,3 +3,4 @@ from . import test_data_source from . import test_client_service +from . import test_outgoing_log_integration diff --git a/spp_dci_client/tests/test_outgoing_log_integration.py b/spp_dci_client/tests/test_outgoing_log_integration.py new file mode 100644 index 00000000..1257ee47 --- /dev/null +++ b/spp_dci_client/tests/test_outgoing_log_integration.py @@ -0,0 +1,442 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Tests for outgoing API log integration in DCI client""" + +import unittest +from unittest.mock import MagicMock, patch + +from odoo.tests import TransactionCase + + +class TestOutgoingLogClientMethods(TransactionCase): + """Test DCI client logging helper methods (no spp_api_v2 dependency needed)""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.DataSource = cls.env["spp.dci.data.source"] + + def _create_test_data_source(self, **kwargs): + """Helper to create a test data source""" + vals = { + "name": "Test CRVS", + "code": "test_crvs", + "base_url": "https://crvs.example.org/api", + "auth_type": "none", + "our_sender_id": "openspp.example.org", + "our_callback_uri": "https://openspp.example.org/callback", + "registry_type": "ns:org:RegistryType:Civil", + } + vals.update(kwargs) + return self.DataSource.create(vals) + + def test_log_skips_if_model_missing(self): + """_log_outgoing_call does nothing if spp.api.outgoing.log model is not installed""" + from ..services.client import DCIClient + + ds = self._create_test_data_source() + client = DCIClient(ds, self.env) + + # Mock env to not contain the outgoing log model + with patch.object(client, "env") as mock_env: + mock_env.__contains__ = lambda self, key: False + + # Should not raise + client._log_outgoing_call( + url="https://example.org/test", + endpoint="/test", + envelope={"header": {"action": "search"}, "message": {}}, + response_data=None, + status_code=None, + duration_ms=100, + status="success", + error_detail=None, + ) + + def test_copy_envelope_for_log_preserves_signature(self): + """_copy_envelope_for_log preserves signature for auditability""" + from ..services.client import DCIClient + + ds = self._create_test_data_source() + client = DCIClient(ds, self.env) + + envelope = { + "signature": "cryptographic_signature_12345", + "header": {"action": "search"}, + "message": {"test": "data"}, + } + + copied = client._copy_envelope_for_log(envelope) + + # Signature is preserved for audit trail / non-repudiation + self.assertEqual(copied["signature"], "cryptographic_signature_12345") + self.assertEqual(copied["header"], {"action": "search"}) + self.assertEqual(copied["message"], {"test": "data"}) + + def test_copy_envelope_for_log_none(self): + """_copy_envelope_for_log returns None for None/empty input""" + from ..services.client import DCIClient + + ds = self._create_test_data_source() + client = DCIClient(ds, self.env) + + self.assertIsNone(client._copy_envelope_for_log(None)) + self.assertIsNone(client._copy_envelope_for_log({})) + + def test_copy_envelope_for_log_returns_copy(self): + """_copy_envelope_for_log returns a copy, not the original""" + from ..services.client import DCIClient + + ds = self._create_test_data_source() + client = DCIClient(ds, self.env) + + envelope = {"header": {"action": "search"}, "message": {}} + copied = client._copy_envelope_for_log(envelope) + + self.assertIsNot(copied, envelope) + self.assertEqual(copied, envelope) + + +@unittest.skipUnless( + True, # Actual check is in setUpClass since we need the Odoo env + "spp_api_v2 required", +) +class TestOutgoingLogIntegration(TransactionCase): + """Test DCI client integration with outgoing API log. + + These tests require spp_api_v2 to be installed (provides spp.api.outgoing.log). + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + if "spp.api.outgoing.log" not in cls.env: + raise unittest.SkipTest("spp_api_v2 not installed (spp.api.outgoing.log model not available)") + cls.DataSource = cls.env["spp.dci.data.source"] + cls.OutgoingLog = cls.env["spp.api.outgoing.log"] + + def _create_test_data_source(self, **kwargs): + """Helper to create a test data source""" + vals = { + "name": "Test CRVS", + "code": "test_crvs", + "base_url": "https://crvs.example.org/api", + "auth_type": "none", + "our_sender_id": "openspp.example.org", + "our_callback_uri": "https://openspp.example.org/callback", + "registry_type": "ns:org:RegistryType:Civil", + } + vals.update(kwargs) + return self.DataSource.create(vals) + + def _make_mock_response(self, status_code=200, json_data=None): + """Helper to create a mock HTTP response""" + mock_response = MagicMock() + mock_response.status_code = status_code + mock_response.json.return_value = json_data or {"header": {"status": "success"}} + mock_response.text = str(json_data or {"header": {"status": "success"}}) + mock_response.raise_for_status = MagicMock() + return mock_response + + def _build_test_envelope(self, client): + """Helper to build a test envelope""" + return client._build_envelope(action="search", message={"test": "data"}) + + def _find_latest_log(self, url_filter="crvs.example.org"): + """Helper to find the most recent outgoing log entry""" + return self.OutgoingLog.search( + [("url", "like", url_filter)], + order="id desc", + limit=1, + ) + + @patch("httpx.Client") + def test_make_request_logs_success(self, mock_client_class): + """Successful request creates outgoing log with status=success""" + from ..services.client import DCIClient + + ds = self._create_test_data_source() + client = DCIClient(ds, self.env) + + mock_response = self._make_mock_response() + mock_http_client = MagicMock() + mock_http_client.post.return_value = mock_response + mock_http_client.__enter__.return_value = mock_http_client + mock_http_client.__exit__.return_value = None + mock_client_class.return_value = mock_http_client + + envelope = self._build_test_envelope(client) + client._make_request("/registry/sync/search", envelope) + + log = self._find_latest_log() + self.assertTrue(log, "Outgoing log should be created on success") + self.assertEqual(log.status, "success") + self.assertEqual(log.response_status_code, 200) + self.assertEqual(log.endpoint, "/registry/sync/search") + self.assertEqual(log.service_name, "DCI Client") + self.assertEqual(log.service_code, "test_crvs") + self.assertGreater(log.duration_ms, 0) + + @patch("httpx.Client") + def test_make_request_logs_http_error(self, mock_client_class): + """HTTP error creates outgoing log with status=http_error""" + import httpx + + from ..services.client import DCIClient + + ds = self._create_test_data_source() + client = DCIClient(ds, self.env) + + mock_response = MagicMock() + mock_response.status_code = 500 + mock_response.text = "Internal Server Error" + mock_response.json.return_value = {"error": "server_error"} + mock_request = MagicMock() + + def raise_status(): + raise httpx.HTTPStatusError("Server Error", request=mock_request, response=mock_response) + + mock_response.raise_for_status = raise_status + + mock_http_client = MagicMock() + mock_http_client.post.return_value = mock_response + mock_http_client.__enter__.return_value = mock_http_client + mock_http_client.__exit__.return_value = None + mock_client_class.return_value = mock_http_client + + envelope = self._build_test_envelope(client) + + from odoo.exceptions import UserError + + with self.assertRaises(UserError): + client._make_request("/registry/sync/search", envelope) + + log = self._find_latest_log() + self.assertTrue(log, "Outgoing log should be created on HTTP error") + self.assertEqual(log.status, "http_error") + self.assertEqual(log.response_status_code, 500) + self.assertTrue(log.error_detail) + + @patch("httpx.Client") + def test_make_request_logs_connection_error(self, mock_client_class): + """Connection error creates outgoing log with status=connection_error""" + import httpx + + from ..services.client import DCIClient + + ds = self._create_test_data_source() + client = DCIClient(ds, self.env) + + mock_http_client = MagicMock() + mock_http_client.post.side_effect = httpx.ConnectError("Connection refused") + mock_http_client.__enter__.return_value = mock_http_client + mock_http_client.__exit__.return_value = None + mock_client_class.return_value = mock_http_client + + envelope = self._build_test_envelope(client) + + from odoo.exceptions import UserError + + with self.assertRaises(UserError): + client._make_request("/registry/sync/search", envelope) + + log = self._find_latest_log() + self.assertTrue(log, "Outgoing log should be created on connection error") + self.assertEqual(log.status, "connection_error") + self.assertTrue(log.error_detail) + + @patch("httpx.Client") + def test_make_request_logs_timeout(self, mock_client_class): + """Timeout creates outgoing log with status=timeout""" + import httpx + + from ..services.client import DCIClient + + ds = self._create_test_data_source() + client = DCIClient(ds, self.env) + + mock_http_client = MagicMock() + mock_http_client.post.side_effect = httpx.ReadTimeout("Read timed out") + mock_http_client.__enter__.return_value = mock_http_client + mock_http_client.__exit__.return_value = None + mock_client_class.return_value = mock_http_client + + envelope = self._build_test_envelope(client) + + from odoo.exceptions import UserError + + with self.assertRaises(UserError): + client._make_request("/registry/sync/search", envelope) + + log = self._find_latest_log() + self.assertTrue(log, "Outgoing log should be created on timeout") + self.assertEqual(log.status, "timeout") + self.assertTrue(log.error_detail) + + @patch("httpx.Client") + def test_make_request_logs_ssl_error(self, mock_client_class): + """SSL error creates outgoing log with status=connection_error""" + import httpx + + from ..services.client import DCIClient + + ds = self._create_test_data_source() + client = DCIClient(ds, self.env) + + mock_http_client = MagicMock() + mock_http_client.post.side_effect = httpx.ConnectError( + "[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed" + ) + mock_http_client.__enter__.return_value = mock_http_client + mock_http_client.__exit__.return_value = None + mock_client_class.return_value = mock_http_client + + envelope = self._build_test_envelope(client) + + from odoo.exceptions import UserError + + with self.assertRaises(UserError): + client._make_request("/registry/sync/search", envelope) + + log = self._find_latest_log() + self.assertTrue(log, "Outgoing log should be created on SSL error") + self.assertEqual(log.status, "connection_error") + self.assertIn("SSL", log.error_detail.upper()) + + @patch("httpx.Client") + def test_make_request_logs_dns_error(self, mock_client_class): + """DNS resolution error creates outgoing log with status=connection_error""" + import httpx + + from ..services.client import DCIClient + + ds = self._create_test_data_source() + client = DCIClient(ds, self.env) + + mock_http_client = MagicMock() + mock_http_client.post.side_effect = httpx.ConnectError("Name or service not known") + mock_http_client.__enter__.return_value = mock_http_client + mock_http_client.__exit__.return_value = None + mock_client_class.return_value = mock_http_client + + envelope = self._build_test_envelope(client) + + from odoo.exceptions import UserError + + with self.assertRaises(UserError): + client._make_request("/registry/sync/search", envelope) + + log = self._find_latest_log() + self.assertTrue(log, "Outgoing log should be created on DNS error") + self.assertEqual(log.status, "connection_error") + self.assertTrue(log.error_detail) + + @patch("httpx.Client") + def test_make_request_logs_generic_exception(self, mock_client_class): + """Generic exception creates outgoing log with status=error""" + from ..services.client import DCIClient + + ds = self._create_test_data_source() + client = DCIClient(ds, self.env) + + mock_http_client = MagicMock() + mock_http_client.post.side_effect = RuntimeError("Something unexpected") + mock_http_client.__enter__.return_value = mock_http_client + mock_http_client.__exit__.return_value = None + mock_client_class.return_value = mock_http_client + + envelope = self._build_test_envelope(client) + + from odoo.exceptions import UserError + + with self.assertRaises(UserError): + client._make_request("/registry/sync/search", envelope) + + log = self._find_latest_log() + self.assertTrue(log, "Outgoing log should be created on generic exception") + self.assertEqual(log.status, "error") + self.assertTrue(log.error_detail) + + @patch("httpx.Client") + def test_401_retry_creates_two_log_entries(self, mock_client_class): + """401 retry path creates log entry for both the 401 and the retry result""" + from ..services.client import DCIClient + + ds = self._create_test_data_source(auth_type="oauth2") + client = DCIClient(ds, self.env) + + # First call returns 401, second returns 200 + mock_401_response = MagicMock() + mock_401_response.status_code = 401 + mock_401_response.text = "Unauthorized" + mock_401_response.json.return_value = {"error": "invalid_token"} + mock_401_response.raise_for_status = MagicMock() + + mock_200_response = self._make_mock_response() + + mock_http_client = MagicMock() + mock_http_client.post.side_effect = [mock_401_response, mock_200_response] + mock_http_client.__enter__.return_value = mock_http_client + mock_http_client.__exit__.return_value = None + mock_client_class.return_value = mock_http_client + + envelope = self._build_test_envelope(client) + + with patch.object(ds, "clear_oauth2_token_cache"): + client._make_request("/registry/sync/search", envelope) + + # Should have two log entries: one for 401, one for retry success + logs = self.OutgoingLog.search( + [("url", "like", "crvs.example.org")], + order="id desc", + limit=2, + ) + self.assertEqual(len(logs), 2, "Should have two log entries (401 + retry)") + + # Most recent should be the retry success + self.assertEqual(logs[0].status, "success") + self.assertEqual(logs[0].response_status_code, 200) + + # Earlier should be the 401 error + self.assertEqual(logs[1].status, "http_error") + self.assertEqual(logs[1].response_status_code, 401) + self.assertIn("retrying", logs[1].error_detail.lower()) + # M-1 fix: 401 response body should be captured + self.assertTrue(logs[1].response_summary) + + @patch("httpx.Client") + def test_log_failure_does_not_block_request(self, mock_client_class): + """Logging failure does not prevent the actual request from succeeding""" + from ..services.client import DCIClient + + ds = self._create_test_data_source() + client = DCIClient(ds, self.env) + + # Mock HTTP response (success) + mock_response = self._make_mock_response() + mock_http_client = MagicMock() + mock_http_client.post.return_value = mock_response + mock_http_client.__enter__.return_value = mock_http_client + mock_http_client.__exit__.return_value = None + mock_client_class.return_value = mock_http_client + + # Monkey-patch the model to raise + original_log_call = self.OutgoingLog.__class__.log_call + + def broken_log_call(self_model, **kwargs): + raise RuntimeError("Database error") + + self.OutgoingLog.__class__.log_call = broken_log_call + try: + # This should not raise despite broken logging + client._log_outgoing_call( + url="https://example.org/test", + endpoint="/test", + envelope={"header": {"action": "search"}, "message": {}}, + response_data=None, + status_code=200, + duration_ms=100, + status="success", + error_detail=None, + ) + finally: + self.OutgoingLog.__class__.log_call = original_log_call From b14a93190f1118b85ca7f4acc0a29260a09821f8 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 18 Feb 2026 16:30:10 +0700 Subject: [PATCH 08/20] fix(spp_approval,spp_dci_client): prevent RPC exposure of system approval and narrow exception handling Rename action_approve_system to _action_approve_system to prevent Odoo from exposing it via the RPC interface, since it uses sudo() and skips _check_can_approve() authorization checks. Replace broad Exception catches in spp_dci_client with specific exception types (json.JSONDecodeError, KeyError, TypeError) and add json import. --- spp_approval/models/approval_mixin.py | 5 ++++- spp_dci_client/services/client.py | 5 +++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/spp_approval/models/approval_mixin.py b/spp_approval/models/approval_mixin.py index a2f469bb..6e4c398c 100644 --- a/spp_approval/models/approval_mixin.py +++ b/spp_approval/models/approval_mixin.py @@ -332,7 +332,7 @@ def action_approve(self, comment=None): record._check_can_approve() record._do_approve(comment=comment) - def action_approve_system(self, comment=None): + def _action_approve_system(self, comment=None): """System-initiated approval bypassing user permission checks. Use this for automated approvals triggered by system events (e.g., DCI @@ -341,6 +341,9 @@ def action_approve_system(self, comment=None): This method uses sudo() to bypass access controls and skips the _check_can_approve() permission validation. + The underscore prefix is intentional — it prevents this method from being + callable via Odoo's RPC interface, since it must not be exposed to users. + Args: comment: Optional approval comment for audit trail """ diff --git a/spp_dci_client/services/client.py b/spp_dci_client/services/client.py index 5ca3a5b2..cefbe1c2 100644 --- a/spp_dci_client/services/client.py +++ b/spp_dci_client/services/client.py @@ -1,5 +1,6 @@ """DCI Client Service for making signed API requests.""" +import json import logging import time import uuid @@ -943,7 +944,7 @@ def _make_request(self, endpoint: str, envelope: dict, _retry_auth: bool = True) log_error_detail = "401 Unauthorized - retrying with fresh token" try: log_response_data = response.json() - except Exception: + except json.JSONDecodeError: log_response_data = None self.data_source.clear_oauth2_token_cache() return self._make_request(endpoint, envelope, _retry_auth=False) @@ -979,7 +980,7 @@ def _make_request(self, endpoint: str, envelope: dict, _retry_auth: bool = True) technical_detail += f": {error_data['header']['status_reason_message']}" else: technical_detail += f": {response_text}" - except Exception: + except (json.JSONDecodeError, KeyError, TypeError): technical_detail += f": {response_text}" log_error_detail = technical_detail From 3ea418e91969f4b06cf9832ddb82e0b6bee55753 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 18 Feb 2026 17:24:02 +0700 Subject: [PATCH 09/20] fix(spp_approval,spp_dci_client): add auto=True to system approval and fix test log ordering Pass auto=True to _do_approve in _action_approve_system so automated approvals show "(auto)" in activity feedback and skip submitter notification, matching the intent of system-initiated approvals. Fix reversed log assertions in test_401_retry_creates_two_log_entries: due to try-finally semantics with recursive calls, the inner retry's finally creates the success log first (lower ID), so with order="id desc" logs[0] is the 401 entry and logs[1] is the success entry. --- spp_approval/models/approval_mixin.py | 2 +- spp_dci_client/models/data_source.py | 2 +- .../tests/test_outgoing_log_integration.py | 21 +++++++++++-------- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/spp_approval/models/approval_mixin.py b/spp_approval/models/approval_mixin.py index 6e4c398c..cfc9c65b 100644 --- a/spp_approval/models/approval_mixin.py +++ b/spp_approval/models/approval_mixin.py @@ -356,7 +356,7 @@ def _action_approve_system(self, comment=None): record.approval_state, ) continue - record.sudo()._do_approve(comment=comment) + record.sudo()._do_approve(comment=comment, auto=True) # nosemgrep: odoo-sudo-without-context _logger.info( "System auto-approved %s %s: %s", record._name, diff --git a/spp_dci_client/models/data_source.py b/spp_dci_client/models/data_source.py index 08a84def..a1cb4d90 100644 --- a/spp_dci_client/models/data_source.py +++ b/spp_dci_client/models/data_source.py @@ -327,7 +327,7 @@ def get_oauth2_token(self, force_refresh=False): try: # Use sudo() to access OAuth2 credentials which are restricted to administrators - sudo_self = self.sudo() + sudo_self = self.sudo() # nosemgrep: odoo-sudo-without-context token_data = { "grant_type": "client_credentials", "client_id": sudo_self.oauth2_client_id, diff --git a/spp_dci_client/tests/test_outgoing_log_integration.py b/spp_dci_client/tests/test_outgoing_log_integration.py index 1257ee47..7cefec24 100644 --- a/spp_dci_client/tests/test_outgoing_log_integration.py +++ b/spp_dci_client/tests/test_outgoing_log_integration.py @@ -392,16 +392,19 @@ def test_401_retry_creates_two_log_entries(self, mock_client_class): ) self.assertEqual(len(logs), 2, "Should have two log entries (401 + retry)") - # Most recent should be the retry success - self.assertEqual(logs[0].status, "success") - self.assertEqual(logs[0].response_status_code, 200) - - # Earlier should be the 401 error - self.assertEqual(logs[1].status, "http_error") - self.assertEqual(logs[1].response_status_code, 401) - self.assertIn("retrying", logs[1].error_detail.lower()) + # With order="id desc", logs[0] has the highest ID (created last). + # Due to try-finally semantics, the recursive retry's finally runs first + # (lower ID = success), then the outer call's finally runs (higher ID = 401). + # So logs[0] (highest ID) is the 401 entry, logs[1] (lower ID) is the success. + self.assertEqual(logs[0].status, "http_error") + self.assertEqual(logs[0].response_status_code, 401) + self.assertIn("retrying", logs[0].error_detail.lower()) # M-1 fix: 401 response body should be captured - self.assertTrue(logs[1].response_summary) + self.assertTrue(logs[0].response_summary) + + # Earlier log (lower ID) is the retry success + self.assertEqual(logs[1].status, "success") + self.assertEqual(logs[1].response_status_code, 200) @patch("httpx.Client") def test_log_failure_does_not_block_request(self, mock_client_class): From c8937ac61f839e1dcad04d57870453e8e780d8a3 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 6 Feb 2026 19:35:28 +0700 Subject: [PATCH 10/20] feat(dci-demo): add DCI birth verification demo module Features: - Birth verification via OpenCRVS DCI integration - Auto-approval when DCI data matches CR detail fields - Auto-enrollment of household in configured program on CR apply - Add Child wizard for streamlined UX - Verified BRN registry ID created on apply Technical: - Add search_by_id_opencrvs method for OpenCRVS-specific format - Extract DCI verification logic to utils module - Add system parameters for configuration - Add post_init_hook for auto-configuration Note: DCI data source credentials must be configured manually via Settings > Technical > System Parameters or UI. --- spp_dci/schemas/constants.py | 16 +- spp_dci_client/services/client.py | 189 ++++-- spp_dci_client/tests/test_client_service.py | 203 +++++- spp_dci_demo/__init__.py | 6 + spp_dci_demo/__manifest__.py | 48 ++ spp_dci_demo/data/system_parameters.xml | 23 + spp_dci_demo/data/vocabulary_data.xml | 15 + spp_dci_demo/hooks.py | 36 ++ spp_dci_demo/models/__init__.py | 5 + spp_dci_demo/models/change_request.py | 138 +++++ spp_dci_demo/models/cr_apply_add_member.py | 219 +++++++ spp_dci_demo/models/cr_detail_add_member.py | 329 ++++++++++ spp_dci_demo/security/ir.model.access.csv | 2 + spp_dci_demo/tests/__init__.py | 6 + spp_dci_demo/tests/test_add_child_wizard.py | 512 +++++++++++++++ spp_dci_demo/tests/test_apply_creates_brn.py | 370 +++++++++++ spp_dci_demo/tests/test_birth_verification.py | 266 ++++++++ .../tests/test_dci_verification_utils.py | 414 +++++++++++++ spp_dci_demo/utils/__init__.py | 3 + spp_dci_demo/utils/dci_verification.py | 166 +++++ spp_dci_demo/views/add_child_wizard_view.xml | 209 +++++++ spp_dci_demo/views/change_request_view.xml | 35 ++ .../views/cr_detail_add_member_view.xml | 67 ++ spp_dci_demo/wizards/__init__.py | 3 + spp_dci_demo/wizards/add_child_wizard.py | 586 ++++++++++++++++++ spp_registry/models/reg_id.py | 41 ++ spp_registry/views/reg_id_view.xml | 46 +- 27 files changed, 3872 insertions(+), 81 deletions(-) create mode 100644 spp_dci_demo/__init__.py create mode 100644 spp_dci_demo/__manifest__.py create mode 100644 spp_dci_demo/data/system_parameters.xml create mode 100644 spp_dci_demo/data/vocabulary_data.xml create mode 100644 spp_dci_demo/hooks.py create mode 100644 spp_dci_demo/models/__init__.py create mode 100644 spp_dci_demo/models/change_request.py create mode 100644 spp_dci_demo/models/cr_apply_add_member.py create mode 100644 spp_dci_demo/models/cr_detail_add_member.py create mode 100644 spp_dci_demo/security/ir.model.access.csv create mode 100644 spp_dci_demo/tests/__init__.py create mode 100644 spp_dci_demo/tests/test_add_child_wizard.py create mode 100644 spp_dci_demo/tests/test_apply_creates_brn.py create mode 100644 spp_dci_demo/tests/test_birth_verification.py create mode 100644 spp_dci_demo/tests/test_dci_verification_utils.py create mode 100644 spp_dci_demo/utils/__init__.py create mode 100644 spp_dci_demo/utils/dci_verification.py create mode 100644 spp_dci_demo/views/add_child_wizard_view.xml create mode 100644 spp_dci_demo/views/change_request_view.xml create mode 100644 spp_dci_demo/views/cr_detail_add_member_view.xml create mode 100644 spp_dci_demo/wizards/__init__.py create mode 100644 spp_dci_demo/wizards/add_child_wizard.py diff --git a/spp_dci/schemas/constants.py b/spp_dci/schemas/constants.py index 912971ba..3d4a49c8 100644 --- a/spp_dci/schemas/constants.py +++ b/spp_dci/schemas/constants.py @@ -4,13 +4,17 @@ class RegistryType(StrEnum): - """DCI Registry types.""" + """DCI Registry types (SPDCI spec compliant). - SOCIAL_REGISTRY = "SOCIAL_REGISTRY" - IBR = "IBR" - CRVS = "CRVS" - DISABILITY_REGISTRY = "DR" - FUNCTIONAL_REGISTRY = "FR" + Values use the namespaced format as specified in SPDCI API Standards. + Reference: src/registry/*/RegistryType.yaml + """ + + SOCIAL_REGISTRY = "ns:org:RegistryType:Social" + CRVS = "ns:org:RegistryType:Civil" + IBR = "ns:org:RegistryType:IBR" + DISABILITY_REGISTRY = "ns:org:RegistryType:DR" + FUNCTIONAL_REGISTRY = "ns:org:RegistryType:FR" class RegistryEventType(StrEnum): diff --git a/spp_dci_client/services/client.py b/spp_dci_client/services/client.py index cefbe1c2..526905d1 100644 --- a/spp_dci_client/services/client.py +++ b/spp_dci_client/services/client.py @@ -169,6 +169,7 @@ def search_by_id( page: int = 1, page_size: int = 10, async_mode: bool = False, + registry_event_type: str | None = None, ) -> dict: """Search by identifier type and value (convenience method). @@ -179,6 +180,7 @@ def search_by_id( page: Page number (1-indexed) page_size: Records per page async_mode: If True, use async endpoint + registry_event_type: Event type filter (BIRTH, DEATH, etc.) Returns: SearchResponse or ACK dict @@ -191,8 +193,56 @@ def search_by_id( record_type=record_type, page=page, page_size=page_size, + registry_event_type=registry_event_type, ) + def search_by_id_opencrvs( + self, + identifier_type: str, + identifier_value: str, + event_type: str = "birth", + page: int = 1, + page_size: int = 10, + async_mode: bool = False, + ) -> dict: + """Search by identifier using OpenCRVS's non-standard format. + + OpenCRVS doesn't support the standard DCI idtype-value query format. + Instead, it requires an expression query with the identifier nested. + + Args: + identifier_type: Identifier type (UIN, BRN, MRN, DRN, etc.) + identifier_value: Identifier value + event_type: Registry event type (birth, death, etc.) - lowercase + page: Page number (1-indexed) + page_size: Records per page + async_mode: If True, use async endpoint + + Returns: + SearchResponse or ACK dict + """ + # Build OpenCRVS-style query for ID lookup + # Format: { type: "BRN"|"UIN"|"DRN", value: "" } + query = { + "type": identifier_type, + "value": identifier_value, + } + + # Build envelope in OpenCRVS format + envelope = self._build_search_envelope_opencrvs( + query=query, + query_type="idtype-value", + event_type=event_type, + page=page, + page_size=page_size, + ) + + if async_mode: + return self._make_request(ENDPOINT_ASYNC_SEARCH, envelope) + else: + endpoint = self.data_source.search_endpoint or ENDPOINT_SYNC_SEARCH + return self._make_request(endpoint, envelope) + def search_by_predicate( self, predicate: str, @@ -231,49 +281,57 @@ def search_by_expression( registry_type: str | None = None, registry_event_type: str | None = None, async_mode: bool = False, + use_opencrvs_format: bool = False, ) -> dict: """Search using expression query (e.g., date range filters). - This supports DCI-compliant expression queries using ExpPredicate format. + This supports both DCI-compliant expression queries and OpenCRVS's + non-standard format. Args: - expression: DCI ExpPredicateWithCondition list, e.g.: - [ - { - "seq_num": 1, - "expression1": { - "attribute_name": "dateOfEvent", - "operator": "ge", - "attribute_value": "2020-01-01" - }, - "condition": "and", - "expression2": { - "attribute_name": "dateOfEvent", - "operator": "le", - "attribute_value": "2026-02-10" - } - } - ] + expression: Query expression. For standard DCI, a list of + ExpPredicateWithCondition dicts. For OpenCRVS format, a dict + of attribute filters (e.g., {"birthDate": {"type": "range", ...}}). record_type: PERSON, GROUP, etc. page: Page number (1-indexed) page_size: Records per page registry_type: Registry type (defaults to data source registry type) registry_event_type: Optional event type filter (e.g., "BIRTH", "DEATH") async_mode: If True, use async endpoint + use_opencrvs_format: If True, wrap expression in OpenCRVS query + structure and use OpenCRVS envelope format. Returns: SearchResponse or ACK dict """ - # Build envelope directly with expression query (bypass _parse_query) - envelope = self._build_search_envelope( - query_type=QueryType.EXPRESSION, - query=expression, - registry_type=registry_type or self._get_registry_type(), - registry_event_type=registry_event_type, - record_type=record_type, - page=page, - page_size=page_size, - ) + if use_opencrvs_format: + # Wrap the caller's expression in OpenCRVS query structure + opencrvs_query = { + "type": "ns:org:QueryType:expression", + "value": { + "expression": { + "query": expression, + } + }, + } + envelope = self._build_search_envelope_opencrvs( + query=opencrvs_query, + query_type="expression", + event_type=registry_event_type, + page=page, + page_size=page_size, + ) + else: + # Standard DCI path (unchanged) + envelope = self._build_search_envelope( + query_type=QueryType.EXPRESSION, + query=expression, + registry_type=registry_type or self._get_registry_type(), + registry_event_type=registry_event_type, + record_type=record_type, + page=page, + page_size=page_size, + ) if async_mode: return self._make_request(ENDPOINT_ASYNC_SEARCH, envelope) @@ -379,14 +437,10 @@ def _search_by_date_range_opencrvs( ) -> dict: """Search by date range using OpenCRVS's non-standard envelope format. - OpenCRVS uses a custom format that differs from DCI spec: - - reg_type: lowercase event type (e.g., "birth") instead of registry type - - No reg_event_type field - - Requires consent object and locale field - - Expression query format: { type: "ns:org:QueryType:expression", value: { : { type: "range", ... } } } + Builds an expression query with the correct OpenCRVS nesting: + { type, value: { expression: { query: { : { type: "range", ... } } } } } - This method exists for interoperability with OpenCRVS servers that don't - follow the standard DCI envelope format. + Then delegates to _build_search_envelope_opencrvs for the envelope wrapper. Args: start_date: Start date in ISO format (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ) @@ -401,11 +455,26 @@ def _search_by_date_range_opencrvs( Returns: SearchResponse or ACK dict """ - # Build envelope directly in OpenCRVS format (bypassing standard _build_search_envelope) + # Build OpenCRVS-style expression query with correct nesting + expression_query = { + "type": "ns:org:QueryType:expression", + "value": { + "expression": { + "query": { + attribute_name: { + "type": "range", + "gte": start_date, + "lte": end_date, + } + } + } + }, + } + + # Build envelope in OpenCRVS format (bypassing standard _build_search_envelope) envelope = self._build_search_envelope_opencrvs( - start_date=start_date, - end_date=end_date, - attribute_name=attribute_name, + query=expression_query, + query_type="expression", event_type=event_type, page=page, page_size=page_size, @@ -419,9 +488,8 @@ def _search_by_date_range_opencrvs( def _build_search_envelope_opencrvs( self, - start_date: str, - end_date: str, - attribute_name: str, + query: dict, + query_type: str, event_type: str | None, page: int, page_size: int, @@ -429,49 +497,34 @@ def _build_search_envelope_opencrvs( """Build search envelope in OpenCRVS's non-standard format. OpenCRVS expects: - - reg_type: lowercase event type (e.g., "birth"), NOT registry type - - No reg_event_type field + - reg_type: "ns:org:RegistryType:Civil" + - reg_event_type: lowercase event type (e.g., "birth") - Required consent object - Required locale field - - Expression query with range syntax + - Pre-built query object (e.g., expression with nested query structure) Args: - start_date: Start date - end_date: End date - attribute_name: Attribute to filter on + query: Pre-built query object (e.g., expression query dict) + query_type: Query type string (e.g., "expression", "idtype-value") event_type: Event type (BIRTH, DEATH, etc.) page: Page number page_size: Page size Returns: - Envelope dict in OpenCRVS format + Envelope dict in OpenCRVS format (no signature wrapper) """ now = datetime.now(UTC) transaction_id = str(uuid.uuid4()) reference_id = str(uuid.uuid4()) message_id = str(uuid.uuid4()) - # OpenCRVS uses lowercase event type for reg_type (e.g., "birth" not "BIRTH" or "CRVS") - reg_type = (event_type or "birth").lower() - - # Build OpenCRVS-style expression query - expression_query = { - "type": "ns:org:QueryType:expression", - "value": { - attribute_name: { - "type": "range", - "gte": start_date, - "lte": end_date, - } - }, - } - - # Build search criteria in OpenCRVS format (no reg_event_type) + # Build search criteria in OpenCRVS format search_criteria = { "version": "1.0.0", - "reg_type": reg_type, - "query_type": "expression", - "query": expression_query, + "reg_type": RegistryType.CRVS.value, + "reg_event_type": (event_type or "birth").lower(), + "query_type": query_type, + "query": query, "sort": [ { "attribute_name": "createdAt", diff --git a/spp_dci_client/tests/test_client_service.py b/spp_dci_client/tests/test_client_service.py index cebed1a7..96ccfb0e 100644 --- a/spp_dci_client/tests/test_client_service.py +++ b/spp_dci_client/tests/test_client_service.py @@ -24,7 +24,7 @@ def _create_test_data_source(self, **kwargs): "auth_type": "none", "our_sender_id": "openspp.example.org", "our_callback_uri": "https://openspp.example.org/callback", - "registry_type": "CRVS", + "registry_type": "ns:org:RegistryType:Civil", } vals.update(kwargs) return self.DataSource.create(vals) @@ -543,12 +543,12 @@ def test_get_registry_type_from_data_source(self): """Test registry type is retrieved from data source""" from ..services.client import DCIClient - ds = self._create_test_data_source(registry_type="CRVS") + ds = self._create_test_data_source(registry_type="ns:org:RegistryType:Civil") client = DCIClient(ds, self.env) registry_type = client._get_registry_type() - self.assertEqual(registry_type, "CRVS") + self.assertEqual(registry_type, "ns:org:RegistryType:Civil") def test_get_registry_type_default(self): """Test default registry type when not configured""" @@ -560,7 +560,7 @@ def test_get_registry_type_default(self): registry_type = client._get_registry_type() - self.assertEqual(registry_type, "SOCIAL_REGISTRY") + self.assertEqual(registry_type, "ns:org:RegistryType:Social") def test_search_uses_custom_endpoint(self): """Test search uses custom endpoint if configured""" @@ -591,7 +591,7 @@ def test_build_search_envelope_structure(self): envelope = client._build_search_envelope( query_type=QueryType.IDTYPE_VALUE, query={"type": "UIN", "value": "12345678"}, - registry_type="CRVS", + registry_type="ns:org:RegistryType:Civil", registry_event_type="BIRTH", record_type="PERSON", page=1, @@ -617,7 +617,7 @@ def test_build_search_envelope_structure(self): # Verify search criteria search_criteria = search_request["search_criteria"] self.assertEqual(search_criteria["version"], "1.0.0") - self.assertEqual(search_criteria["reg_type"], "CRVS") + self.assertEqual(search_criteria["reg_type"], "ns:org:RegistryType:Civil") self.assertEqual(search_criteria["reg_event_type"], "BIRTH") self.assertEqual(search_criteria["query_type"], QueryType.IDTYPE_VALUE) self.assertEqual(search_criteria["query"]["type"], "UIN") @@ -858,3 +858,194 @@ def test_envelope_meta_field(self): # meta is required by DCI spec (even if empty) self.assertIn("meta", envelope["header"]) self.assertEqual(envelope["header"]["meta"], {}) + + +class TestOpenCRVSEnvelopeFormat(TransactionCase): + """Test OpenCRVS envelope format for _build_search_envelope_opencrvs.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.DataSource = cls.env["spp.dci.data.source"] + + def _create_test_data_source(self, **kwargs): + """Helper to create a test data source""" + vals = { + "name": "Test CRVS", + "code": "test_crvs", + "base_url": "https://crvs.example.org/api", + "auth_type": "none", + "our_sender_id": "openspp.example.org", + "our_callback_uri": "https://openspp.example.org/callback", + "registry_type": "ns:org:RegistryType:Civil", + } + vals.update(kwargs) + return self.DataSource.create(vals) + + def _build_opencrvs_envelope(self, client, event_type=None): + """Helper to build an OpenCRVS envelope via search_by_date_range.""" + with patch.object(client, "_make_request") as mock_request: + mock_request.return_value = {"status": "success"} + client.search_by_date_range( + start_date="2020-01-01", + end_date="2026-02-10", + attribute_name="dateOfEvent", + event_type=event_type, + use_opencrvs_format=True, + ) + return mock_request.call_args[0][1] + + def test_opencrvs_envelope_reg_type(self): + """reg_type must be 'ns:org:RegistryType:Civil' (not lowercase event type).""" + from ..services.client import DCIClient + + ds = self._create_test_data_source() + client = DCIClient(ds, self.env) + + envelope = self._build_opencrvs_envelope(client) + search_criteria = envelope["message"]["search_request"][0]["search_criteria"] + + self.assertEqual(search_criteria["reg_type"], "ns:org:RegistryType:Civil") + + def test_opencrvs_envelope_reg_event_type(self): + """reg_event_type must be present and default to 'birth' (lowercase).""" + from ..services.client import DCIClient + + ds = self._create_test_data_source() + client = DCIClient(ds, self.env) + + envelope = self._build_opencrvs_envelope(client) + search_criteria = envelope["message"]["search_request"][0]["search_criteria"] + + self.assertIn("reg_event_type", search_criteria) + self.assertEqual(search_criteria["reg_event_type"], "birth") + + def test_opencrvs_envelope_reg_event_type_death(self): + """Passing event_type='DEATH' produces reg_event_type: 'death'.""" + from ..services.client import DCIClient + + ds = self._create_test_data_source() + client = DCIClient(ds, self.env) + + envelope = self._build_opencrvs_envelope(client, event_type="DEATH") + search_criteria = envelope["message"]["search_request"][0]["search_criteria"] + + self.assertEqual(search_criteria["reg_event_type"], "death") + + def test_opencrvs_envelope_expression_query_structure(self): + """Expression query must be nested: type/value/expression/query.""" + from ..services.client import DCIClient + + ds = self._create_test_data_source() + client = DCIClient(ds, self.env) + + envelope = self._build_opencrvs_envelope(client) + search_criteria = envelope["message"]["search_request"][0]["search_criteria"] + query = search_criteria["query"] + + # Top-level structure + self.assertEqual(query["type"], "ns:org:QueryType:expression") + self.assertIn("value", query) + + # value -> expression -> query -> { attribute_name: { type, gte, lte } } + value = query["value"] + self.assertIn("expression", value) + self.assertIn("query", value["expression"]) + + attribute_query = value["expression"]["query"] + self.assertIn("dateOfEvent", attribute_query) + self.assertEqual(attribute_query["dateOfEvent"]["type"], "range") + self.assertEqual(attribute_query["dateOfEvent"]["gte"], "2020-01-01") + self.assertEqual(attribute_query["dateOfEvent"]["lte"], "2026-02-10") + + def test_opencrvs_envelope_has_consent_and_locale(self): + """OpenCRVS envelope must include consent and locale.""" + from ..services.client import DCIClient + + ds = self._create_test_data_source() + client = DCIClient(ds, self.env) + + envelope = self._build_opencrvs_envelope(client) + search_request_item = envelope["message"]["search_request"][0] + + self.assertIn("consent", search_request_item) + self.assertIn("locale", search_request_item) + self.assertEqual(search_request_item["locale"], "eng") + + def test_opencrvs_envelope_no_signature_key(self): + """OpenCRVS envelope must not have a 'signature' key.""" + from ..services.client import DCIClient + + ds = self._create_test_data_source() + client = DCIClient(ds, self.env) + + envelope = self._build_opencrvs_envelope(client) + + self.assertNotIn("signature", envelope) + + def test_search_by_expression_opencrvs_format(self): + """search_by_expression(use_opencrvs_format=True) produces OpenCRVS envelope.""" + from ..services.client import DCIClient + + ds = self._create_test_data_source() + client = DCIClient(ds, self.env) + + expression = {"birthDate": {"type": "range", "gte": "2020-01-01", "lte": "2026-12-31"}} + + with patch.object(client, "_make_request") as mock_request: + mock_request.return_value = {"status": "success"} + client.search_by_expression( + expression=expression, + use_opencrvs_format=True, + ) + envelope = mock_request.call_args[0][1] + + # Should use OpenCRVS format (no signature, has consent/locale) + self.assertNotIn("signature", envelope) + search_request_item = envelope["message"]["search_request"][0] + self.assertIn("consent", search_request_item) + self.assertIn("locale", search_request_item) + + # Query should be wrapped in expression/query structure + search_criteria = search_request_item["search_criteria"] + self.assertEqual(search_criteria["reg_type"], "ns:org:RegistryType:Civil") + query = search_criteria["query"] + self.assertEqual(query["type"], "ns:org:QueryType:expression") + self.assertIn("expression", query["value"]) + self.assertIn("query", query["value"]["expression"]) + self.assertEqual(query["value"]["expression"]["query"], expression) + + def test_search_by_expression_standard_unchanged(self): + """search_by_expression() without flag still uses standard DCI path.""" + from ..services.client import DCIClient + + ds = self._create_test_data_source() + client = DCIClient(ds, self.env) + + expression = [ + { + "seq_num": 1, + "expression1": { + "attribute_name": "dateOfEvent", + "operator": "ge", + "attribute_value": "2020-01-01", + }, + "condition": "and", + "expression2": { + "attribute_name": "dateOfEvent", + "operator": "le", + "attribute_value": "2026-02-10", + }, + } + ] + + with patch.object(client, "_make_request") as mock_request: + mock_request.return_value = {"status": "success"} + client.search_by_expression(expression=expression) + envelope = mock_request.call_args[0][1] + + # Standard DCI format has signature + self.assertIn("signature", envelope) + # Standard DCI format uses registry type not OpenCRVS format + search_criteria = envelope["message"]["search_request"][0]["search_criteria"] + self.assertEqual(search_criteria["reg_type"], "ns:org:RegistryType:Civil") diff --git a/spp_dci_demo/__init__.py b/spp_dci_demo/__init__.py new file mode 100644 index 00000000..30036870 --- /dev/null +++ b/spp_dci_demo/__init__.py @@ -0,0 +1,6 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from . import models +from . import utils +from . import wizards +from .hooks import post_init_hook diff --git a/spp_dci_demo/__manifest__.py b/spp_dci_demo/__manifest__.py new file mode 100644 index 00000000..5698fadf --- /dev/null +++ b/spp_dci_demo/__manifest__.py @@ -0,0 +1,48 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +{ + "name": "OpenSPP DCI Demo", + "version": "19.0.1.0.0", + "category": "OpenSPP", + "license": "LGPL-3", + "website": "https://openspp.org", + "author": "OpenSPP.org", + "depends": [ + "spp_mis_demo_v2", + "spp_dci_client", + "spp_change_request_v2", + "spp_programs", + ], + "data": [ + "security/ir.model.access.csv", + "data/vocabulary_data.xml", + "data/dci_data_source.xml", + "data/system_parameters.xml", + "views/cr_detail_add_member_view.xml", + "views/add_child_wizard_view.xml", + "views/change_request_view.xml", + ], + "demo": [], + "post_init_hook": "post_init_hook", + "installable": True, + "application": False, + "auto_install": False, + "summary": "DCI Demo: Birth Verification for Child Benefit Enrollment", + "description": """ +DCI Demo Module +=============== + +This module demonstrates DCI (Data Convergence Initiative) integration +for birth verification in the context of adding a child to a household. + +Demo Story: +----------- +A parent comes to a service point to add their newborn to their household. +They have the Birth Registration Number (BRN) from OpenCRVS. The social worker: + +1. Creates an "Add Child" change request on the household +2. Enters child details (name, DOB, gender) + BRN +3. Clicks "Verify Birth" -> DCI query to OpenCRVS -> birth verified +4. CR approved -> child added to household with verified BRN identity document +5. Registry ID shows verification_method=dci_api, is_verified=True +""", +} diff --git a/spp_dci_demo/data/system_parameters.xml b/spp_dci_demo/data/system_parameters.xml new file mode 100644 index 00000000..8db75f66 --- /dev/null +++ b/spp_dci_demo/data/system_parameters.xml @@ -0,0 +1,23 @@ + + + + + + + spp_dci_demo.auto_approve_on_match + True + + + + diff --git a/spp_dci_demo/data/vocabulary_data.xml b/spp_dci_demo/data/vocabulary_data.xml new file mode 100644 index 00000000..61919586 --- /dev/null +++ b/spp_dci_demo/data/vocabulary_data.xml @@ -0,0 +1,15 @@ + + + + + + brn + Birth Registration Number (BRN) + individual + urn:dci:id:brn + Birth Registration Number from a civil registration system (e.g., OpenCRVS) + 10 + + diff --git a/spp_dci_demo/hooks.py b/spp_dci_demo/hooks.py new file mode 100644 index 00000000..96b4671c --- /dev/null +++ b/spp_dci_demo/hooks.py @@ -0,0 +1,36 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +import logging + +_logger = logging.getLogger(__name__) + + +def post_init_hook(env): + """Post-installation hook to configure enrollment program. + + Finds the first available program and sets it as the default + enrollment program for the DCI demo. + """ + _logger.info("Running spp_dci_demo post_init_hook") + + # Check if spp.program model exists + if "spp.program" not in env: + _logger.info("spp.program model not available, skipping enrollment program setup") + return + + # Find the first program + program = env["spp.program"].search([], limit=1) + if not program: + _logger.info("No programs found, enrollment_program_id not configured") + return + + # Set the system parameter + env["ir.config_parameter"].sudo().set_param( + "spp_dci_demo.enrollment_program_id", + str(program.id), + ) + _logger.info( + "Set spp_dci_demo.enrollment_program_id to %s (%s)", + program.id, + program.name, + ) diff --git a/spp_dci_demo/models/__init__.py b/spp_dci_demo/models/__init__.py new file mode 100644 index 00000000..dd0463d7 --- /dev/null +++ b/spp_dci_demo/models/__init__.py @@ -0,0 +1,5 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from . import cr_detail_add_member +from . import cr_apply_add_member +from . import change_request diff --git a/spp_dci_demo/models/change_request.py b/spp_dci_demo/models/change_request.py new file mode 100644 index 00000000..91605cbf --- /dev/null +++ b/spp_dci_demo/models/change_request.py @@ -0,0 +1,138 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +"""Extend spp.change.request with computed DCI verification fields. + +These fields pull DCI birth verification data from the detail record +and make it visible on the main CR form, so reviewers can see +verification status without navigating into the detail sub-form. +""" + +import logging + +from markupsafe import Markup, escape + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + + +class SPPChangeRequestDCI(models.Model): + """Extend CR with computed DCI verification fields for reviewer UX.""" + + _inherit = "spp.change.request" + + dci_verification_status = fields.Selection( + [ + ("unverified", "Unverified"), + ("verified", "Verified"), + ("not_found", "Not Found"), + ("error", "Error"), + ], + compute="_compute_dci_verification", + string="DCI Verification Status", + ) + + dci_verification_html = fields.Html( + compute="_compute_dci_verification", + string="DCI Verification Summary", + sanitize=False, + ) + + dci_data_match = fields.Boolean( + compute="_compute_dci_verification", + string="DCI Data Matches", + ) + + @api.depends("detail_res_model", "detail_res_id", "approval_state") + def _compute_dci_verification(self): + for rec in self: + rec.dci_verification_status = False + rec.dci_verification_html = "" + rec.dci_data_match = False + + # Only applicable for add_member detail type + if rec.detail_res_model != "spp.cr.detail.add_member": + continue + + detail = rec.get_detail() + if not detail: + continue + + # Check if the detail has DCI fields (from spp_dci_demo) + if not hasattr(detail, "birth_verification_status"): + continue + + status = detail.birth_verification_status + if not status or status == "unverified": + rec.dci_verification_status = status or "unverified" + continue + + rec.dci_verification_status = status + rec.dci_data_match = detail.dci_data_match + + # Build HTML summary + badge_class = { + "verified": "bg-success", + "not_found": "bg-warning", + "error": "bg-danger", + }.get(status, "bg-secondary") + + status_label = { + "verified": "Verified", + "not_found": "Not Found", + "error": "Error", + }.get(status, status) + + parts = [] + + # Status badge + parts.append( + Markup( + '
' + 'Birth Verification:' + '{}' + "
" + ).format(badge_class, escape(status_label)) + ) + + # BRN + if detail.birth_registration_number: + parts.append( + Markup('
BRN: {}
').format( + escape(detail.birth_registration_number) + ) + ) + + # Data match indicator + if status == "verified": + if detail.dci_data_match: + parts.append( + Markup( + '
' + 'Data Match: ' + '' + 'All fields match' + "
" + ) + ) + else: + parts.append( + Markup( + '
' + 'Data Match: ' + '' + '' + "Mismatch detected" + "
" + ) + ) + + # Verification date + if detail.birth_verification_date: + parts.append( + Markup('
Verified: {}
').format( + escape(str(detail.birth_verification_date)) + ) + ) + + rec.dci_verification_html = Markup("").join(parts) diff --git a/spp_dci_demo/models/cr_apply_add_member.py b/spp_dci_demo/models/cr_apply_add_member.py new file mode 100644 index 00000000..c7521002 --- /dev/null +++ b/spp_dci_demo/models/cr_apply_add_member.py @@ -0,0 +1,219 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +import logging + +from odoo import models + +_logger = logging.getLogger(__name__) + + +class SPPCRApplyAddMemberDCI(models.AbstractModel): + """Extend Add Member CR apply to create verified BRN registry ID.""" + + _inherit = "spp.cr.apply.add_member" + + def apply(self, change_request): + """Apply change request and create BRN registry ID if birth was verified. + + Extends the base apply() to: + 1. Create the individual and group membership (via super) + 2. If birth was verified, create a verified BRN registry ID on the individual + 3. Auto-enroll the new individual in the household's programs + """ + # Call parent apply - creates individual and membership + result = super().apply(change_request) + + # Get the detail record + detail = change_request.get_detail() + if not detail: + return result + + # Check if birth was verified and BRN is present + if ( + detail.birth_verification_status == "verified" + and detail.birth_registration_number + and detail.created_individual_id + ): + self._create_verified_brn_registry_id(detail) + + # Auto-enroll in household's programs if enabled + if detail.created_individual_id and change_request.registrant_id: + self._auto_enroll_in_household_programs( + detail.created_individual_id, + change_request.registrant_id, + ) + + return result + + def _create_verified_brn_registry_id(self, detail): + """Create a verified BRN registry ID on the created individual. + + Args: + detail: The CR detail record containing verification data + """ + # Get the BRN ID type vocabulary code + brn_id_type = self.env.ref( + "spp_dci_demo.code_id_type_brn", + raise_if_not_found=False, + ) + + if not brn_id_type: + _logger.warning( + "BRN ID type vocabulary code not found (spp_dci_demo.code_id_type_brn). " + "Cannot create verified registry ID." + ) + return + + # Check if the individual already has a BRN + existing_brn = self.env["spp.registry.id"].search( + [ + ("partner_id", "=", detail.created_individual_id.id), + ("id_type_id", "=", brn_id_type.id), + ], + limit=1, + ) + + if existing_brn: + _logger.info( + "Individual %s already has a BRN registry ID, updating with verification data", + detail.created_individual_id.id, + ) + # Update existing record with verification data + existing_brn.write( + { + "value": detail.birth_registration_number, + "status": "valid", + "verification_method": "dci_api", + "verification_date": detail.birth_verification_date, + "verification_source": self._get_verification_source(detail), + "verification_response": detail.birth_verification_response, + } + ) + else: + # Create new registry ID with verification data + registry_id_vals = { + "partner_id": detail.created_individual_id.id, + "id_type_id": brn_id_type.id, + "value": detail.birth_registration_number, + "status": "valid", + "verification_method": "dci_api", + "verification_date": detail.birth_verification_date, + "verification_source": self._get_verification_source(detail), + "verification_response": detail.birth_verification_response, + } + + self.env["spp.registry.id"].create(registry_id_vals) + + _logger.info( + "Created verified BRN registry ID for individual %s (BRN: %s)", + detail.created_individual_id.id, + detail.birth_registration_number, + ) + + def _get_verification_source(self, detail): + """Get the verification source name from the data source. + + Args: + detail: The CR detail record + + Returns: + String identifying the verification source + """ + if detail.dci_data_source_id: + return detail.dci_data_source_id.name + # Try to get from default + default_source = detail._get_default_dci_data_source() + if default_source: + return default_source.name + return "DCI API" + + def _auto_enroll_in_household_programs(self, individual, household): + """Auto-enroll the household and new individual in the configured program. + + Args: + individual: The newly created individual (res.partner) + household: The household/group (res.partner) + """ + # Get program ID from system parameter + program_id_str = ( + self.env["ir.config_parameter"] + .sudo() + .get_param("spp_dci_demo.enrollment_program_id", "") + ) + if not program_id_str: + _logger.info("No enrollment program configured (spp_dci_demo.enrollment_program_id)") + return + + try: + program_id = int(program_id_str) + except (ValueError, TypeError): + _logger.warning("Invalid enrollment_program_id: %s", program_id_str) + return + + # Check if program membership model exists + if "spp.program.membership" not in self.env: + _logger.info("spp.program.membership model not available, skipping enrollment") + return + + # Get the program + program = self.env["spp.program"].browse(program_id) + if not program.exists(): + _logger.warning("Enrollment program ID %s does not exist", program_id) + return + + _logger.info( + "Auto-enrolling household %s and members in program %s", + household.id, + program.name, + ) + + # Enroll the household (group) if not already enrolled + self._enroll_partner_in_program(household, program) + + # Enroll all household members including the new child + if hasattr(household, "group_membership_ids"): + for membership in household.group_membership_ids: + if membership.individual: + self._enroll_partner_in_program(membership.individual, program) + + def _enroll_partner_in_program(self, partner, program): + """Enroll a partner (individual or group) in a program. + + Args: + partner: The partner to enroll (res.partner) + program: The program to enroll in (spp.program) + """ + # Check if already enrolled + existing = self.env["spp.program.membership"].search([ + ("partner_id", "=", partner.id), + ("program_id", "=", program.id), + ], limit=1) + + if existing: + _logger.info( + "Partner %s already in program %s (state: %s)", + partner.id, + program.name, + existing.state, + ) + return + + # Create enrollment + try: + self.env["spp.program.membership"].create({ + "partner_id": partner.id, + "program_id": program.id, + "state": "enrolled", + }) + _logger.info( + "Enrolled partner %s in program %s", + partner.id, + program.name, + ) + except Exception as e: + _logger.warning( + "Failed to enroll partner %s in program %s: %s", + partner.id, + program.name, + str(e), + ) diff --git a/spp_dci_demo/models/cr_detail_add_member.py b/spp_dci_demo/models/cr_detail_add_member.py new file mode 100644 index 00000000..74f93d71 --- /dev/null +++ b/spp_dci_demo/models/cr_detail_add_member.py @@ -0,0 +1,329 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +import json +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +from ..utils.dci_verification import ( + check_data_matches, + extract_person_from_dci_response, + parse_dci_response, +) + +_logger = logging.getLogger(__name__) + + +class SPPCRDetailAddMemberDCI(models.Model): + """Extend Add Member CR Detail with DCI birth verification fields.""" + + _inherit = "spp.cr.detail.add_member" + + # Birth Registration Number entered by social worker + birth_registration_number = fields.Char( + string="Birth Registration Number (BRN)", + tracking=True, + help="Birth Registration Number from the civil registry (e.g., OpenCRVS)", + ) + + # Verification status + birth_verification_status = fields.Selection( + selection=[ + ("unverified", "Unverified"), + ("verified", "Verified"), + ("not_found", "Not Found"), + ("error", "Error"), + ], + string="Birth Verification Status", + default="unverified", + tracking=True, + help="Status of birth verification via DCI", + ) + + # Data match status + dci_data_match = fields.Boolean( + string="DCI Data Matches", + readonly=True, + help="Whether the DCI response data matches the CR detail fields", + ) + + # When verification was performed + birth_verification_date = fields.Datetime( + string="Verification Date", + readonly=True, + help="When the birth verification was performed", + ) + + # Raw DCI response for audit + birth_verification_response = fields.Text( + string="Verification Response", + readonly=True, + help="Raw JSON response from DCI verification for audit purposes", + ) + + # Which CRVS registry to verify against + dci_data_source_id = fields.Many2one( + "spp.dci.data.source", + string="DCI Data Source", + domain="[('registry_type', '=', 'ns:org:RegistryType:Civil'), ('active', '=', True)]", + help="DCI data source (CRVS registry) to use for birth verification", + ) + + @api.onchange("birth_registration_number") + def _onchange_birth_registration_number(self): + """Strip whitespace from BRN on change.""" + if self.birth_registration_number: + stripped = self.birth_registration_number.strip().upper() + if stripped != self.birth_registration_number: + self.birth_registration_number = stripped + + @api.model + def _get_default_dci_data_source(self): + """Get the default DCI data source for birth verification. + + Looks for a system parameter or finds the first active CRVS data source. + """ + # Try system parameter first + param_value = self.env["ir.config_parameter"].sudo().get_param("spp_dci_demo.default_crvs_data_source") + if param_value: + try: + data_source = self.env["spp.dci.data.source"].browse(int(param_value)) + if data_source.exists() and data_source.active: + return data_source + except (ValueError, TypeError): + pass + + # Fall back to first active CRVS data source + return self.env["spp.dci.data.source"].search( + [ + ("registry_type", "=", "ns:org:RegistryType:Civil"), + ("active", "=", True), + ], + limit=1, + ) + + def action_verify_birth(self): + """Verify birth registration via DCI query to CRVS registry. + + 1. Validate BRN is filled + 2. Get DCI data source + 3. Call DCI client to search by BRN + 4. Parse response and update verification status + """ + self.ensure_one() + + # Validate BRN is provided + if not self.birth_registration_number: + raise UserError(_("Please enter the Birth Registration Number (BRN) before verifying.")) + + # Get the DCI data source + data_source = self.dci_data_source_id or self._get_default_dci_data_source() + if not data_source: + raise UserError( + _( + "No DCI data source configured for birth verification. " + "Please configure a CRVS data source or contact your administrator." + ) + ) + + # Import DCIClient + from odoo.addons.spp_dci_client.services.client import DCIClient + + try: + # Create client and search by BRN + # OpenCRVS requires special format - use search_by_id_opencrvs + client = DCIClient(data_source, self.env) + response = client.search_by_id_opencrvs( + identifier_type="BRN", + identifier_value=self.birth_registration_number, + event_type="birth", + ) + + # Store raw response for audit + response_json = json.dumps(response, indent=2, default=str) + + # Parse response to determine verification status + verification_status = self._parse_dci_response(response) + + # Check if DCI data matches CR detail fields + data_matches = False + if verification_status == "verified": + data_matches = self._check_data_matches_dci_response(response) + + # Update record + self.write( + { + "birth_verification_status": verification_status, + "birth_verification_date": fields.Datetime.now(), + "birth_verification_response": response_json, + "dci_data_match": data_matches, + } + ) + + # Log success + _logger.info( + "Birth verification for BRN %s completed with status: %s, data_match: %s", + self.birth_registration_number, + verification_status, + data_matches, + ) + + # Auto-approve if verified and data matches + auto_approved = False + if verification_status == "verified" and data_matches: + auto_approved = self._try_auto_approve() + + # Return notification + if verification_status == "verified": + if auto_approved: + message = _("Birth registration verified and CR auto-approved!") + elif data_matches: + message = _("Birth registration verified and data matches!") + else: + message = _("Birth registration verified (data mismatch - manual review required).") + elif verification_status == "not_found": + message = _("No matching birth registration found for BRN: %s") % self.birth_registration_number + else: + message = _("Birth verification completed with status: %s") % verification_status + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Birth Verification"), + "message": message, + "type": "success" if verification_status == "verified" else "warning", + "sticky": False, + "next": {"type": "ir.actions.client", "tag": "soft_reload"}, + }, + } + + except UserError: + # Re-raise UserError as-is + raise + except Exception as e: + _logger.exception("Birth verification failed for BRN %s", self.birth_registration_number) + self.write( + { + "birth_verification_status": "error", + "birth_verification_date": fields.Datetime.now(), + "birth_verification_response": str(e), + } + ) + raise UserError(_("Birth verification failed: %s") % str(e)) from e + + def _parse_dci_response(self, response): + """Parse DCI response to determine verification status. + + Delegates to the standalone utility function. + + Args: + response: Dict response from DCI search + + Returns: + Verification status string: 'verified', 'not_found', or 'error' + """ + return parse_dci_response(response) + + def _extract_person_from_dci_response(self, response): + """Extract person data from DCI response. + + Delegates to the standalone utility function. + + Args: + response: Dict response from DCI search + + Returns: + Dict with normalized person data or None if not found + """ + return extract_person_from_dci_response(response) + + def _check_data_matches_dci_response(self, response): + """Check if DCI response data matches the CR detail fields. + + Delegates to the standalone utility function, passing field + values from this record. + + Args: + response: Dict response from DCI search + + Returns: + Boolean indicating if data matches + """ + person_data = extract_person_from_dci_response(response) + if not person_data: + _logger.warning("Could not extract person data from DCI response for matching") + return False + + gender_display = (self.gender_id.display or "") if self.gender_id else "" + matches, mismatches = check_data_matches( + person_data, + given_name=self.given_name, + family_name=self.family_name, + birthdate=self.birthdate, + gender_display=gender_display, + ) + + if mismatches: + _logger.info( + "DCI data mismatch for BRN %s: %s", + self.birth_registration_number, + "; ".join(mismatches), + ) + else: + _logger.info( + "DCI data matches CR detail for BRN %s", + self.birth_registration_number, + ) + + return matches + + def _try_auto_approve(self): + """Try to auto-approve the change request. + + Only auto-approves if: + - System parameter spp_dci_demo.auto_approve_on_match is True + - The change request is in a state that can be approved + + Returns: + Boolean indicating if auto-approval was successful + """ + # Check system parameter + auto_approve_enabled = ( + self.env["ir.config_parameter"].sudo().get_param("spp_dci_demo.auto_approve_on_match", "False") + ) + if auto_approve_enabled.lower() not in ("true", "1", "yes"): + _logger.info("Auto-approval disabled by system parameter") + return False + + # Get the change request + cr = self.change_request_id + if not cr: + _logger.warning("No change request linked to detail, cannot auto-approve") + return False + + # Check if CR can be approved (must be in pending state) + if cr.display_state != "pending": + _logger.info( + "Change request %s is in state '%s', cannot auto-approve", + cr.name, + cr.display_state, + ) + return False + + try: + # Auto-approve with comment + cr.action_approve(comment=_("Auto-approved: DCI birth verification matched")) + _logger.info( + "Auto-approved change request %s due to DCI data match", + cr.name, + ) + return True + except Exception as e: + _logger.warning( + "Failed to auto-approve change request %s: %s", + cr.name, + str(e), + ) + return False diff --git a/spp_dci_demo/security/ir.model.access.csv b/spp_dci_demo/security/ir.model.access.csv new file mode 100644 index 00000000..a41b54ab --- /dev/null +++ b/spp_dci_demo/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_spp_dci_demo_add_child_wizard,access.spp.dci.demo.add.child.wizard,model_spp_dci_demo_add_child_wizard,base.group_user,1,1,1,1 diff --git a/spp_dci_demo/tests/__init__.py b/spp_dci_demo/tests/__init__.py new file mode 100644 index 00000000..d29380d2 --- /dev/null +++ b/spp_dci_demo/tests/__init__.py @@ -0,0 +1,6 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from . import test_birth_verification +from . import test_apply_creates_brn +from . import test_dci_verification_utils +from . import test_add_child_wizard diff --git a/spp_dci_demo/tests/test_add_child_wizard.py b/spp_dci_demo/tests/test_add_child_wizard.py new file mode 100644 index 00000000..96ee65fc --- /dev/null +++ b/spp_dci_demo/tests/test_add_child_wizard.py @@ -0,0 +1,512 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +"""Tests for the multi-step Add Child wizard.""" + +from unittest.mock import MagicMock, patch + +from odoo.exceptions import UserError +from odoo.tests import TransactionCase, tagged + + +@tagged("post_install", "-at_install") +class TestAddChildWizard(TransactionCase): + """Test the multi-step Add Child wizard.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.Wizard = cls.env["spp.dci.demo.add.child.wizard"] + + # Create test data source + cls.data_source = cls.env["spp.dci.data.source"].create( + { + "name": "Test CRVS", + "code": "test_crvs_wizard", + "base_url": "https://crvs.example.org/api", + "auth_type": "none", + "our_sender_id": "openspp.test", + "registry_type": "ns:org:RegistryType:Civil", + "active": True, + } + ) + + # Create test household (group) + cls.test_group = cls.env["res.partner"].create( + { + "name": "Test Wizard Household", + "is_registrant": True, + "is_group": True, + } + ) + + # Get or create the add_member CR type + cls.request_type = cls.env["spp.change.request.type"].search([("code", "=", "add_member")], limit=1) + if not cls.request_type: + cls.request_type = cls.env["spp.change.request.type"].create( + { + "name": "Add Member", + "code": "add_member", + "detail_model": "spp.cr.detail.add_member", + "strategy_model": "spp.cr.apply.add_member", + "target_type": "group", + } + ) + + # Create an approver user + cls.approver = cls.env["res.users"].create( + { + "name": "Test Wizard Approver", + "login": "test_wizard_approver", + "email": "wizard_approver@test.com", + } + ) + + # Get spp.change.request ir.model record + cls.cr_model_record = cls.env["ir.model"].search([("model", "=", "spp.change.request")], limit=1) + + # Create approval definition + cls.approval_def = cls.env["spp.approval.definition"].create( + { + "name": "Test Wizard Approval", + "model_id": cls.cr_model_record.id, + "approval_type": "user", + "approval_user_ids": [(6, 0, [cls.approver.id])], + } + ) + + # Link approval definition to CR type + cls.request_type.approval_definition_id = cls.approval_def + + # Get gender vocabulary code (loaded via spp_vocabulary data) + cls.gender_male = cls.env.ref("spp_vocabulary.code_gender_male", raise_if_not_found=False) + if not cls.gender_male: + cls.gender_male = cls.env["spp.vocabulary.code"].search( + [ + ("namespace_uri", "=", "urn:iso:std:iso:5218"), + ("code", "=", "1"), + ], + limit=1, + ) + + # Get relationship vocabulary code (use "head" since "child" is not in data) + cls.relationship_head = cls.env.ref( + "spp_vocabulary.code_membership_type_head", + raise_if_not_found=False, + ) + if not cls.relationship_head: + cls.relationship_head = cls.env["spp.vocabulary.code"].search( + [ + ( + "vocabulary_id.namespace_uri", + "=", + "urn:openspp:vocab:group-membership-type", + ), + ], + limit=1, + ) + + def _create_wizard(self, **kwargs): + """Create a wizard with sensible defaults.""" + vals = { + "registrant_id": self.test_group.id, + } + vals.update(kwargs) + return self.Wizard.create(vals) + + # ================== + # Default Get Tests + # ================== + + def test_default_type_is_add_member(self): + """default_get sets request_type_id to the add_member type.""" + wizard = self.Wizard.create({}) + self.assertEqual(wizard.request_type_id, self.request_type) + + def test_context_prefill_registrant(self): + """Registrant is pre-filled from active_id context.""" + wizard = self.Wizard.with_context( + active_model="res.partner", + active_id=self.test_group.id, + ).create({}) + self.assertEqual(wizard.registrant_id, self.test_group) + + # ================== + # Step Navigation + # ================== + + def test_initial_stage_is_registrant(self): + """Wizard starts at 'registrant' stage.""" + wizard = self._create_wizard() + self.assertEqual(wizard.stage, "registrant") + + def test_navigate_forward_to_details(self): + """action_next advances from 'registrant' to 'details'.""" + wizard = self._create_wizard() + wizard.action_next() + self.assertEqual(wizard.stage, "details") + + def test_navigate_forward_to_review(self): + """action_next advances from 'details' to 'review'.""" + wizard = self._create_wizard( + given_name="John", + family_name="Doe", + birthdate="2024-01-15", + ) + wizard.stage = "details" + wizard.action_next() + self.assertEqual(wizard.stage, "review") + + def test_navigate_backward_from_details(self): + """action_previous goes from 'details' to 'registrant'.""" + wizard = self._create_wizard() + wizard.stage = "details" + wizard.action_previous() + self.assertEqual(wizard.stage, "registrant") + + def test_navigate_backward_from_review(self): + """action_previous goes from 'review' to 'details'.""" + wizard = self._create_wizard() + wizard.stage = "review" + wizard.action_previous() + self.assertEqual(wizard.stage, "details") + + def test_navigate_backward_from_registrant_stays(self): + """action_previous on first step stays at 'registrant'.""" + wizard = self._create_wizard() + wizard.action_previous() + self.assertEqual(wizard.stage, "registrant") + + # ================== + # Per-Step Validation + # ================== + + def test_step1_requires_registrant(self): + """Cannot advance past step 1 without a registrant.""" + wizard = self.Wizard.create({}) + with self.assertRaises(UserError): + wizard.action_next() + + def test_step2_requires_given_name(self): + """Cannot advance past step 2 without given_name.""" + wizard = self._create_wizard( + family_name="Doe", + birthdate="2024-01-15", + ) + wizard.stage = "details" + with self.assertRaises(UserError): + wizard.action_next() + + def test_step2_requires_birthdate(self): + """Cannot advance past step 2 without birthdate.""" + wizard = self._create_wizard( + given_name="John", + family_name="Doe", + ) + wizard.stage = "details" + with self.assertRaises(UserError): + wizard.action_next() + + # ================== + # Computed Fields + # ================== + + def test_member_name_computed(self): + """member_name is computed from given_name and family_name.""" + wizard = self._create_wizard( + given_name="John", + family_name="Doe", + ) + self.assertEqual(wizard.member_name, "DOE, JOHN") + + def test_member_name_given_only(self): + """member_name with only given_name.""" + wizard = self._create_wizard(given_name="John") + self.assertEqual(wizard.member_name, "JOHN") + + def test_member_name_family_only(self): + """member_name with only family_name.""" + wizard = self._create_wizard(family_name="Doe") + self.assertEqual(wizard.member_name, "DOE") + + def test_registrant_info_html_populated(self): + """registrant_info_html is populated when registrant is selected.""" + wizard = self._create_wizard() + self.assertTrue(wizard.registrant_info_html) + self.assertIn("Test Wizard Household", wizard.registrant_info_html) + + def test_preview_html_contains_data(self): + """preview_html shows summary data at review stage.""" + wizard = self._create_wizard( + given_name="John", + family_name="Doe", + birthdate="2024-01-15", + gender_id=self.gender_male.id, + ) + wizard.stage = "review" + self.assertTrue(wizard.preview_html) + self.assertIn("DOE, JOHN", wizard.preview_html) + self.assertIn("2024-01-15", wizard.preview_html) + + # ================== + # Birth Verification + # ================== + + def test_verify_birth_requires_brn(self): + """action_verify_birth requires a BRN.""" + wizard = self._create_wizard( + given_name="John", + family_name="Doe", + ) + with self.assertRaises(UserError): + wizard.action_verify_birth() + + @patch("odoo.addons.spp_dci_client.services.client.DCIClient") + def test_verify_birth_success(self, mock_client_class): + """Successful birth verification sets status to 'verified'.""" + mock_client = MagicMock() + mock_client.search_by_id_opencrvs.return_value = { + "identifier": [{"identifier_type": "BRN", "identifier_value": "TEST123"}], + "name": {"given_name": "John", "surname": "Doe"}, + "sex": "male", + "birth_date": "2024-01-15", + } + mock_client_class.return_value = mock_client + + wizard = self._create_wizard( + given_name="John", + family_name="Doe", + birthdate="2024-01-15", + gender_id=self.gender_male.id, + birth_registration_number="TEST123", + dci_data_source_id=self.data_source.id, + ) + wizard.action_verify_birth() + + self.assertEqual(wizard.birth_verification_status, "verified") + self.assertTrue(wizard.birth_verification_date) + self.assertTrue(wizard.birth_verification_response) + self.assertTrue(wizard.dci_data_match) + + @patch("odoo.addons.spp_dci_client.services.client.DCIClient") + def test_verify_birth_not_found(self, mock_client_class): + """Not-found response sets status to 'not_found'.""" + mock_client = MagicMock() + mock_client.search_by_id_opencrvs.return_value = { + "message": { + "search_response": [ + {"status": "succ", "data": []}, + ] + }, + } + mock_client_class.return_value = mock_client + + wizard = self._create_wizard( + given_name="John", + birth_registration_number="NONEXISTENT", + dci_data_source_id=self.data_source.id, + ) + wizard.action_verify_birth() + + self.assertEqual(wizard.birth_verification_status, "not_found") + + @patch("odoo.addons.spp_dci_client.services.client.DCIClient") + def test_verify_birth_error(self, mock_client_class): + """API error sets status to 'error'.""" + mock_client = MagicMock() + mock_client.search_by_id_opencrvs.side_effect = Exception("Connection timeout") + mock_client_class.return_value = mock_client + + wizard = self._create_wizard( + given_name="John", + birth_registration_number="TEST123", + dci_data_source_id=self.data_source.id, + ) + + with self.assertRaises(UserError) as cm: + wizard.action_verify_birth() + self.assertIn("Connection timeout", str(cm.exception)) + + @patch("odoo.addons.spp_dci_client.services.client.DCIClient") + def test_verify_birth_data_mismatch(self, mock_client_class): + """Data mismatch sets dci_data_match to False.""" + mock_client = MagicMock() + mock_client.search_by_id_opencrvs.return_value = { + "identifier": [{"identifier_type": "BRN", "identifier_value": "TEST123"}], + "name": {"given_name": "Jane", "surname": "Smith"}, + "sex": "female", + "birth_date": "2024-06-20", + } + mock_client_class.return_value = mock_client + + wizard = self._create_wizard( + given_name="John", + family_name="Doe", + birthdate="2024-01-15", + gender_id=self.gender_male.id, + birth_registration_number="TEST123", + dci_data_source_id=self.data_source.id, + ) + wizard.action_verify_birth() + + self.assertEqual(wizard.birth_verification_status, "verified") + self.assertFalse(wizard.dci_data_match) + + # ================== + # Create & Submit + # ================== + + def test_create_and_submit_creates_cr(self): + """action_create_and_submit creates a CR with detail populated.""" + wizard = self._create_wizard( + given_name="John", + family_name="Doe", + birthdate="2024-01-15", + gender_id=self.gender_male.id, + relationship_id=self.relationship_head.id, + ) + wizard.stage = "review" + + result = wizard.action_create_and_submit() + + # Should return an action opening the CR form + self.assertEqual(result["res_model"], "spp.change.request") + cr_id = result["res_id"] + cr = self.env["spp.change.request"].browse(cr_id) + self.assertTrue(cr.exists()) + + # Check CR fields + self.assertEqual(cr.request_type_id, self.request_type) + self.assertEqual(cr.registrant_id, self.test_group) + + # Check detail fields + detail = cr.get_detail() + self.assertTrue(detail) + self.assertEqual(detail.given_name, "John") + self.assertEqual(detail.family_name, "Doe") + self.assertEqual(str(detail.birthdate), "2024-01-15") + self.assertEqual(detail.gender_id, self.gender_male) + self.assertEqual(detail.relationship_id, self.relationship_head) + + def test_create_and_submit_submits_cr(self): + """action_create_and_submit submits the CR for approval.""" + wizard = self._create_wizard( + given_name="John", + family_name="Doe", + birthdate="2024-01-15", + gender_id=self.gender_male.id, + ) + wizard.stage = "review" + + result = wizard.action_create_and_submit() + + cr = self.env["spp.change.request"].browse(result["res_id"]) + # Should be pending (submitted for approval) + self.assertEqual(cr.display_state, "pending") + + def test_create_and_submit_copies_verification_data(self): + """Verification data from wizard is copied to the CR detail.""" + wizard = self._create_wizard( + given_name="John", + family_name="Doe", + birthdate="2024-01-15", + gender_id=self.gender_male.id, + birth_registration_number="BRN123", + birth_verification_status="verified", + birth_verification_response='{"test": true}', + dci_data_match=True, + dci_data_source_id=self.data_source.id, + ) + wizard.stage = "review" + + result = wizard.action_create_and_submit() + + cr = self.env["spp.change.request"].browse(result["res_id"]) + detail = cr.get_detail() + self.assertEqual(detail.birth_registration_number, "BRN123") + self.assertEqual(detail.birth_verification_status, "verified") + self.assertTrue(detail.birth_verification_response) + self.assertTrue(detail.dci_data_match) + self.assertEqual(detail.dci_data_source_id, self.data_source) + + @patch("odoo.addons.spp_dci_client.services.client.DCIClient") + def test_full_happy_path(self, mock_client_class): + """Full wizard flow: create -> submit -> auto-approve -> auto-apply.""" + # Enable auto-approve + self.env["ir.config_parameter"].sudo().set_param("spp_dci_demo.auto_approve_on_match", "True") + + mock_client = MagicMock() + mock_client.search_by_id_opencrvs.return_value = { + "identifier": [{"identifier_type": "BRN", "identifier_value": "HAPPY123"}], + "name": {"given_name": "George", "surname": "Doe"}, + "sex": "male", + "birth_date": "2024-01-15", + } + mock_client_class.return_value = mock_client + + # Step 1: Create wizard with household + wizard = self._create_wizard( + given_name="George", + family_name="Doe", + birthdate="2024-01-15", + gender_id=self.gender_male.id, + relationship_id=self.relationship_head.id, + birth_registration_number="HAPPY123", + dci_data_source_id=self.data_source.id, + ) + + # Step 2: Verify birth + wizard.action_verify_birth() + self.assertEqual(wizard.birth_verification_status, "verified") + self.assertTrue(wizard.dci_data_match) + + # Step 3: Create and submit + wizard.stage = "review" + result = wizard.action_create_and_submit() + + cr = self.env["spp.change.request"].browse(result["res_id"]) + detail = cr.get_detail() + + # The CR should be submitted (pending). Auto-approve happens + # at birth verification on the detail, not on the wizard. + # So the CR is in pending state after wizard submit. + self.assertIn(cr.display_state, ("pending", "applied")) + + # Verify detail has all the data + self.assertEqual(detail.given_name, "George") + self.assertEqual(detail.family_name, "Doe") + self.assertEqual(detail.birth_registration_number, "HAPPY123") + self.assertEqual(detail.birth_verification_status, "verified") + self.assertTrue(detail.dci_data_match) + + def test_create_and_submit_with_applicant(self): + """Applicant info is stored when provided.""" + applicant = self.env["res.partner"].create( + { + "name": "Parent Applicant", + "is_registrant": True, + "is_group": False, + } + ) + wizard = self._create_wizard( + given_name="John", + family_name="Doe", + birthdate="2024-01-15", + applicant_id=applicant.id, + applicant_phone="555-1234", + ) + wizard.stage = "review" + + result = wizard.action_create_and_submit() + + cr = self.env["spp.change.request"].browse(result["res_id"]) + self.assertEqual(cr.applicant_id, applicant) + self.assertEqual(cr.applicant_phone, "555-1234") + + def test_action_returns_wizard_form(self): + """Navigation actions return an action dict that redisplays the wizard.""" + wizard = self._create_wizard() + result = wizard.action_next() + self.assertEqual(result["type"], "ir.actions.act_window") + self.assertEqual(result["res_model"], "spp.dci.demo.add.child.wizard") + self.assertEqual(result["res_id"], wizard.id) + self.assertEqual(result["target"], "current") diff --git a/spp_dci_demo/tests/test_apply_creates_brn.py b/spp_dci_demo/tests/test_apply_creates_brn.py new file mode 100644 index 00000000..9a6c5b9b --- /dev/null +++ b/spp_dci_demo/tests/test_apply_creates_brn.py @@ -0,0 +1,370 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Tests for BRN registry ID creation on CR apply.""" + +from odoo import fields +from odoo.tests import TransactionCase, tagged + + +@tagged("post_install", "-at_install") +class TestApplyCreatesBRN(TransactionCase): + """Test that applying Add Member CR creates verified BRN registry ID.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.DataSource = cls.env["spp.dci.data.source"] + cls.CRDetail = cls.env["spp.cr.detail.add_member"] + cls.RegistryId = cls.env["spp.registry.id"] + + # Create a test data source + cls.data_source = cls.DataSource.create( + { + "name": "Test CRVS", + "code": "test_crvs", + "base_url": "https://crvs.example.org/api", + "auth_type": "none", + "our_sender_id": "openspp.test", + "registry_type": "ns:org:RegistryType:Civil", + "active": True, + } + ) + + # Get or create a group for testing + cls.test_group = cls.env["res.partner"].create( + { + "name": "Test Household", + "is_registrant": True, + "is_group": True, + } + ) + + # Get or create a change request type + cls.request_type = cls.env["spp.change.request.type"].search([("code", "=", "add_member")], limit=1) + if not cls.request_type: + cls.request_type = cls.env["spp.change.request.type"].create( + { + "name": "Add Member", + "code": "add_member", + "detail_model": "spp.cr.detail.add_member", + "strategy_model": "spp.cr.apply.add_member", + } + ) + + # Get BRN ID type from vocabulary + cls.brn_id_type = cls.env.ref( + "spp_dci_demo.code_id_type_brn", + raise_if_not_found=False, + ) + + def _create_test_cr_with_detail(self, **detail_kwargs): + """Helper to create a CR with detail record.""" + cr = self.env["spp.change.request"].create( + { + "registrant_id": self.test_group.id, + "request_type_id": self.request_type.id, + } + ) + + detail_vals = { + "registrant_id": self.test_group.id, + "change_request_id": cr.id, + "given_name": "Test", + "family_name": "Child", + "member_name": "CHILD, TEST", + } + detail_vals.update(detail_kwargs) + detail = self.CRDetail.create(detail_vals) + + # Link the detail to the CR + cr.write({"detail_res_id": detail.id}) + + return cr + + def test_apply_creates_verified_brn_when_verified(self): + """Test that applying a verified CR creates a verified BRN registry ID.""" + if not self.brn_id_type: + self.skipTest("BRN ID type not found - module data may not be loaded") + + # Create CR with verified birth + cr = self._create_test_cr_with_detail( + birth_registration_number="UP7D57VSEAZM", + birth_verification_status="verified", + birth_verification_date=fields.Datetime.now(), + birth_verification_response='{"test": "response"}', + dci_data_source_id=self.data_source.id, + ) + + # Apply the change request + apply_strategy = self.env["spp.cr.apply.add_member"] + apply_strategy.apply(cr) + + # Get the created individual + detail = cr.get_detail() + individual = detail.created_individual_id + self.assertTrue(individual, "Individual should be created") + + # Check registry ID was created + registry_id = self.RegistryId.search( + [ + ("partner_id", "=", individual.id), + ("id_type_id", "=", self.brn_id_type.id), + ] + ) + + self.assertTrue(registry_id, "BRN registry ID should be created") + self.assertEqual(registry_id.value, "UP7D57VSEAZM") + self.assertEqual(registry_id.status, "valid") + self.assertEqual(registry_id.verification_method, "dci_api") + self.assertTrue(registry_id.is_verified) + self.assertTrue(registry_id.verification_date) + self.assertEqual(registry_id.verification_source, self.data_source.name) + self.assertTrue(registry_id.verification_response) + + def test_apply_no_brn_when_unverified(self): + """Test that unverified CR does not create BRN registry ID.""" + if not self.brn_id_type: + self.skipTest("BRN ID type not found - module data may not be loaded") + + # Create CR with unverified birth + cr = self._create_test_cr_with_detail( + birth_registration_number="UNVERIFIED123", + birth_verification_status="unverified", + ) + + # Apply the change request + apply_strategy = self.env["spp.cr.apply.add_member"] + apply_strategy.apply(cr) + + # Get the created individual + detail = cr.get_detail() + individual = detail.created_individual_id + self.assertTrue(individual, "Individual should be created") + + # Check registry ID was NOT created + registry_id = self.RegistryId.search( + [ + ("partner_id", "=", individual.id), + ("id_type_id", "=", self.brn_id_type.id), + ] + ) + + self.assertFalse(registry_id, "BRN registry ID should NOT be created for unverified") + + def test_apply_no_brn_when_not_found(self): + """Test that 'not_found' verification does not create BRN registry ID.""" + if not self.brn_id_type: + self.skipTest("BRN ID type not found - module data may not be loaded") + + # Create CR with not_found status + cr = self._create_test_cr_with_detail( + birth_registration_number="NOTFOUND123", + birth_verification_status="not_found", + ) + + # Apply the change request + apply_strategy = self.env["spp.cr.apply.add_member"] + apply_strategy.apply(cr) + + # Get the created individual + detail = cr.get_detail() + individual = detail.created_individual_id + + # Check registry ID was NOT created + registry_id = self.RegistryId.search( + [ + ("partner_id", "=", individual.id), + ("id_type_id", "=", self.brn_id_type.id), + ] + ) + + self.assertFalse(registry_id, "BRN registry ID should NOT be created for not_found") + + def test_apply_no_brn_when_no_brn_number(self): + """Test that verified CR without BRN number does not create registry ID.""" + if not self.brn_id_type: + self.skipTest("BRN ID type not found - module data may not be loaded") + + # Create CR verified but no BRN + cr = self._create_test_cr_with_detail( + birth_verification_status="verified", + birth_verification_date=fields.Datetime.now(), + # No birth_registration_number + ) + + # Apply the change request + apply_strategy = self.env["spp.cr.apply.add_member"] + apply_strategy.apply(cr) + + # Get the created individual + detail = cr.get_detail() + individual = detail.created_individual_id + + # Check registry ID was NOT created + registry_id = self.RegistryId.search( + [ + ("partner_id", "=", individual.id), + ("id_type_id", "=", self.brn_id_type.id), + ] + ) + + self.assertFalse(registry_id, "BRN registry ID should NOT be created without BRN number") + + def test_apply_updates_existing_brn(self): + """Test that applying updates existing BRN registry ID.""" + if not self.brn_id_type: + self.skipTest("BRN ID type not found - module data may not be loaded") + + # First, create an individual with an existing BRN + individual = self.env["res.partner"].create( + { + "name": "Existing Child", + "is_registrant": True, + "is_group": False, + } + ) + + # Create an existing unverified BRN + existing_brn = self.RegistryId.create( + { + "partner_id": individual.id, + "id_type_id": self.brn_id_type.id, + "value": "OLD_BRN", + "status": "invalid", + } + ) + + # Create CR with verified birth + cr = self._create_test_cr_with_detail( + birth_registration_number="NEW_BRN", + birth_verification_status="verified", + birth_verification_date=fields.Datetime.now(), + birth_verification_response='{"new": "data"}', + dci_data_source_id=self.data_source.id, + ) + + # Manually set the created_individual_id to our existing individual + detail = cr.get_detail() + detail.write( + { + "created_individual_id": individual.id, + } + ) + + # Call the BRN creation method directly + apply_strategy = self.env["spp.cr.apply.add_member"] + apply_strategy._create_verified_brn_registry_id(detail) + + # Refresh existing BRN + existing_brn.invalidate_recordset() + + # Check existing BRN was updated + self.assertEqual(existing_brn.value, "NEW_BRN") + self.assertEqual(existing_brn.status, "valid") + self.assertEqual(existing_brn.verification_method, "dci_api") + self.assertTrue(existing_brn.is_verified) + + +@tagged("post_install", "-at_install") +class TestRegistryIdVerification(TransactionCase): + """Test verification fields on spp.registry.id model.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.RegistryId = cls.env["spp.registry.id"] + + # Get an ID type + cls.id_type = cls.env["spp.vocabulary.code"].search( + [ + ("vocabulary_id.namespace_uri", "=", "urn:openspp:vocab:id-type"), + ], + limit=1, + ) + if not cls.id_type: + # Create a vocabulary and code for testing + vocab = cls.env["spp.vocabulary"].create( + { + "name": "ID Type", + "namespace_uri": "urn:openspp:vocab:id-type", + } + ) + cls.id_type = cls.env["spp.vocabulary.code"].create( + { + "vocabulary_id": vocab.id, + "code": "test_id", + "display": "Test ID", + } + ) + + # Create a test registrant + cls.registrant = cls.env["res.partner"].create( + { + "name": "Test Registrant", + "is_registrant": True, + "is_group": False, + } + ) + + def test_is_verified_computed_for_dci_api(self): + """Test is_verified is True for dci_api verification method.""" + registry_id = self.RegistryId.create( + { + "partner_id": self.registrant.id, + "id_type_id": self.id_type.id, + "value": "TEST123", + "verification_method": "dci_api", + } + ) + + self.assertTrue(registry_id.is_verified) + + def test_is_verified_computed_for_physical_document(self): + """Test is_verified is True for physical_document verification method.""" + registry_id = self.RegistryId.create( + { + "partner_id": self.registrant.id, + "id_type_id": self.id_type.id, + "value": "TEST456", + "verification_method": "physical_document", + } + ) + + self.assertTrue(registry_id.is_verified) + + def test_is_verified_false_for_verbal(self): + """Test is_verified is False for verbal verification method.""" + registry_id = self.RegistryId.create( + { + "partner_id": self.registrant.id, + "id_type_id": self.id_type.id, + "value": "TEST789", + "verification_method": "verbal", + } + ) + + self.assertFalse(registry_id.is_verified) + + def test_is_verified_false_for_self_declared(self): + """Test is_verified is False for self_declared verification method.""" + registry_id = self.RegistryId.create( + { + "partner_id": self.registrant.id, + "id_type_id": self.id_type.id, + "value": "TEST012", + "verification_method": "self_declared", + } + ) + + self.assertFalse(registry_id.is_verified) + + def test_is_verified_false_when_no_method(self): + """Test is_verified is False when no verification method set.""" + registry_id = self.RegistryId.create( + { + "partner_id": self.registrant.id, + "id_type_id": self.id_type.id, + "value": "TEST345", + } + ) + + self.assertFalse(registry_id.is_verified) diff --git a/spp_dci_demo/tests/test_birth_verification.py b/spp_dci_demo/tests/test_birth_verification.py new file mode 100644 index 00000000..cbe65cec --- /dev/null +++ b/spp_dci_demo/tests/test_birth_verification.py @@ -0,0 +1,266 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Tests for DCI birth verification in Add Member change request.""" + +import json +from unittest.mock import MagicMock, patch + +from odoo.exceptions import UserError +from odoo.tests import TransactionCase, tagged + + +@tagged("post_install", "-at_install") +class TestBirthVerification(TransactionCase): + """Test birth verification via DCI in Add Member CR.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.DataSource = cls.env["spp.dci.data.source"] + cls.CRDetail = cls.env["spp.cr.detail.add_member"] + + # Create a test data source + cls.data_source = cls.DataSource.create( + { + "name": "Test CRVS", + "code": "test_crvs", + "base_url": "https://crvs.example.org/api", + "auth_type": "none", + "our_sender_id": "openspp.test", + "registry_type": "ns:org:RegistryType:Civil", + "active": True, + } + ) + + # Get or create a group for testing + cls.test_group = cls.env["res.partner"].create( + { + "name": "Test Household", + "is_registrant": True, + "is_group": True, + } + ) + + # Get or create a change request type + cls.request_type = cls.env["spp.change.request.type"].search([("code", "=", "add_member")], limit=1) + if not cls.request_type: + cls.request_type = cls.env["spp.change.request.type"].create( + { + "name": "Add Member", + "code": "add_member", + "detail_model": "spp.cr.detail.add_member", + "strategy_model": "spp.cr.apply.add_member", + } + ) + + def _create_test_cr_detail(self, **kwargs): + """Helper to create a test CR detail record.""" + # Create a change request first + cr = self.env["spp.change.request"].create( + { + "registrant_id": self.test_group.id, + "request_type_id": self.request_type.id, + } + ) + + # Create detail record + detail_vals = { + "registrant_id": self.test_group.id, + "change_request_id": cr.id, + "given_name": "Test", + "family_name": "Child", + "member_name": "CHILD, TEST", + } + detail_vals.update(kwargs) + detail = self.CRDetail.create(detail_vals) + + # Link the detail to the CR + cr.write({"detail_res_id": detail.id}) + + return detail + + def test_birth_verification_fields_exist(self): + """Test that DCI birth verification fields are present on CR detail.""" + detail = self._create_test_cr_detail() + + # Check field existence + self.assertIn("birth_registration_number", detail._fields) + self.assertIn("birth_verification_status", detail._fields) + self.assertIn("birth_verification_date", detail._fields) + self.assertIn("birth_verification_response", detail._fields) + self.assertIn("dci_data_source_id", detail._fields) + + def test_birth_verification_default_status(self): + """Test default verification status is 'unverified'.""" + detail = self._create_test_cr_detail() + self.assertEqual(detail.birth_verification_status, "unverified") + + def test_action_verify_birth_requires_brn(self): + """Test verify action fails without BRN.""" + detail = self._create_test_cr_detail() + + with self.assertRaises(UserError) as cm: + detail.action_verify_birth() + self.assertIn("Birth Registration Number", str(cm.exception)) + + def test_action_verify_birth_requires_data_source(self): + """Test verify action fails without data source.""" + # Deactivate all CRVS data sources + self.DataSource.search( + [ + ("registry_type", "=", "ns:org:RegistryType:Civil"), + ] + ).write({"active": False}) + + detail = self._create_test_cr_detail( + birth_registration_number="TEST123", + ) + + with self.assertRaises(UserError) as cm: + detail.action_verify_birth() + self.assertIn("data source", str(cm.exception).lower()) + + # Re-activate for other tests + self.data_source.active = True + + @patch("odoo.addons.spp_dci_client.services.client.DCIClient") + def test_action_verify_birth_success_direct_response(self, mock_client_class): + """Test successful verification with OpenCRVS direct response format.""" + # Mock DCI client response (OpenCRVS format) + mock_client = MagicMock() + mock_client.search_by_id_opencrvs.return_value = { + "identifier": [ + {"identifier_type": "UIN", "identifier_value": "5126797337"}, + {"identifier_type": "BRN", "identifier_value": "UP7D57VSEAZM"}, + ], + "name": {"given_name": "George", "second_name": "", "surname": "Smith"}, + "sex": "male", + "birth_date": "2026-02-05", + } + mock_client_class.return_value = mock_client + + detail = self._create_test_cr_detail( + birth_registration_number="UP7D57VSEAZM", + dci_data_source_id=self.data_source.id, + ) + + result = detail.action_verify_birth() + + # Verify status updated + self.assertEqual(detail.birth_verification_status, "verified") + self.assertTrue(detail.birth_verification_date) + self.assertTrue(detail.birth_verification_response) + + # Verify response is stored as JSON + response_data = json.loads(detail.birth_verification_response) + self.assertIn("identifier", response_data) + + # Verify notification is returned + self.assertEqual(result["type"], "ir.actions.client") + self.assertEqual(result["tag"], "display_notification") + self.assertEqual(result["params"]["type"], "success") + + @patch("odoo.addons.spp_dci_client.services.client.DCIClient") + def test_action_verify_birth_success_dci_format(self, mock_client_class): + """Test successful verification with standard DCI response format.""" + mock_client = MagicMock() + mock_client.search_by_id_opencrvs.return_value = { + "header": {"status": "succ"}, + "message": { + "search_response": [ + { + "status": "succ", + "data": [ + { + "identifier": [{"type": "BRN", "value": "TEST123"}], + "name": {"given_name": "Test"}, + } + ], + } + ] + }, + } + mock_client_class.return_value = mock_client + + detail = self._create_test_cr_detail( + birth_registration_number="TEST123", + dci_data_source_id=self.data_source.id, + ) + + detail.action_verify_birth() + + self.assertEqual(detail.birth_verification_status, "verified") + + @patch("odoo.addons.spp_dci_client.services.client.DCIClient") + def test_action_verify_birth_not_found(self, mock_client_class): + """Test verification with no matching record.""" + mock_client = MagicMock() + mock_client.search_by_id_opencrvs.return_value = { + "message": { + "search_response": [ + { + "status": "succ", + "data": [], + } + ] + }, + } + mock_client_class.return_value = mock_client + + detail = self._create_test_cr_detail( + birth_registration_number="NONEXISTENT", + dci_data_source_id=self.data_source.id, + ) + + result = detail.action_verify_birth() + + self.assertEqual(detail.birth_verification_status, "not_found") + self.assertEqual(result["params"]["type"], "warning") + + @patch("odoo.addons.spp_dci_client.services.client.DCIClient") + def test_action_verify_birth_error(self, mock_client_class): + """Test verification with API error.""" + mock_client = MagicMock() + mock_client.search_by_id_opencrvs.side_effect = Exception("API Error") + mock_client_class.return_value = mock_client + + detail = self._create_test_cr_detail( + birth_registration_number="TEST123", + dci_data_source_id=self.data_source.id, + ) + + with self.assertRaises(UserError) as cm: + detail.action_verify_birth() + + self.assertIn("API Error", str(cm.exception)) + # Note: The status update is rolled back with the transaction when exception is raised + # So we only verify the exception message, not the status + + def test_get_default_dci_data_source(self): + """Test getting default DCI data source.""" + detail = self._create_test_cr_detail() + + default_source = detail._get_default_dci_data_source() + + # Should find our test data source + self.assertTrue(default_source) + self.assertEqual(default_source.registry_type, "ns:org:RegistryType:Civil") + self.assertTrue(default_source.active) + + @patch("odoo.addons.spp_dci_client.services.client.DCIClient") + def test_parse_dci_response_error_status(self, mock_client_class): + """Test parsing response with error status in header.""" + mock_client = MagicMock() + mock_client.search_by_id_opencrvs.return_value = { + "header": {"status": "rjct"}, + "message": {}, + } + mock_client_class.return_value = mock_client + + detail = self._create_test_cr_detail( + birth_registration_number="TEST123", + dci_data_source_id=self.data_source.id, + ) + + detail.action_verify_birth() + + self.assertEqual(detail.birth_verification_status, "error") diff --git a/spp_dci_demo/tests/test_dci_verification_utils.py b/spp_dci_demo/tests/test_dci_verification_utils.py new file mode 100644 index 00000000..1ddce73a --- /dev/null +++ b/spp_dci_demo/tests/test_dci_verification_utils.py @@ -0,0 +1,414 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from datetime import date + +from odoo.tests import TransactionCase, tagged + + +@tagged("post_install", "-at_install") +class TestParseDCIResponse(TransactionCase): + """Tests for parse_dci_response standalone function.""" + + def _get_function(self): + from odoo.addons.spp_dci_demo.utils.dci_verification import ( + parse_dci_response, + ) + + return parse_dci_response + + def test_dci_format_success_with_data(self): + """Standard DCI response with successful status and data returns 'verified'.""" + parse = self._get_function() + response = { + "header": {"status": "succ"}, + "message": { + "search_response": [ + { + "status": "succ", + "data": [{"name": {"given_name": "John"}}], + } + ] + }, + } + self.assertEqual(parse(response), "verified") + + def test_dci_format_success_no_data(self): + """DCI response with success status but empty data returns 'not_found'.""" + parse = self._get_function() + response = { + "header": {"status": "succ"}, + "message": { + "search_response": [ + { + "status": "succ", + "data": [], + } + ] + }, + } + self.assertEqual(parse(response), "not_found") + + def test_dci_format_rejected_header(self): + """DCI response with rejected header status returns 'error'.""" + parse = self._get_function() + response = { + "header": {"status": "rjct"}, + "message": {"search_response": []}, + } + self.assertEqual(parse(response), "error") + + def test_dci_format_rejected_search_response(self): + """DCI response with rejected search_response item returns 'error'.""" + parse = self._get_function() + response = { + "header": {"status": "succ"}, + "message": { + "search_response": [ + { + "status": "rjct", + "data": [], + } + ] + }, + } + self.assertEqual(parse(response), "error") + + def test_dci_format_empty_search_response(self): + """DCI response with empty search_response list returns 'not_found'.""" + parse = self._get_function() + response = { + "header": {"status": "succ"}, + "message": {"search_response": []}, + } + self.assertEqual(parse(response), "not_found") + + def test_opencrvs_direct_format_with_identifier(self): + """OpenCRVS direct response with 'identifier' key returns 'verified'.""" + parse = self._get_function() + response = { + "identifier": "BRN-12345", + "name": {"given_name": "John", "surname": "Doe"}, + } + self.assertEqual(parse(response), "verified") + + def test_opencrvs_direct_format_with_name_only(self): + """OpenCRVS direct response with 'name' key returns 'verified'.""" + parse = self._get_function() + response = { + "name": {"given_name": "John", "surname": "Doe"}, + } + self.assertEqual(parse(response), "verified") + + def test_unexpected_format_returns_not_found(self): + """Unknown response structure returns 'not_found'.""" + parse = self._get_function() + response = {"something_else": True} + self.assertEqual(parse(response), "not_found") + + def test_empty_dict_returns_not_found(self): + """Empty dict returns 'not_found'.""" + parse = self._get_function() + self.assertEqual(parse({}), "not_found") + + +@tagged("post_install", "-at_install") +class TestExtractPersonFromDCIResponse(TransactionCase): + """Tests for extract_person_from_dci_response standalone function.""" + + def _get_function(self): + from odoo.addons.spp_dci_demo.utils.dci_verification import ( + extract_person_from_dci_response, + ) + + return extract_person_from_dci_response + + def test_dci_format_list_data(self): + """Extract person from standard DCI format with data as list.""" + extract = self._get_function() + response = { + "message": { + "search_response": [ + { + "status": "succ", + "data": [ + { + "name": {"given_name": "Jane", "surname": "Doe"}, + "birth_date": "2024-01-15", + "sex": "Female", + } + ], + } + ] + }, + } + result = extract(response) + self.assertIsNotNone(result) + self.assertEqual(result["given_name"], "JANE") + self.assertEqual(result["family_name"], "DOE") + self.assertEqual(result["birth_date"], "2024-01-15") + self.assertEqual(result["sex"], "female") + + def test_dci_format_dict_data(self): + """Extract person from DCI format with data as dict.""" + extract = self._get_function() + response = { + "message": { + "search_response": [ + { + "status": "succ", + "data": { + "name": {"given_name": "Bob", "surname": "Smith"}, + "birthdate": "2023-06-01", + "gender": "Male", + }, + } + ] + }, + } + result = extract(response) + self.assertIsNotNone(result) + self.assertEqual(result["given_name"], "BOB") + self.assertEqual(result["family_name"], "SMITH") + self.assertEqual(result["birth_date"], "2023-06-01") + self.assertEqual(result["sex"], "male") + + def test_opencrvs_direct_format(self): + """Extract person from OpenCRVS direct response format.""" + extract = self._get_function() + response = { + "identifier": "BRN-12345", + "name": {"given_name": "Alice", "surname": "Johnson"}, + "birth_date": "2024-03-20", + "sex": "Female", + } + result = extract(response) + self.assertIsNotNone(result) + self.assertEqual(result["given_name"], "ALICE") + self.assertEqual(result["family_name"], "JOHNSON") + self.assertEqual(result["birth_date"], "2024-03-20") + self.assertEqual(result["sex"], "female") + + def test_name_as_string(self): + """Extract person when name is a plain string.""" + extract = self._get_function() + response = { + "name": "John Doe", + } + result = extract(response) + self.assertIsNotNone(result) + self.assertEqual(result["given_name"], "JOHN DOE") + self.assertNotIn("family_name", result) + + def test_no_person_data(self): + """Return None when no person data can be extracted.""" + extract = self._get_function() + response = {"something_else": True} + self.assertIsNone(extract(response)) + + def test_empty_search_response(self): + """Return None when search_response is empty.""" + extract = self._get_function() + response = { + "message": {"search_response": []}, + } + self.assertIsNone(extract(response)) + + def test_empty_data_in_search_response(self): + """Return None when data in search_response is empty.""" + extract = self._get_function() + response = { + "message": { + "search_response": [ + {"status": "succ", "data": []}, + ] + }, + } + self.assertIsNone(extract(response)) + + +@tagged("post_install", "-at_install") +class TestCheckDataMatches(TransactionCase): + """Tests for check_data_matches standalone function.""" + + def _get_function(self): + from odoo.addons.spp_dci_demo.utils.dci_verification import ( + check_data_matches, + ) + + return check_data_matches + + def test_all_fields_match(self): + """All fields matching returns (True, []).""" + check = self._get_function() + person_data = { + "given_name": "JOHN", + "family_name": "DOE", + "birth_date": "2024-01-15", + "sex": "male", + } + matches, mismatches = check( + person_data, + given_name="John", + family_name="Doe", + birthdate=date(2024, 1, 15), + gender_display="Male", + ) + self.assertTrue(matches) + self.assertEqual(mismatches, []) + + def test_given_name_mismatch(self): + """Given name mismatch returns (False, [mismatch info]).""" + check = self._get_function() + person_data = { + "given_name": "JOHN", + "family_name": "DOE", + } + matches, mismatches = check( + person_data, + given_name="Jane", + family_name="Doe", + birthdate=None, + gender_display=None, + ) + self.assertFalse(matches) + self.assertEqual(len(mismatches), 1) + self.assertIn("given_name", mismatches[0]) + + def test_family_name_mismatch(self): + """Family name mismatch returns (False, [mismatch info]).""" + check = self._get_function() + person_data = { + "given_name": "JOHN", + "family_name": "DOE", + } + matches, mismatches = check( + person_data, + given_name="John", + family_name="Smith", + birthdate=None, + gender_display=None, + ) + self.assertFalse(matches) + self.assertEqual(len(mismatches), 1) + self.assertIn("family_name", mismatches[0]) + + def test_birthdate_mismatch(self): + """Birthdate mismatch returns (False, [mismatch info]).""" + check = self._get_function() + person_data = { + "birth_date": "2024-01-15", + } + matches, mismatches = check( + person_data, + given_name=None, + family_name=None, + birthdate=date(2024, 6, 20), + gender_display=None, + ) + self.assertFalse(matches) + self.assertEqual(len(mismatches), 1) + self.assertIn("birthdate", mismatches[0]) + + def test_gender_mismatch(self): + """Gender mismatch returns (False, [mismatch info]).""" + check = self._get_function() + person_data = { + "sex": "male", + } + matches, mismatches = check( + person_data, + given_name=None, + family_name=None, + birthdate=None, + gender_display="Female", + ) + self.assertFalse(matches) + self.assertEqual(len(mismatches), 1) + self.assertIn("gender", mismatches[0]) + + def test_multiple_mismatches(self): + """Multiple mismatches returns all mismatch details.""" + check = self._get_function() + person_data = { + "given_name": "JOHN", + "family_name": "DOE", + "birth_date": "2024-01-15", + "sex": "male", + } + matches, mismatches = check( + person_data, + given_name="Jane", + family_name="Smith", + birthdate=date(2024, 6, 20), + gender_display="Female", + ) + self.assertFalse(matches) + self.assertEqual(len(mismatches), 4) + + def test_missing_dci_fields_still_match(self): + """When DCI data doesn't have a field, it's not considered a mismatch.""" + check = self._get_function() + person_data = { + "given_name": "JOHN", + } + matches, mismatches = check( + person_data, + given_name="John", + family_name="Doe", + birthdate=date(2024, 1, 15), + gender_display="Male", + ) + self.assertTrue(matches) + self.assertEqual(mismatches, []) + + def test_missing_cr_fields_still_match(self): + """When CR data is None/empty, those fields are not compared.""" + check = self._get_function() + person_data = { + "given_name": "JOHN", + "family_name": "DOE", + "birth_date": "2024-01-15", + "sex": "male", + } + matches, mismatches = check( + person_data, + given_name="John", + family_name="Doe", + birthdate=None, + gender_display=None, + ) + self.assertTrue(matches) + self.assertEqual(mismatches, []) + + def test_case_insensitive_name_comparison(self): + """Name comparison is case-insensitive.""" + check = self._get_function() + person_data = { + "given_name": "JOHN", + "family_name": "DOE", + } + matches, mismatches = check( + person_data, + given_name="john", + family_name="doe", + birthdate=None, + gender_display=None, + ) + self.assertTrue(matches) + self.assertEqual(mismatches, []) + + def test_whitespace_stripped_from_names(self): + """Leading/trailing whitespace is stripped before comparison.""" + check = self._get_function() + person_data = { + "given_name": "JOHN", + } + matches, mismatches = check( + person_data, + given_name=" John ", + family_name=None, + birthdate=None, + gender_display=None, + ) + self.assertTrue(matches) + self.assertEqual(mismatches, []) diff --git a/spp_dci_demo/utils/__init__.py b/spp_dci_demo/utils/__init__.py new file mode 100644 index 00000000..5b0c5427 --- /dev/null +++ b/spp_dci_demo/utils/__init__.py @@ -0,0 +1,3 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from . import dci_verification diff --git a/spp_dci_demo/utils/dci_verification.py b/spp_dci_demo/utils/dci_verification.py new file mode 100644 index 00000000..2357d7f0 --- /dev/null +++ b/spp_dci_demo/utils/dci_verification.py @@ -0,0 +1,166 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +"""Standalone DCI verification utility functions. + +These functions extract, parse, and compare DCI response data without +depending on Odoo models. Both the wizard and the detail model call +these functions instead of implementing their own logic. +""" + +import logging + +_logger = logging.getLogger(__name__) + + +def parse_dci_response(response): + """Parse DCI response to determine verification status. + + Args: + response: Dict response from DCI search + + Returns: + Verification status string: 'verified', 'not_found', or 'error' + """ + # Check for error status in header + if "header" in response: + header = response["header"] + if header.get("status") == "rjct": + return "error" + + # Check for search_response in message + if "message" in response: + message = response["message"] + if "search_response" in message: + search_responses = message["search_response"] + if search_responses: + # Check first response item + first_response = search_responses[0] + if first_response.get("status") == "succ": + # Check if we have data + data = first_response.get("data", []) + if data: + return "verified" + return "not_found" + elif first_response.get("status") == "rjct": + return "error" + return "not_found" + + # OpenCRVS direct response format (record directly in response) + if "identifier" in response or "name" in response: + return "verified" + + # Unexpected response format + _logger.warning( + "Unexpected DCI response structure, treating as not found. Keys: %s", + list(response.keys()) if isinstance(response, dict) else type(response), + ) + return "not_found" + + +def extract_person_from_dci_response(response): + """Extract normalized person data from DCI response. + + Args: + response: Dict response from DCI search + + Returns: + Dict with normalized person data or None if not found. + Keys: given_name, family_name, birth_date, sex (all uppercase names, + lowercase sex). + """ + person_data = None + + # Check for search_response in message (standard DCI format) + if "message" in response: + message = response["message"] + if "search_response" in message: + search_responses = message["search_response"] + if search_responses: + first_response = search_responses[0] + data = first_response.get("data", {}) + if data: + # Data can be a list or a dict depending on registry + if isinstance(data, list): + person_data = data[0] # First matching record + elif isinstance(data, dict): + person_data = data # Direct dict response + + # OpenCRVS direct response format + if not person_data and ("identifier" in response or "name" in response): + person_data = response + + if not person_data: + return None + + # Normalize the person data + normalized = {} + + # Extract name + if "name" in person_data: + name = person_data["name"] + if isinstance(name, dict): + normalized["given_name"] = name.get("given_name", "").strip().upper() + normalized["family_name"] = name.get("surname", "").strip().upper() + elif isinstance(name, str): + normalized["given_name"] = name.strip().upper() + + # Extract birth date + if "birth_date" in person_data: + normalized["birth_date"] = person_data["birth_date"] + elif "birthdate" in person_data: + normalized["birth_date"] = person_data["birthdate"] + + # Extract sex/gender + if "sex" in person_data: + normalized["sex"] = person_data["sex"].lower() + elif "gender" in person_data: + normalized["sex"] = person_data["gender"].lower() + + return normalized + + +def check_data_matches(person_data, given_name, family_name, birthdate, gender_display): + """Check if DCI data matches provided fields. + + Args: + person_data: Dict from extract_person_from_dci_response + given_name: Given name string from CR/wizard + family_name: Family name string from CR/wizard + birthdate: date object or None from CR/wizard + gender_display: Gender display string (e.g., "Male") or None + + Returns: + Tuple of (matches: bool, mismatches: list[str]) + """ + mismatches = [] + + # Compare given name + if person_data.get("given_name"): + cr_given_name = (given_name or "").strip().upper() + dci_given_name = person_data["given_name"] + if cr_given_name != dci_given_name: + mismatches.append(f"given_name: CR='{cr_given_name}' vs DCI='{dci_given_name}'") + + # Compare family name + if person_data.get("family_name"): + cr_family_name = (family_name or "").strip().upper() + dci_family_name = person_data["family_name"] + if cr_family_name != dci_family_name: + mismatches.append(f"family_name: CR='{cr_family_name}' vs DCI='{dci_family_name}'") + + # Compare birth date + if person_data.get("birth_date") and birthdate: + cr_birthdate = str(birthdate) + dci_birthdate = person_data["birth_date"] + # Handle different date formats (YYYY-MM-DD) + if cr_birthdate[:10] != dci_birthdate[:10]: + mismatches.append(f"birthdate: CR='{cr_birthdate}' vs DCI='{dci_birthdate}'") + + # Compare gender/sex + if person_data.get("sex") and gender_display: + cr_gender = gender_display.lower() + dci_sex = person_data["sex"].lower() + if cr_gender != dci_sex: + mismatches.append(f"gender: CR='{cr_gender}' vs DCI='{dci_sex}'") + + return (len(mismatches) == 0, mismatches) diff --git a/spp_dci_demo/views/add_child_wizard_view.xml b/spp_dci_demo/views/add_child_wizard_view.xml new file mode 100644 index 00000000..d15b8e45 --- /dev/null +++ b/spp_dci_demo/views/add_child_wizard_view.xml @@ -0,0 +1,209 @@ + + + + + spp.dci.demo.add.child.wizard.form + spp.dci.demo.add.child.wizard + +
+
+ +
+ +
+

Add Child to Household

+
+ + + + + + +
+ + + + + + +
+ +
+ + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + +
+ + + +
+ + + + + + + + + +
+
+
+
+
+
+ + + + Add Child (DCI Demo) + spp.dci.demo.add.child.wizard + form + + current + + + + +
diff --git a/spp_dci_demo/views/change_request_view.xml b/spp_dci_demo/views/change_request_view.xml new file mode 100644 index 00000000..fee8edc6 --- /dev/null +++ b/spp_dci_demo/views/change_request_view.xml @@ -0,0 +1,35 @@ + + + + + spp.change.request.form.dci + spp.change.request + + + + +
+
+ +
+
+ DCI Birth Verification +
+ +
+
+
+ + + +
+
+
+
diff --git a/spp_dci_demo/views/cr_detail_add_member_view.xml b/spp_dci_demo/views/cr_detail_add_member_view.xml new file mode 100644 index 00000000..0ac90016 --- /dev/null +++ b/spp_dci_demo/views/cr_detail_add_member_view.xml @@ -0,0 +1,67 @@ + + + + + spp.cr.detail.add_member.form.dci + spp.cr.detail.add_member + + + + + + + + + + + + + + +
+
+
+
+
+
+
diff --git a/spp_dci_demo/wizards/__init__.py b/spp_dci_demo/wizards/__init__.py new file mode 100644 index 00000000..ed1ac9eb --- /dev/null +++ b/spp_dci_demo/wizards/__init__.py @@ -0,0 +1,3 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from . import add_child_wizard diff --git a/spp_dci_demo/wizards/add_child_wizard.py b/spp_dci_demo/wizards/add_child_wizard.py new file mode 100644 index 00000000..a693d313 --- /dev/null +++ b/spp_dci_demo/wizards/add_child_wizard.py @@ -0,0 +1,586 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +"""Multi-step wizard for adding a child to a household with DCI birth verification. + +Wizard flow (3 steps): + 1. Select Household - search/select group, optional applicant + 2. Child Information - enter child details + BRN, verify birth + 3. Review & Submit - see summary, create + auto-submit CR +""" + +import json +import logging + +from markupsafe import Markup, escape + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +from ..utils.dci_verification import ( + check_data_matches, + extract_person_from_dci_response, + parse_dci_response, +) + +_logger = logging.getLogger(__name__) + +STAGE_ORDER = ["registrant", "details", "review"] + + +class SPPDCIDemoAddChildWizard(models.TransientModel): + """Multi-step wizard for creating an Add Child CR with DCI birth verification.""" + + _name = "spp.dci.demo.add.child.wizard" + _description = "Add Child Wizard (DCI Demo)" + + stage = fields.Selection( + [ + ("registrant", "Select Household"), + ("details", "Child Information"), + ("review", "Review & Submit"), + ], + default="registrant", + required=True, + ) + + # ================== + # Step 1 - Household + # ================== + request_type_id = fields.Many2one( + "spp.change.request.type", + string="Request Type", + readonly=True, + ) + + registrant_id = fields.Many2one( + "res.partner", + string="Household", + domain="[('is_registrant', '=', True), ('is_group', '=', True)]", + ) + + registrant_info_html = fields.Html( + compute="_compute_registrant_info_html", + string="Household Info", + ) + + applicant_id = fields.Many2one( + "res.partner", + string="Applicant", + help="Person requesting the change (optional)", + ) + + applicant_phone = fields.Char( + string="Applicant Phone", + ) + + # ================== + # Step 2 - Child Details + # ================== + given_name = fields.Char(string="Given Name") + family_name = fields.Char(string="Family Name") + member_name = fields.Char( + string="Full Name", + compute="_compute_member_name", + store=True, + ) + birthdate = fields.Date(string="Date of Birth") + gender_id = fields.Many2one( + "spp.vocabulary.code", + string="Gender", + domain="[('namespace_uri', '=', 'urn:iso:std:iso:5218')]", + ) + relationship_id = fields.Many2one( + "spp.vocabulary.code", + string="Relationship to Head", + domain="[('vocabulary_id.namespace_uri', '=', " + "'urn:openspp:vocab:group-membership-type'), " + "('code', '!=', 'head')]", + ) + + # Birth Verification + birth_registration_number = fields.Char(string="Birth Registration Number (BRN)") + dci_data_source_id = fields.Many2one( + "spp.dci.data.source", + string="DCI Data Source", + domain="[('registry_type', '=', 'ns:org:RegistryType:Civil'), ('active', '=', True)]", + ) + birth_verification_status = fields.Selection( + [ + ("unverified", "Unverified"), + ("verified", "Verified"), + ("not_found", "Not Found"), + ("error", "Error"), + ], + default="unverified", + string="Verification Status", + ) + birth_verification_date = fields.Datetime( + string="Verification Date", + readonly=True, + ) + birth_verification_response = fields.Text( + string="Verification Response", + readonly=True, + ) + dci_data_match = fields.Boolean( + string="DCI Data Matches", + readonly=True, + ) + + # ================== + # Step 3 - Review + # ================== + preview_html = fields.Html( + compute="_compute_preview_html", + string="Summary", + ) + + # ================== + # Default Values + # ================== + + @api.model + def default_get(self, fields_list): + """Pre-fill request_type_id and registrant from context.""" + res = super().default_get(fields_list) + + # Always pre-set to add_member type + if "request_type_id" in fields_list: + request_type = self.env["spp.change.request.type"].search([("code", "=", "add_member")], limit=1) + if request_type: + res["request_type_id"] = request_type.id + + # Pre-fill registrant from context + if "registrant_id" in fields_list: + if self.env.context.get("active_model") == "res.partner": + active_id = self.env.context.get("active_id") + if active_id: + partner = self.env["res.partner"].browse(active_id) + if partner.exists() and partner.is_registrant and partner.is_group: + res["registrant_id"] = partner.id + + return res + + # ================== + # Computed Fields + # ================== + + @api.depends("given_name", "family_name") + def _compute_member_name(self): + for rec in self: + if rec.given_name or rec.family_name: + name_vals = [ + f"{rec.family_name}," + if rec.family_name and rec.given_name + else f"{rec.family_name}" + if rec.family_name + else "", + rec.given_name, + ] + rec.member_name = " ".join(filter(None, name_vals)).upper() + else: + rec.member_name = False + + @api.depends("registrant_id") + def _compute_registrant_info_html(self): + for rec in self: + if rec.registrant_id: + reg = rec.registrant_id + info_parts = [] + + # Name with ID + primary_id = "" + if hasattr(reg, "reg_ids") and reg.reg_ids: + first_id = reg.reg_ids[0] + if first_id.value: + primary_id = first_id.value + + if primary_id: + name_part = Markup("{} ({})").format( + escape(reg.name or "Unknown"), escape(primary_id) + ) + else: + name_part = Markup("{}").format(escape(reg.name or "Unknown")) + info_parts.append(name_part) + + # Member count + member_count = len(reg.group_membership_ids) if hasattr(reg, "group_membership_ids") else 0 + info_parts.append( + Markup("{} members").format( + member_count + ) + ) + + # Address + if reg.street: + addr = escape(reg.street) + if reg.city: + addr = Markup("{}, {}").format(escape(reg.street), escape(reg.city)) + info_parts.append( + Markup("{}").format( + addr + ) + ) + + rec.registrant_info_html = Markup(" ").join(info_parts) + else: + rec.registrant_info_html = "" + + @api.depends( + "registrant_id", + "given_name", + "family_name", + "birthdate", + "gender_id", + "relationship_id", + "birth_registration_number", + "birth_verification_status", + "dci_data_match", + "applicant_id", + ) + def _compute_preview_html(self): + for rec in self: + if not rec.registrant_id: + rec.preview_html = "" + continue + + rows = [] + + # Household + rows.append( + Markup("{}{}").format( + escape("Household"), + escape(rec.registrant_id.name or ""), + ) + ) + + # Child name + rows.append( + Markup("{}{}").format( + escape("Child Name"), + escape(rec.member_name or ""), + ) + ) + + # Birthdate + if rec.birthdate: + rows.append( + Markup("{}{}").format( + escape("Date of Birth"), + escape(str(rec.birthdate)), + ) + ) + + # Gender + if rec.gender_id: + rows.append( + Markup("{}{}").format( + escape("Gender"), + escape(rec.gender_id.display or rec.gender_id.code or ""), + ) + ) + + # Relationship + if rec.relationship_id: + rows.append( + Markup("{}{}").format( + escape("Relationship"), + escape(rec.relationship_id.display or rec.relationship_id.code or ""), + ) + ) + + # BRN & Verification + if rec.birth_registration_number: + rows.append( + Markup("{}{}").format( + escape("BRN"), + escape(rec.birth_registration_number), + ) + ) + + status_label = dict(rec._fields["birth_verification_status"].selection).get( + rec.birth_verification_status, "" + ) + badge_class = { + "verified": "bg-success", + "not_found": "bg-warning", + "error": "bg-danger", + "unverified": "bg-secondary", + }.get(rec.birth_verification_status, "bg-secondary") + + rows.append( + Markup('{}{}').format( + escape("Verification Status"), + badge_class, + escape(status_label), + ) + ) + + if rec.birth_verification_status == "verified": + match_text = "Yes" if rec.dci_data_match else "No" + match_class = "text-success" if rec.dci_data_match else "text-danger" + rows.append( + Markup('{}{}').format( + escape("Data Matches"), + match_class, + escape(match_text), + ) + ) + + # Applicant + if rec.applicant_id: + rows.append( + Markup("{}{}").format( + escape("Applicant"), + escape(rec.applicant_id.name or ""), + ) + ) + + table = Markup('{}
').format( + Markup("").join(rows) + ) + + rec.preview_html = table + + # ================== + # Navigation + # ================== + + def action_next(self): + """Validate current step and advance to the next stage.""" + self.ensure_one() + self._validate_current_step() + + current_index = STAGE_ORDER.index(self.stage) + if current_index < len(STAGE_ORDER) - 1: + self.stage = STAGE_ORDER[current_index + 1] + + return self._return_wizard_action() + + def action_previous(self): + """Go back one step.""" + self.ensure_one() + + current_index = STAGE_ORDER.index(self.stage) + if current_index > 0: + self.stage = STAGE_ORDER[current_index - 1] + + return self._return_wizard_action() + + def _validate_current_step(self): + """Validate fields for the current step before advancing.""" + if self.stage == "registrant": + if not self.registrant_id: + raise UserError(_("Please select a household before continuing.")) + elif self.stage == "details": + if not self.given_name: + raise UserError(_("Please enter the child's given name.")) + if not self.birthdate: + raise UserError(_("Please enter the child's date of birth.")) + + def _return_wizard_action(self): + """Return action dict to redisplay the same wizard record.""" + return { + "type": "ir.actions.act_window", + "name": "Add Child (DCI Demo)", + "res_model": self._name, + "res_id": self.id, + "view_mode": "form", + "target": "current", + } + + # ================== + # Birth Verification + # ================== + + def action_verify_birth(self): + """Verify birth registration via DCI query to CRVS registry.""" + self.ensure_one() + + if not self.birth_registration_number: + raise UserError(_("Please enter the Birth Registration Number (BRN) before verifying.")) + + # Get the DCI data source + data_source = self.dci_data_source_id or self._get_default_dci_data_source() + if not data_source: + raise UserError( + _( + "No DCI data source configured for birth verification. " + "Please configure a CRVS data source or contact your administrator." + ) + ) + + from odoo.addons.spp_dci_client.services.client import DCIClient + + try: + client = DCIClient(data_source, self.env) + response = client.search_by_id_opencrvs( + identifier_type="BRN", + identifier_value=self.birth_registration_number, + event_type="birth", + ) + + response_json = json.dumps(response, indent=2, default=str) + + # Parse response using shared utility + verification_status = parse_dci_response(response) + + # Check data match + data_matches = False + if verification_status == "verified": + person_data = extract_person_from_dci_response(response) + if person_data: + gender_display = (self.gender_id.display or "") if self.gender_id else "" + data_matches, mismatches = check_data_matches( + person_data, + given_name=self.given_name, + family_name=self.family_name, + birthdate=self.birthdate, + gender_display=gender_display, + ) + if mismatches: + _logger.info( + "Wizard DCI data mismatch for BRN %s: %s", + self.birth_registration_number, + "; ".join(mismatches), + ) + + self.write( + { + "birth_verification_status": verification_status, + "birth_verification_date": fields.Datetime.now(), + "birth_verification_response": response_json, + "dci_data_match": data_matches, + } + ) + + _logger.info( + "Wizard birth verification for BRN %s: status=%s, data_match=%s", + self.birth_registration_number, + verification_status, + data_matches, + ) + + return self._return_wizard_action() + + except UserError: + raise + except Exception as e: + _logger.exception( + "Wizard birth verification failed for BRN %s", + self.birth_registration_number, + ) + self.write( + { + "birth_verification_status": "error", + "birth_verification_date": fields.Datetime.now(), + "birth_verification_response": str(e), + } + ) + raise UserError(self.env._("Birth verification failed: %s") % str(e)) from e + + def _get_default_dci_data_source(self): + """Get the default DCI data source for birth verification.""" + param_value = self.env["ir.config_parameter"].sudo().get_param("spp_dci_demo.default_crvs_data_source") + if param_value: + try: + data_source = self.env["spp.dci.data.source"].browse(int(param_value)) + if data_source.exists() and data_source.active: + return data_source + except (ValueError, TypeError): + pass + + return self.env["spp.dci.data.source"].search( + [ + ("registry_type", "=", "ns:org:RegistryType:Civil"), + ("active", "=", True), + ], + limit=1, + ) + + # ================== + # Create & Submit + # ================== + + def action_create_and_submit(self): + """Create the CR, populate detail fields, and submit for approval. + + Flow: + 1. spp.change.request.create() -> creates CR + empty detail + 2. cr.get_detail().write() -> populate detail with wizard values + 3. cr.action_submit_for_approval() -> submit + + If submit fails, the entire transaction rolls back. + """ + self.ensure_one() + + try: + # Step 1: Create the change request + cr_vals = { + "request_type_id": self.request_type_id.id, + "registrant_id": self.registrant_id.id, + "source_type": "manual", + } + if self.applicant_id: + cr_vals["applicant_id"] = self.applicant_id.id + if self.applicant_phone: + cr_vals["applicant_phone"] = self.applicant_phone + + cr = self.env["spp.change.request"].create(cr_vals) + + # Step 2: Populate the detail record + detail = cr.get_detail() + if detail: + detail_vals = { + "given_name": self.given_name, + "family_name": self.family_name, + "member_name": self.member_name, + "birthdate": self.birthdate, + } + if self.gender_id: + detail_vals["gender_id"] = self.gender_id.id + if self.relationship_id: + detail_vals["relationship_id"] = self.relationship_id.id + if self.birth_registration_number: + detail_vals["birth_registration_number"] = self.birth_registration_number + if self.dci_data_source_id: + detail_vals["dci_data_source_id"] = self.dci_data_source_id.id + if self.birth_verification_status != "unverified": + detail_vals["birth_verification_status"] = self.birth_verification_status + if self.birth_verification_date: + detail_vals["birth_verification_date"] = self.birth_verification_date + if self.birth_verification_response: + detail_vals["birth_verification_response"] = self.birth_verification_response + if self.dci_data_match: + detail_vals["dci_data_match"] = self.dci_data_match + + detail.write(detail_vals) + + # Step 3: Submit for approval + cr.action_submit_for_approval() + + _logger.info( + "Wizard created and submitted CR %s for household %s", + cr.name, + self.registrant_id.name, + ) + + # Return action to open the CR form + cr_id = cr.id + return { + "type": "ir.actions.act_window", + "name": "Change Request", + "res_model": "spp.change.request", + "res_id": cr_id, + "view_mode": "form", + "target": "current", + "context": { + "form_view_initial_mode": "readonly", + }, + } + + except (UserError, ValueError): + raise + except Exception as e: + _logger.exception("Wizard create and submit failed") + raise UserError(f"Failed to create change request: {e}") from e diff --git a/spp_registry/models/reg_id.py b/spp_registry/models/reg_id.py index aec3d031..bfd1c41e 100644 --- a/spp_registry/models/reg_id.py +++ b/spp_registry/models/reg_id.py @@ -38,6 +38,40 @@ class SPPRegistrantID(models.Model): description = fields.Char() + # Verification fields + verification_method = fields.Selection( + selection=[ + ("dci_api", "DCI API Verification"), + ("physical_document", "Physical Document"), + ("scanned", "Scanned Document"), + ("verbal", "Verbal (Unverified)"), + ("self_declared", "Self Declared"), + ("manual_lookup", "Manual Lookup"), + ("biometric", "Biometric Match"), + ], + string="Verification Method", + help="How this ID was verified", + ) + is_verified = fields.Boolean( + string="Verified", + default=False, + compute="_compute_is_verified", + store=True, + help="Whether this ID has been verified", + ) + verification_date = fields.Datetime( + string="Verification Date", + help="When the ID was verified", + ) + verification_source = fields.Char( + string="Verification Source", + help="System/person that verified this ID (e.g., 'OpenCRVS', 'Staff: John')", + ) + verification_response = fields.Text( + string="Verification Response", + help="Raw response or notes from verification", + ) + _unique_partner_id_type = models.Constraint( "UNIQUE(partner_id, id_type_id)", "A registrant cannot have duplicate ID types", @@ -69,6 +103,13 @@ def _name_search(self, name, domain=None, operator="ilike", limit=100, order=Non domain = [("partner_id", operator, name)] + domain return self._search(domain, limit=limit, order=order) + @api.depends("verification_method") + def _compute_is_verified(self): + """Compute is_verified based on method - verbal/self_declared are not verified.""" + unverified_methods = {"verbal", "self_declared", False} + for record in self: + record.is_verified = record.verification_method not in unverified_methods + @api.constrains("value") @api.onchange("value") def _onchange_id_validation(self): diff --git a/spp_registry/views/reg_id_view.xml b/spp_registry/views/reg_id_view.xml index 896539cb..40a7902d 100644 --- a/spp_registry/views/reg_id_view.xml +++ b/spp_registry/views/reg_id_view.xml @@ -3,6 +3,48 @@ Part of OpenSPP. See LICENSE file for full copyright and licensing details. --> + + spp.registry.id.form + spp.registry.id + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ view_reg_id_tree spp.registry.id @@ -19,6 +61,8 @@ /> + + @@ -51,7 +95,7 @@ Registrant IDs ir.actions.act_window spp.registry.id - list + list,form {} [] From e09ac76d12eaf3576f34d4a7eb76f9e8dc2f6d17 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 7 Feb 2026 11:01:13 +0700 Subject: [PATCH 11/20] fix(dci-demo): handle reg_records in OpenCRVS SPDCI response format --- spp_dci_demo/utils/dci_verification.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/spp_dci_demo/utils/dci_verification.py b/spp_dci_demo/utils/dci_verification.py index 2357d7f0..db708ae8 100644 --- a/spp_dci_demo/utils/dci_verification.py +++ b/spp_dci_demo/utils/dci_verification.py @@ -83,7 +83,11 @@ def extract_person_from_dci_response(response): if isinstance(data, list): person_data = data[0] # First matching record elif isinstance(data, dict): - person_data = data # Direct dict response + # Check for reg_records (OpenCRVS SPDCI format) + if "reg_records" in data and data["reg_records"]: + person_data = data["reg_records"][0] + else: + person_data = data # Direct dict response # OpenCRVS direct response format if not person_data and ("identifier" in response or "name" in response): From 65e5c98e5a54c2980e98d8beafec23a419d29fb9 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 7 Feb 2026 11:08:02 +0700 Subject: [PATCH 12/20] fix(dci-demo): add auto-approve to wizard flow --- spp_dci_demo/wizards/add_child_wizard.py | 33 ++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/spp_dci_demo/wizards/add_child_wizard.py b/spp_dci_demo/wizards/add_child_wizard.py index a693d313..68ba29d0 100644 --- a/spp_dci_demo/wizards/add_child_wizard.py +++ b/spp_dci_demo/wizards/add_child_wizard.py @@ -565,6 +565,10 @@ def action_create_and_submit(self): self.registrant_id.name, ) + # Step 4: Auto-approve if verified and data matches + if self.birth_verification_status == "verified" and self.dci_data_match: + self._try_auto_approve_cr(cr) + # Return action to open the CR form cr_id = cr.id return { @@ -584,3 +588,32 @@ def action_create_and_submit(self): except Exception as e: _logger.exception("Wizard create and submit failed") raise UserError(f"Failed to create change request: {e}") from e + + def _try_auto_approve_cr(self, cr): + """Try to auto-approve the change request if enabled. + + Args: + cr: The change request to approve + """ + # Check system parameter + auto_approve_enabled = ( + self.env["ir.config_parameter"].sudo().get_param("spp_dci_demo.auto_approve_on_match", "False") + ) + if auto_approve_enabled.lower() not in ("true", "1", "yes"): + _logger.info("Auto-approval disabled by system parameter") + return + + # Check if CR can be approved (must be pending/under review) + if cr.display_state != "pending": + _logger.info( + "Change request %s is in state '%s', cannot auto-approve", + cr.name, + cr.display_state, + ) + return + + try: + cr.action_approve(comment="Auto-approved: DCI birth verification matched") + _logger.info("Auto-approved change request %s due to DCI data match", cr.name) + except Exception as e: + _logger.warning("Failed to auto-approve change request %s: %s", cr.name, str(e)) From 64b4d6e063fe39b08d398d6a5c38f9fb6090383e Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 8 Feb 2026 11:29:59 +0700 Subject: [PATCH 13/20] fix(dci-demo): hide data source selector when only one CRVS registry exists Add computed single_dci_data_source field to auto-hide the DCI data source dropdown when there is zero or one active Civil registry, removing an unnecessary selection step from the UI. --- spp_dci_demo/models/cr_detail_add_member.py | 11 +++++++++++ spp_dci_demo/views/add_child_wizard_view.xml | 2 ++ spp_dci_demo/views/cr_detail_add_member_view.xml | 2 ++ spp_dci_demo/wizards/add_child_wizard.py | 11 +++++++++++ 4 files changed, 26 insertions(+) diff --git a/spp_dci_demo/models/cr_detail_add_member.py b/spp_dci_demo/models/cr_detail_add_member.py index 74f93d71..9ab5a506 100644 --- a/spp_dci_demo/models/cr_detail_add_member.py +++ b/spp_dci_demo/models/cr_detail_add_member.py @@ -69,6 +69,17 @@ class SPPCRDetailAddMemberDCI(models.Model): domain="[('registry_type', '=', 'ns:org:RegistryType:Civil'), ('active', '=', True)]", help="DCI data source (CRVS registry) to use for birth verification", ) + single_dci_data_source = fields.Boolean( + compute="_compute_single_dci_data_source", + ) + + def _compute_single_dci_data_source(self): + count = self.env["spp.dci.data.source"].search_count( + [("registry_type", "=", "ns:org:RegistryType:Civil"), ("active", "=", True)], + ) + is_single = count <= 1 + for rec in self: + rec.single_dci_data_source = is_single @api.onchange("birth_registration_number") def _onchange_birth_registration_number(self): diff --git a/spp_dci_demo/views/add_child_wizard_view.xml b/spp_dci_demo/views/add_child_wizard_view.xml index d15b8e45..a29c10e2 100644 --- a/spp_dci_demo/views/add_child_wizard_view.xml +++ b/spp_dci_demo/views/add_child_wizard_view.xml @@ -91,10 +91,12 @@ + + Date: Mon, 9 Feb 2026 10:44:21 +0700 Subject: [PATCH 14/20] refactor(spp_dci_demo): remove wizard, consolidate auto-approval in _after_submit The DCI demo wizard adds no value over the standard CR flow now that detail forms have Submit buttons. Remove the wizard and its tests, and remove the duplicated _try_auto_approve() from the detail model. Auto- approval now only happens via the _after_submit() hook on the CR model. --- spp_dci_demo/__init__.py | 1 - spp_dci_demo/__manifest__.py | 7 +- spp_dci_demo/models/cr_detail_add_member.py | 80 +-- spp_dci_demo/security/ir.model.access.csv | 1 - spp_dci_demo/tests/__init__.py | 1 - spp_dci_demo/tests/test_add_child_wizard.py | 512 --------------- spp_dci_demo/views/add_child_wizard_view.xml | 211 ------- spp_dci_demo/wizards/__init__.py | 3 - spp_dci_demo/wizards/add_child_wizard.py | 630 ------------------- 9 files changed, 26 insertions(+), 1420 deletions(-) delete mode 100644 spp_dci_demo/tests/test_add_child_wizard.py delete mode 100644 spp_dci_demo/views/add_child_wizard_view.xml delete mode 100644 spp_dci_demo/wizards/__init__.py delete mode 100644 spp_dci_demo/wizards/add_child_wizard.py diff --git a/spp_dci_demo/__init__.py b/spp_dci_demo/__init__.py index 30036870..bd701f00 100644 --- a/spp_dci_demo/__init__.py +++ b/spp_dci_demo/__init__.py @@ -2,5 +2,4 @@ from . import models from . import utils -from . import wizards from .hooks import post_init_hook diff --git a/spp_dci_demo/__manifest__.py b/spp_dci_demo/__manifest__.py index 5698fadf..f02114b6 100644 --- a/spp_dci_demo/__manifest__.py +++ b/spp_dci_demo/__manifest__.py @@ -18,7 +18,6 @@ "data/dci_data_source.xml", "data/system_parameters.xml", "views/cr_detail_add_member_view.xml", - "views/add_child_wizard_view.xml", "views/change_request_view.xml", ], "demo": [], @@ -40,9 +39,9 @@ They have the Birth Registration Number (BRN) from OpenCRVS. The social worker: 1. Creates an "Add Child" change request on the household -2. Enters child details (name, DOB, gender) + BRN +2. Enters child details (name, DOB, gender) + BRN on the detail form 3. Clicks "Verify Birth" -> DCI query to OpenCRVS -> birth verified -4. CR approved -> child added to household with verified BRN identity document -5. Registry ID shows verification_method=dci_api, is_verified=True +4. Clicks "Submit" -> CR auto-approved if verification matched +5. Child added to household with verified BRN identity document """, } diff --git a/spp_dci_demo/models/cr_detail_add_member.py b/spp_dci_demo/models/cr_detail_add_member.py index 9ab5a506..982e116b 100644 --- a/spp_dci_demo/models/cr_detail_add_member.py +++ b/spp_dci_demo/models/cr_detail_add_member.py @@ -89,6 +89,28 @@ def _onchange_birth_registration_number(self): if stripped != self.birth_registration_number: self.birth_registration_number = stripped + @api.onchange("given_name", "family_name", "birthdate", "gender_id", "birth_registration_number") + def _onchange_invalidate_verification(self): + """Reset verification status when verified fields are edited. + + This is a security control: if the user changes name, DOB, gender, or BRN + after verification, the verification is no longer valid and must be re-done. + """ + if self.birth_verification_status == "verified": + self.birth_verification_status = "unverified" + self.dci_data_match = False + self.birth_verification_date = False + self.birth_verification_response = False + return { + "warning": { + "title": _("Verification Invalidated"), + "message": _( + "Verification has been reset because you modified verified data. " + "Please verify again after making changes." + ), + } + } + @api.model def _get_default_dci_data_source(self): """Get the default DCI data source for birth verification. @@ -180,16 +202,9 @@ def action_verify_birth(self): data_matches, ) - # Auto-approve if verified and data matches - auto_approved = False - if verification_status == "verified" and data_matches: - auto_approved = self._try_auto_approve() - # Return notification if verification_status == "verified": - if auto_approved: - message = _("Birth registration verified and CR auto-approved!") - elif data_matches: + if data_matches: message = _("Birth registration verified and data matches!") else: message = _("Birth registration verified (data mismatch - manual review required).") @@ -289,52 +304,3 @@ def _check_data_matches_dci_response(self, response): ) return matches - - def _try_auto_approve(self): - """Try to auto-approve the change request. - - Only auto-approves if: - - System parameter spp_dci_demo.auto_approve_on_match is True - - The change request is in a state that can be approved - - Returns: - Boolean indicating if auto-approval was successful - """ - # Check system parameter - auto_approve_enabled = ( - self.env["ir.config_parameter"].sudo().get_param("spp_dci_demo.auto_approve_on_match", "False") - ) - if auto_approve_enabled.lower() not in ("true", "1", "yes"): - _logger.info("Auto-approval disabled by system parameter") - return False - - # Get the change request - cr = self.change_request_id - if not cr: - _logger.warning("No change request linked to detail, cannot auto-approve") - return False - - # Check if CR can be approved (must be in pending state) - if cr.display_state != "pending": - _logger.info( - "Change request %s is in state '%s', cannot auto-approve", - cr.name, - cr.display_state, - ) - return False - - try: - # Auto-approve with comment - cr.action_approve(comment=_("Auto-approved: DCI birth verification matched")) - _logger.info( - "Auto-approved change request %s due to DCI data match", - cr.name, - ) - return True - except Exception as e: - _logger.warning( - "Failed to auto-approve change request %s: %s", - cr.name, - str(e), - ) - return False diff --git a/spp_dci_demo/security/ir.model.access.csv b/spp_dci_demo/security/ir.model.access.csv index a41b54ab..97dd8b91 100644 --- a/spp_dci_demo/security/ir.model.access.csv +++ b/spp_dci_demo/security/ir.model.access.csv @@ -1,2 +1 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink -access_spp_dci_demo_add_child_wizard,access.spp.dci.demo.add.child.wizard,model_spp_dci_demo_add_child_wizard,base.group_user,1,1,1,1 diff --git a/spp_dci_demo/tests/__init__.py b/spp_dci_demo/tests/__init__.py index d29380d2..9634f3e6 100644 --- a/spp_dci_demo/tests/__init__.py +++ b/spp_dci_demo/tests/__init__.py @@ -3,4 +3,3 @@ from . import test_birth_verification from . import test_apply_creates_brn from . import test_dci_verification_utils -from . import test_add_child_wizard diff --git a/spp_dci_demo/tests/test_add_child_wizard.py b/spp_dci_demo/tests/test_add_child_wizard.py deleted file mode 100644 index 96ee65fc..00000000 --- a/spp_dci_demo/tests/test_add_child_wizard.py +++ /dev/null @@ -1,512 +0,0 @@ -# Part of OpenSPP. See LICENSE file for full copyright and licensing details. - -"""Tests for the multi-step Add Child wizard.""" - -from unittest.mock import MagicMock, patch - -from odoo.exceptions import UserError -from odoo.tests import TransactionCase, tagged - - -@tagged("post_install", "-at_install") -class TestAddChildWizard(TransactionCase): - """Test the multi-step Add Child wizard.""" - - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.Wizard = cls.env["spp.dci.demo.add.child.wizard"] - - # Create test data source - cls.data_source = cls.env["spp.dci.data.source"].create( - { - "name": "Test CRVS", - "code": "test_crvs_wizard", - "base_url": "https://crvs.example.org/api", - "auth_type": "none", - "our_sender_id": "openspp.test", - "registry_type": "ns:org:RegistryType:Civil", - "active": True, - } - ) - - # Create test household (group) - cls.test_group = cls.env["res.partner"].create( - { - "name": "Test Wizard Household", - "is_registrant": True, - "is_group": True, - } - ) - - # Get or create the add_member CR type - cls.request_type = cls.env["spp.change.request.type"].search([("code", "=", "add_member")], limit=1) - if not cls.request_type: - cls.request_type = cls.env["spp.change.request.type"].create( - { - "name": "Add Member", - "code": "add_member", - "detail_model": "spp.cr.detail.add_member", - "strategy_model": "spp.cr.apply.add_member", - "target_type": "group", - } - ) - - # Create an approver user - cls.approver = cls.env["res.users"].create( - { - "name": "Test Wizard Approver", - "login": "test_wizard_approver", - "email": "wizard_approver@test.com", - } - ) - - # Get spp.change.request ir.model record - cls.cr_model_record = cls.env["ir.model"].search([("model", "=", "spp.change.request")], limit=1) - - # Create approval definition - cls.approval_def = cls.env["spp.approval.definition"].create( - { - "name": "Test Wizard Approval", - "model_id": cls.cr_model_record.id, - "approval_type": "user", - "approval_user_ids": [(6, 0, [cls.approver.id])], - } - ) - - # Link approval definition to CR type - cls.request_type.approval_definition_id = cls.approval_def - - # Get gender vocabulary code (loaded via spp_vocabulary data) - cls.gender_male = cls.env.ref("spp_vocabulary.code_gender_male", raise_if_not_found=False) - if not cls.gender_male: - cls.gender_male = cls.env["spp.vocabulary.code"].search( - [ - ("namespace_uri", "=", "urn:iso:std:iso:5218"), - ("code", "=", "1"), - ], - limit=1, - ) - - # Get relationship vocabulary code (use "head" since "child" is not in data) - cls.relationship_head = cls.env.ref( - "spp_vocabulary.code_membership_type_head", - raise_if_not_found=False, - ) - if not cls.relationship_head: - cls.relationship_head = cls.env["spp.vocabulary.code"].search( - [ - ( - "vocabulary_id.namespace_uri", - "=", - "urn:openspp:vocab:group-membership-type", - ), - ], - limit=1, - ) - - def _create_wizard(self, **kwargs): - """Create a wizard with sensible defaults.""" - vals = { - "registrant_id": self.test_group.id, - } - vals.update(kwargs) - return self.Wizard.create(vals) - - # ================== - # Default Get Tests - # ================== - - def test_default_type_is_add_member(self): - """default_get sets request_type_id to the add_member type.""" - wizard = self.Wizard.create({}) - self.assertEqual(wizard.request_type_id, self.request_type) - - def test_context_prefill_registrant(self): - """Registrant is pre-filled from active_id context.""" - wizard = self.Wizard.with_context( - active_model="res.partner", - active_id=self.test_group.id, - ).create({}) - self.assertEqual(wizard.registrant_id, self.test_group) - - # ================== - # Step Navigation - # ================== - - def test_initial_stage_is_registrant(self): - """Wizard starts at 'registrant' stage.""" - wizard = self._create_wizard() - self.assertEqual(wizard.stage, "registrant") - - def test_navigate_forward_to_details(self): - """action_next advances from 'registrant' to 'details'.""" - wizard = self._create_wizard() - wizard.action_next() - self.assertEqual(wizard.stage, "details") - - def test_navigate_forward_to_review(self): - """action_next advances from 'details' to 'review'.""" - wizard = self._create_wizard( - given_name="John", - family_name="Doe", - birthdate="2024-01-15", - ) - wizard.stage = "details" - wizard.action_next() - self.assertEqual(wizard.stage, "review") - - def test_navigate_backward_from_details(self): - """action_previous goes from 'details' to 'registrant'.""" - wizard = self._create_wizard() - wizard.stage = "details" - wizard.action_previous() - self.assertEqual(wizard.stage, "registrant") - - def test_navigate_backward_from_review(self): - """action_previous goes from 'review' to 'details'.""" - wizard = self._create_wizard() - wizard.stage = "review" - wizard.action_previous() - self.assertEqual(wizard.stage, "details") - - def test_navigate_backward_from_registrant_stays(self): - """action_previous on first step stays at 'registrant'.""" - wizard = self._create_wizard() - wizard.action_previous() - self.assertEqual(wizard.stage, "registrant") - - # ================== - # Per-Step Validation - # ================== - - def test_step1_requires_registrant(self): - """Cannot advance past step 1 without a registrant.""" - wizard = self.Wizard.create({}) - with self.assertRaises(UserError): - wizard.action_next() - - def test_step2_requires_given_name(self): - """Cannot advance past step 2 without given_name.""" - wizard = self._create_wizard( - family_name="Doe", - birthdate="2024-01-15", - ) - wizard.stage = "details" - with self.assertRaises(UserError): - wizard.action_next() - - def test_step2_requires_birthdate(self): - """Cannot advance past step 2 without birthdate.""" - wizard = self._create_wizard( - given_name="John", - family_name="Doe", - ) - wizard.stage = "details" - with self.assertRaises(UserError): - wizard.action_next() - - # ================== - # Computed Fields - # ================== - - def test_member_name_computed(self): - """member_name is computed from given_name and family_name.""" - wizard = self._create_wizard( - given_name="John", - family_name="Doe", - ) - self.assertEqual(wizard.member_name, "DOE, JOHN") - - def test_member_name_given_only(self): - """member_name with only given_name.""" - wizard = self._create_wizard(given_name="John") - self.assertEqual(wizard.member_name, "JOHN") - - def test_member_name_family_only(self): - """member_name with only family_name.""" - wizard = self._create_wizard(family_name="Doe") - self.assertEqual(wizard.member_name, "DOE") - - def test_registrant_info_html_populated(self): - """registrant_info_html is populated when registrant is selected.""" - wizard = self._create_wizard() - self.assertTrue(wizard.registrant_info_html) - self.assertIn("Test Wizard Household", wizard.registrant_info_html) - - def test_preview_html_contains_data(self): - """preview_html shows summary data at review stage.""" - wizard = self._create_wizard( - given_name="John", - family_name="Doe", - birthdate="2024-01-15", - gender_id=self.gender_male.id, - ) - wizard.stage = "review" - self.assertTrue(wizard.preview_html) - self.assertIn("DOE, JOHN", wizard.preview_html) - self.assertIn("2024-01-15", wizard.preview_html) - - # ================== - # Birth Verification - # ================== - - def test_verify_birth_requires_brn(self): - """action_verify_birth requires a BRN.""" - wizard = self._create_wizard( - given_name="John", - family_name="Doe", - ) - with self.assertRaises(UserError): - wizard.action_verify_birth() - - @patch("odoo.addons.spp_dci_client.services.client.DCIClient") - def test_verify_birth_success(self, mock_client_class): - """Successful birth verification sets status to 'verified'.""" - mock_client = MagicMock() - mock_client.search_by_id_opencrvs.return_value = { - "identifier": [{"identifier_type": "BRN", "identifier_value": "TEST123"}], - "name": {"given_name": "John", "surname": "Doe"}, - "sex": "male", - "birth_date": "2024-01-15", - } - mock_client_class.return_value = mock_client - - wizard = self._create_wizard( - given_name="John", - family_name="Doe", - birthdate="2024-01-15", - gender_id=self.gender_male.id, - birth_registration_number="TEST123", - dci_data_source_id=self.data_source.id, - ) - wizard.action_verify_birth() - - self.assertEqual(wizard.birth_verification_status, "verified") - self.assertTrue(wizard.birth_verification_date) - self.assertTrue(wizard.birth_verification_response) - self.assertTrue(wizard.dci_data_match) - - @patch("odoo.addons.spp_dci_client.services.client.DCIClient") - def test_verify_birth_not_found(self, mock_client_class): - """Not-found response sets status to 'not_found'.""" - mock_client = MagicMock() - mock_client.search_by_id_opencrvs.return_value = { - "message": { - "search_response": [ - {"status": "succ", "data": []}, - ] - }, - } - mock_client_class.return_value = mock_client - - wizard = self._create_wizard( - given_name="John", - birth_registration_number="NONEXISTENT", - dci_data_source_id=self.data_source.id, - ) - wizard.action_verify_birth() - - self.assertEqual(wizard.birth_verification_status, "not_found") - - @patch("odoo.addons.spp_dci_client.services.client.DCIClient") - def test_verify_birth_error(self, mock_client_class): - """API error sets status to 'error'.""" - mock_client = MagicMock() - mock_client.search_by_id_opencrvs.side_effect = Exception("Connection timeout") - mock_client_class.return_value = mock_client - - wizard = self._create_wizard( - given_name="John", - birth_registration_number="TEST123", - dci_data_source_id=self.data_source.id, - ) - - with self.assertRaises(UserError) as cm: - wizard.action_verify_birth() - self.assertIn("Connection timeout", str(cm.exception)) - - @patch("odoo.addons.spp_dci_client.services.client.DCIClient") - def test_verify_birth_data_mismatch(self, mock_client_class): - """Data mismatch sets dci_data_match to False.""" - mock_client = MagicMock() - mock_client.search_by_id_opencrvs.return_value = { - "identifier": [{"identifier_type": "BRN", "identifier_value": "TEST123"}], - "name": {"given_name": "Jane", "surname": "Smith"}, - "sex": "female", - "birth_date": "2024-06-20", - } - mock_client_class.return_value = mock_client - - wizard = self._create_wizard( - given_name="John", - family_name="Doe", - birthdate="2024-01-15", - gender_id=self.gender_male.id, - birth_registration_number="TEST123", - dci_data_source_id=self.data_source.id, - ) - wizard.action_verify_birth() - - self.assertEqual(wizard.birth_verification_status, "verified") - self.assertFalse(wizard.dci_data_match) - - # ================== - # Create & Submit - # ================== - - def test_create_and_submit_creates_cr(self): - """action_create_and_submit creates a CR with detail populated.""" - wizard = self._create_wizard( - given_name="John", - family_name="Doe", - birthdate="2024-01-15", - gender_id=self.gender_male.id, - relationship_id=self.relationship_head.id, - ) - wizard.stage = "review" - - result = wizard.action_create_and_submit() - - # Should return an action opening the CR form - self.assertEqual(result["res_model"], "spp.change.request") - cr_id = result["res_id"] - cr = self.env["spp.change.request"].browse(cr_id) - self.assertTrue(cr.exists()) - - # Check CR fields - self.assertEqual(cr.request_type_id, self.request_type) - self.assertEqual(cr.registrant_id, self.test_group) - - # Check detail fields - detail = cr.get_detail() - self.assertTrue(detail) - self.assertEqual(detail.given_name, "John") - self.assertEqual(detail.family_name, "Doe") - self.assertEqual(str(detail.birthdate), "2024-01-15") - self.assertEqual(detail.gender_id, self.gender_male) - self.assertEqual(detail.relationship_id, self.relationship_head) - - def test_create_and_submit_submits_cr(self): - """action_create_and_submit submits the CR for approval.""" - wizard = self._create_wizard( - given_name="John", - family_name="Doe", - birthdate="2024-01-15", - gender_id=self.gender_male.id, - ) - wizard.stage = "review" - - result = wizard.action_create_and_submit() - - cr = self.env["spp.change.request"].browse(result["res_id"]) - # Should be pending (submitted for approval) - self.assertEqual(cr.display_state, "pending") - - def test_create_and_submit_copies_verification_data(self): - """Verification data from wizard is copied to the CR detail.""" - wizard = self._create_wizard( - given_name="John", - family_name="Doe", - birthdate="2024-01-15", - gender_id=self.gender_male.id, - birth_registration_number="BRN123", - birth_verification_status="verified", - birth_verification_response='{"test": true}', - dci_data_match=True, - dci_data_source_id=self.data_source.id, - ) - wizard.stage = "review" - - result = wizard.action_create_and_submit() - - cr = self.env["spp.change.request"].browse(result["res_id"]) - detail = cr.get_detail() - self.assertEqual(detail.birth_registration_number, "BRN123") - self.assertEqual(detail.birth_verification_status, "verified") - self.assertTrue(detail.birth_verification_response) - self.assertTrue(detail.dci_data_match) - self.assertEqual(detail.dci_data_source_id, self.data_source) - - @patch("odoo.addons.spp_dci_client.services.client.DCIClient") - def test_full_happy_path(self, mock_client_class): - """Full wizard flow: create -> submit -> auto-approve -> auto-apply.""" - # Enable auto-approve - self.env["ir.config_parameter"].sudo().set_param("spp_dci_demo.auto_approve_on_match", "True") - - mock_client = MagicMock() - mock_client.search_by_id_opencrvs.return_value = { - "identifier": [{"identifier_type": "BRN", "identifier_value": "HAPPY123"}], - "name": {"given_name": "George", "surname": "Doe"}, - "sex": "male", - "birth_date": "2024-01-15", - } - mock_client_class.return_value = mock_client - - # Step 1: Create wizard with household - wizard = self._create_wizard( - given_name="George", - family_name="Doe", - birthdate="2024-01-15", - gender_id=self.gender_male.id, - relationship_id=self.relationship_head.id, - birth_registration_number="HAPPY123", - dci_data_source_id=self.data_source.id, - ) - - # Step 2: Verify birth - wizard.action_verify_birth() - self.assertEqual(wizard.birth_verification_status, "verified") - self.assertTrue(wizard.dci_data_match) - - # Step 3: Create and submit - wizard.stage = "review" - result = wizard.action_create_and_submit() - - cr = self.env["spp.change.request"].browse(result["res_id"]) - detail = cr.get_detail() - - # The CR should be submitted (pending). Auto-approve happens - # at birth verification on the detail, not on the wizard. - # So the CR is in pending state after wizard submit. - self.assertIn(cr.display_state, ("pending", "applied")) - - # Verify detail has all the data - self.assertEqual(detail.given_name, "George") - self.assertEqual(detail.family_name, "Doe") - self.assertEqual(detail.birth_registration_number, "HAPPY123") - self.assertEqual(detail.birth_verification_status, "verified") - self.assertTrue(detail.dci_data_match) - - def test_create_and_submit_with_applicant(self): - """Applicant info is stored when provided.""" - applicant = self.env["res.partner"].create( - { - "name": "Parent Applicant", - "is_registrant": True, - "is_group": False, - } - ) - wizard = self._create_wizard( - given_name="John", - family_name="Doe", - birthdate="2024-01-15", - applicant_id=applicant.id, - applicant_phone="555-1234", - ) - wizard.stage = "review" - - result = wizard.action_create_and_submit() - - cr = self.env["spp.change.request"].browse(result["res_id"]) - self.assertEqual(cr.applicant_id, applicant) - self.assertEqual(cr.applicant_phone, "555-1234") - - def test_action_returns_wizard_form(self): - """Navigation actions return an action dict that redisplays the wizard.""" - wizard = self._create_wizard() - result = wizard.action_next() - self.assertEqual(result["type"], "ir.actions.act_window") - self.assertEqual(result["res_model"], "spp.dci.demo.add.child.wizard") - self.assertEqual(result["res_id"], wizard.id) - self.assertEqual(result["target"], "current") diff --git a/spp_dci_demo/views/add_child_wizard_view.xml b/spp_dci_demo/views/add_child_wizard_view.xml deleted file mode 100644 index a29c10e2..00000000 --- a/spp_dci_demo/views/add_child_wizard_view.xml +++ /dev/null @@ -1,211 +0,0 @@ - - - - - spp.dci.demo.add.child.wizard.form - spp.dci.demo.add.child.wizard - -
-
- -
- -
-

Add Child to Household

-
- - - - - - -
- - - - - - -
- -
- - - - - - - -
- - - - -
- - - - - - - - - - - - - - - - - - - - - - - - -
-
-
-
- - - - -
- - - -
- - - - - - - - - -
-
-
-
-
-
- - - - Add Child (DCI Demo) - spp.dci.demo.add.child.wizard - form - - current - - - - -
diff --git a/spp_dci_demo/wizards/__init__.py b/spp_dci_demo/wizards/__init__.py deleted file mode 100644 index ed1ac9eb..00000000 --- a/spp_dci_demo/wizards/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# Part of OpenSPP. See LICENSE file for full copyright and licensing details. - -from . import add_child_wizard diff --git a/spp_dci_demo/wizards/add_child_wizard.py b/spp_dci_demo/wizards/add_child_wizard.py deleted file mode 100644 index f7bae7c6..00000000 --- a/spp_dci_demo/wizards/add_child_wizard.py +++ /dev/null @@ -1,630 +0,0 @@ -# Part of OpenSPP. See LICENSE file for full copyright and licensing details. - -"""Multi-step wizard for adding a child to a household with DCI birth verification. - -Wizard flow (3 steps): - 1. Select Household - search/select group, optional applicant - 2. Child Information - enter child details + BRN, verify birth - 3. Review & Submit - see summary, create + auto-submit CR -""" - -import json -import logging - -from markupsafe import Markup, escape - -from odoo import _, api, fields, models -from odoo.exceptions import UserError - -from ..utils.dci_verification import ( - check_data_matches, - extract_person_from_dci_response, - parse_dci_response, -) - -_logger = logging.getLogger(__name__) - -STAGE_ORDER = ["registrant", "details", "review"] - - -class SPPDCIDemoAddChildWizard(models.TransientModel): - """Multi-step wizard for creating an Add Child CR with DCI birth verification.""" - - _name = "spp.dci.demo.add.child.wizard" - _description = "Add Child Wizard (DCI Demo)" - - stage = fields.Selection( - [ - ("registrant", "Select Household"), - ("details", "Child Information"), - ("review", "Review & Submit"), - ], - default="registrant", - required=True, - ) - - # ================== - # Step 1 - Household - # ================== - request_type_id = fields.Many2one( - "spp.change.request.type", - string="Request Type", - readonly=True, - ) - - registrant_id = fields.Many2one( - "res.partner", - string="Household", - domain="[('is_registrant', '=', True), ('is_group', '=', True)]", - ) - - registrant_info_html = fields.Html( - compute="_compute_registrant_info_html", - string="Household Info", - ) - - applicant_id = fields.Many2one( - "res.partner", - string="Applicant", - help="Person requesting the change (optional)", - ) - - applicant_phone = fields.Char( - string="Applicant Phone", - ) - - # ================== - # Step 2 - Child Details - # ================== - given_name = fields.Char(string="Given Name") - family_name = fields.Char(string="Family Name") - member_name = fields.Char( - string="Full Name", - compute="_compute_member_name", - store=True, - ) - birthdate = fields.Date(string="Date of Birth") - gender_id = fields.Many2one( - "spp.vocabulary.code", - string="Gender", - domain="[('namespace_uri', '=', 'urn:iso:std:iso:5218')]", - ) - relationship_id = fields.Many2one( - "spp.vocabulary.code", - string="Relationship to Head", - domain="[('vocabulary_id.namespace_uri', '=', " - "'urn:openspp:vocab:group-membership-type'), " - "('code', '!=', 'head')]", - ) - - # Birth Verification - birth_registration_number = fields.Char(string="Birth Registration Number (BRN)") - dci_data_source_id = fields.Many2one( - "spp.dci.data.source", - string="DCI Data Source", - domain="[('registry_type', '=', 'ns:org:RegistryType:Civil'), ('active', '=', True)]", - ) - single_dci_data_source = fields.Boolean( - compute="_compute_single_dci_data_source", - ) - birth_verification_status = fields.Selection( - [ - ("unverified", "Unverified"), - ("verified", "Verified"), - ("not_found", "Not Found"), - ("error", "Error"), - ], - default="unverified", - string="Verification Status", - ) - birth_verification_date = fields.Datetime( - string="Verification Date", - readonly=True, - ) - birth_verification_response = fields.Text( - string="Verification Response", - readonly=True, - ) - dci_data_match = fields.Boolean( - string="DCI Data Matches", - readonly=True, - ) - - # ================== - # Step 3 - Review - # ================== - preview_html = fields.Html( - compute="_compute_preview_html", - string="Summary", - ) - - # ================== - # Default Values - # ================== - - @api.model - def default_get(self, fields_list): - """Pre-fill request_type_id and registrant from context.""" - res = super().default_get(fields_list) - - # Always pre-set to add_member type - if "request_type_id" in fields_list: - request_type = self.env["spp.change.request.type"].search([("code", "=", "add_member")], limit=1) - if request_type: - res["request_type_id"] = request_type.id - - # Pre-fill registrant from context - if "registrant_id" in fields_list: - if self.env.context.get("active_model") == "res.partner": - active_id = self.env.context.get("active_id") - if active_id: - partner = self.env["res.partner"].browse(active_id) - if partner.exists() and partner.is_registrant and partner.is_group: - res["registrant_id"] = partner.id - - return res - - # ================== - # Computed Fields - # ================== - - def _compute_single_dci_data_source(self): - count = self.env["spp.dci.data.source"].search_count( - [("registry_type", "=", "ns:org:RegistryType:Civil"), ("active", "=", True)], - ) - is_single = count <= 1 - for rec in self: - rec.single_dci_data_source = is_single - - @api.depends("given_name", "family_name") - def _compute_member_name(self): - for rec in self: - if rec.given_name or rec.family_name: - name_vals = [ - f"{rec.family_name}," - if rec.family_name and rec.given_name - else f"{rec.family_name}" - if rec.family_name - else "", - rec.given_name, - ] - rec.member_name = " ".join(filter(None, name_vals)).upper() - else: - rec.member_name = False - - @api.depends("registrant_id") - def _compute_registrant_info_html(self): - for rec in self: - if rec.registrant_id: - reg = rec.registrant_id - info_parts = [] - - # Name with ID - primary_id = "" - if hasattr(reg, "reg_ids") and reg.reg_ids: - first_id = reg.reg_ids[0] - if first_id.value: - primary_id = first_id.value - - if primary_id: - name_part = Markup("{} ({})").format( - escape(reg.name or "Unknown"), escape(primary_id) - ) - else: - name_part = Markup("{}").format(escape(reg.name or "Unknown")) - info_parts.append(name_part) - - # Member count - member_count = len(reg.group_membership_ids) if hasattr(reg, "group_membership_ids") else 0 - info_parts.append( - Markup("{} members").format( - member_count - ) - ) - - # Address - if reg.street: - addr = escape(reg.street) - if reg.city: - addr = Markup("{}, {}").format(escape(reg.street), escape(reg.city)) - info_parts.append( - Markup("{}").format( - addr - ) - ) - - rec.registrant_info_html = Markup(" ").join(info_parts) - else: - rec.registrant_info_html = "" - - @api.depends( - "registrant_id", - "given_name", - "family_name", - "birthdate", - "gender_id", - "relationship_id", - "birth_registration_number", - "birth_verification_status", - "dci_data_match", - "applicant_id", - ) - def _compute_preview_html(self): - for rec in self: - if not rec.registrant_id: - rec.preview_html = "" - continue - - rows = [] - - # Household - rows.append( - Markup("{}{}").format( - escape("Household"), - escape(rec.registrant_id.name or ""), - ) - ) - - # Child name - rows.append( - Markup("{}{}").format( - escape("Child Name"), - escape(rec.member_name or ""), - ) - ) - - # Birthdate - if rec.birthdate: - rows.append( - Markup("{}{}").format( - escape("Date of Birth"), - escape(str(rec.birthdate)), - ) - ) - - # Gender - if rec.gender_id: - rows.append( - Markup("{}{}").format( - escape("Gender"), - escape(rec.gender_id.display or rec.gender_id.code or ""), - ) - ) - - # Relationship - if rec.relationship_id: - rows.append( - Markup("{}{}").format( - escape("Relationship"), - escape(rec.relationship_id.display or rec.relationship_id.code or ""), - ) - ) - - # BRN & Verification - if rec.birth_registration_number: - rows.append( - Markup("{}{}").format( - escape("BRN"), - escape(rec.birth_registration_number), - ) - ) - - status_label = dict(rec._fields["birth_verification_status"].selection).get( - rec.birth_verification_status, "" - ) - badge_class = { - "verified": "bg-success", - "not_found": "bg-warning", - "error": "bg-danger", - "unverified": "bg-secondary", - }.get(rec.birth_verification_status, "bg-secondary") - - rows.append( - Markup('{}{}').format( - escape("Verification Status"), - badge_class, - escape(status_label), - ) - ) - - if rec.birth_verification_status == "verified": - match_text = "Yes" if rec.dci_data_match else "No" - match_class = "text-success" if rec.dci_data_match else "text-danger" - rows.append( - Markup('{}{}').format( - escape("Data Matches"), - match_class, - escape(match_text), - ) - ) - - # Applicant - if rec.applicant_id: - rows.append( - Markup("{}{}").format( - escape("Applicant"), - escape(rec.applicant_id.name or ""), - ) - ) - - table = Markup('{}
').format( - Markup("").join(rows) - ) - - rec.preview_html = table - - # ================== - # Navigation - # ================== - - def action_next(self): - """Validate current step and advance to the next stage.""" - self.ensure_one() - self._validate_current_step() - - current_index = STAGE_ORDER.index(self.stage) - if current_index < len(STAGE_ORDER) - 1: - self.stage = STAGE_ORDER[current_index + 1] - - return self._return_wizard_action() - - def action_previous(self): - """Go back one step.""" - self.ensure_one() - - current_index = STAGE_ORDER.index(self.stage) - if current_index > 0: - self.stage = STAGE_ORDER[current_index - 1] - - return self._return_wizard_action() - - def _validate_current_step(self): - """Validate fields for the current step before advancing.""" - if self.stage == "registrant": - if not self.registrant_id: - raise UserError(_("Please select a household before continuing.")) - elif self.stage == "details": - if not self.given_name: - raise UserError(_("Please enter the child's given name.")) - if not self.birthdate: - raise UserError(_("Please enter the child's date of birth.")) - - def _return_wizard_action(self): - """Return action dict to redisplay the same wizard record.""" - return { - "type": "ir.actions.act_window", - "name": "Add Child (DCI Demo)", - "res_model": self._name, - "res_id": self.id, - "view_mode": "form", - "target": "current", - } - - # ================== - # Birth Verification - # ================== - - def action_verify_birth(self): - """Verify birth registration via DCI query to CRVS registry.""" - self.ensure_one() - - if not self.birth_registration_number: - raise UserError(_("Please enter the Birth Registration Number (BRN) before verifying.")) - - # Get the DCI data source - data_source = self.dci_data_source_id or self._get_default_dci_data_source() - if not data_source: - raise UserError( - _( - "No DCI data source configured for birth verification. " - "Please configure a CRVS data source or contact your administrator." - ) - ) - - from odoo.addons.spp_dci_client.services.client import DCIClient - - try: - client = DCIClient(data_source, self.env) - response = client.search_by_id_opencrvs( - identifier_type="BRN", - identifier_value=self.birth_registration_number, - event_type="birth", - ) - - response_json = json.dumps(response, indent=2, default=str) - - # Parse response using shared utility - verification_status = parse_dci_response(response) - - # Check data match - data_matches = False - if verification_status == "verified": - person_data = extract_person_from_dci_response(response) - if person_data: - gender_display = (self.gender_id.display or "") if self.gender_id else "" - data_matches, mismatches = check_data_matches( - person_data, - given_name=self.given_name, - family_name=self.family_name, - birthdate=self.birthdate, - gender_display=gender_display, - ) - if mismatches: - _logger.info( - "Wizard DCI data mismatch for BRN %s: %s", - self.birth_registration_number, - "; ".join(mismatches), - ) - - self.write( - { - "birth_verification_status": verification_status, - "birth_verification_date": fields.Datetime.now(), - "birth_verification_response": response_json, - "dci_data_match": data_matches, - } - ) - - _logger.info( - "Wizard birth verification for BRN %s: status=%s, data_match=%s", - self.birth_registration_number, - verification_status, - data_matches, - ) - - return self._return_wizard_action() - - except UserError: - raise - except Exception as e: - _logger.exception( - "Wizard birth verification failed for BRN %s", - self.birth_registration_number, - ) - self.write( - { - "birth_verification_status": "error", - "birth_verification_date": fields.Datetime.now(), - "birth_verification_response": str(e), - } - ) - raise UserError(self.env._("Birth verification failed: %s") % str(e)) from e - - def _get_default_dci_data_source(self): - """Get the default DCI data source for birth verification.""" - param_value = self.env["ir.config_parameter"].sudo().get_param("spp_dci_demo.default_crvs_data_source") - if param_value: - try: - data_source = self.env["spp.dci.data.source"].browse(int(param_value)) - if data_source.exists() and data_source.active: - return data_source - except (ValueError, TypeError): - pass - - return self.env["spp.dci.data.source"].search( - [ - ("registry_type", "=", "ns:org:RegistryType:Civil"), - ("active", "=", True), - ], - limit=1, - ) - - # ================== - # Create & Submit - # ================== - - def action_create_and_submit(self): - """Create the CR, populate detail fields, and submit for approval. - - Flow: - 1. spp.change.request.create() -> creates CR + empty detail - 2. cr.get_detail().write() -> populate detail with wizard values - 3. cr.action_submit_for_approval() -> submit - - If submit fails, the entire transaction rolls back. - """ - self.ensure_one() - - try: - # Step 1: Create the change request - cr_vals = { - "request_type_id": self.request_type_id.id, - "registrant_id": self.registrant_id.id, - "source_type": "manual", - } - if self.applicant_id: - cr_vals["applicant_id"] = self.applicant_id.id - if self.applicant_phone: - cr_vals["applicant_phone"] = self.applicant_phone - - cr = self.env["spp.change.request"].create(cr_vals) - - # Step 2: Populate the detail record - detail = cr.get_detail() - if detail: - detail_vals = { - "given_name": self.given_name, - "family_name": self.family_name, - "member_name": self.member_name, - "birthdate": self.birthdate, - } - if self.gender_id: - detail_vals["gender_id"] = self.gender_id.id - if self.relationship_id: - detail_vals["relationship_id"] = self.relationship_id.id - if self.birth_registration_number: - detail_vals["birth_registration_number"] = self.birth_registration_number - if self.dci_data_source_id: - detail_vals["dci_data_source_id"] = self.dci_data_source_id.id - if self.birth_verification_status != "unverified": - detail_vals["birth_verification_status"] = self.birth_verification_status - if self.birth_verification_date: - detail_vals["birth_verification_date"] = self.birth_verification_date - if self.birth_verification_response: - detail_vals["birth_verification_response"] = self.birth_verification_response - if self.dci_data_match: - detail_vals["dci_data_match"] = self.dci_data_match - - detail.write(detail_vals) - - # Step 3: Submit for approval - cr.action_submit_for_approval() - - _logger.info( - "Wizard created and submitted CR %s for household %s", - cr.name, - self.registrant_id.name, - ) - - # Step 4: Auto-approve if verified and data matches - if self.birth_verification_status == "verified" and self.dci_data_match: - self._try_auto_approve_cr(cr) - - # Return action to open the CR form - cr_id = cr.id - return { - "type": "ir.actions.act_window", - "name": "Change Request", - "res_model": "spp.change.request", - "res_id": cr_id, - "view_mode": "form", - "target": "current", - "context": { - "form_view_initial_mode": "readonly", - }, - } - - except (UserError, ValueError): - raise - except Exception as e: - _logger.exception("Wizard create and submit failed") - raise UserError(f"Failed to create change request: {e}") from e - - def _try_auto_approve_cr(self, cr): - """Try to auto-approve the change request if enabled. - - Args: - cr: The change request to approve - """ - # Check system parameter - auto_approve_enabled = ( - self.env["ir.config_parameter"].sudo().get_param("spp_dci_demo.auto_approve_on_match", "False") - ) - if auto_approve_enabled.lower() not in ("true", "1", "yes"): - _logger.info("Auto-approval disabled by system parameter") - return - - # Check if CR can be approved (must be pending/under review) - if cr.display_state != "pending": - _logger.info( - "Change request %s is in state '%s', cannot auto-approve", - cr.name, - cr.display_state, - ) - return - - try: - cr.action_approve(comment="Auto-approved: DCI birth verification matched") - _logger.info("Auto-approved change request %s due to DCI data match", cr.name) - except Exception as e: - _logger.warning("Failed to auto-approve change request %s: %s", cr.name, str(e)) From 43850e32ba20c829791e18559fe810b15abe2261 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 13 Feb 2026 22:13:51 +0100 Subject: [PATCH 15/20] feat(spp_dci_demo,spp_mis_demo_v2): add Conditional Child Grant and demo household - Add Masters household (Adam + Mary) as demo data for DCI CR flow - Hide Contact Information and Relationship fields in add-member form - Target Conditional Child Grant program in post_init_hook - Add Conditional Child Grant program with first-1,000-days eligibility - Add Health Visit event type for compliance tracking - Configure compliance manager with CEL expression support --- spp_dci_demo/__manifest__.py | 1 + spp_dci_demo/data/demo_household.xml | 48 +++++++++++++++++++ spp_dci_demo/hooks.py | 7 ++- .../views/cr_detail_add_member_view.xml | 13 +++++ spp_mis_demo_v2/data/event_types.xml | 13 +++++ spp_mis_demo_v2/models/demo_programs.py | 45 ++++++++++++++--- spp_mis_demo_v2/models/demo_variables.py | 1 + spp_mis_demo_v2/models/mis_demo_generator.py | 48 +++++++++++++++++++ 8 files changed, 168 insertions(+), 8 deletions(-) create mode 100644 spp_dci_demo/data/demo_household.xml diff --git a/spp_dci_demo/__manifest__.py b/spp_dci_demo/__manifest__.py index f02114b6..5b5b2593 100644 --- a/spp_dci_demo/__manifest__.py +++ b/spp_dci_demo/__manifest__.py @@ -17,6 +17,7 @@ "data/vocabulary_data.xml", "data/dci_data_source.xml", "data/system_parameters.xml", + "data/demo_household.xml", "views/cr_detail_add_member_view.xml", "views/change_request_view.xml", ], diff --git a/spp_dci_demo/data/demo_household.xml b/spp_dci_demo/data/demo_household.xml new file mode 100644 index 00000000..3d39e6ab --- /dev/null +++ b/spp_dci_demo/data/demo_household.xml @@ -0,0 +1,48 @@ + + + + + + + MASTERS, Adam + Adam + Masters + 1995-08-14 + + + + + + + + MASTERS, Mary + Mary + Masters + 2002-02-01 + + + + + + + + Masters Household + + + + + + + + + + + + + + + + diff --git a/spp_dci_demo/hooks.py b/spp_dci_demo/hooks.py index 96b4671c..52613a3b 100644 --- a/spp_dci_demo/hooks.py +++ b/spp_dci_demo/hooks.py @@ -18,8 +18,11 @@ def post_init_hook(env): _logger.info("spp.program model not available, skipping enrollment program setup") return - # Find the first program - program = env["spp.program"].search([], limit=1) + # Find the Conditional Child Grant program (the target for DCI demo enrollment) + program = env["spp.program"].search([("name", "=", "Conditional Child Grant")], limit=1) + if not program: + # Fall back to any program if not found + program = env["spp.program"].search([], limit=1) if not program: _logger.info("No programs found, enrollment_program_id not configured") return diff --git a/spp_dci_demo/views/cr_detail_add_member_view.xml b/spp_dci_demo/views/cr_detail_add_member_view.xml index 380c3c36..123cf035 100644 --- a/spp_dci_demo/views/cr_detail_add_member_view.xml +++ b/spp_dci_demo/views/cr_detail_add_member_view.xml @@ -9,6 +9,19 @@ ref="spp_change_request_v2.spp_cr_detail_add_member_form" /> + + + 1 + + + + + 1 + + diff --git a/spp_mis_demo_v2/data/event_types.xml b/spp_mis_demo_v2/data/event_types.xml index 9436c65d..9280ef5b 100644 --- a/spp_mis_demo_v2/data/event_types.xml +++ b/spp_mis_demo_v2/data/event_types.xml @@ -20,6 +20,19 @@ + + + Health Visit + health_visit + Health checkup visits for children under 5, required for Conditional Child Grant compliance. Tracks immunization and growth monitoring. + visit + both + internal + 5 + + Training Session diff --git a/spp_mis_demo_v2/models/demo_programs.py b/spp_mis_demo_v2/models/demo_programs.py index 15372d0f..d38f14be 100644 --- a/spp_mis_demo_v2/models/demo_programs.py +++ b/spp_mis_demo_v2/models/demo_programs.py @@ -10,13 +10,14 @@ 2. Demonstrate different CEL expression patterns 3. Link to existing Logic Packs from spp_studio -Program Catalog (6 programs): +Program Catalog (7 programs): 1. Universal Child Grant - Member aggregation (child_benefit pack) -2. Elderly Social Pension - Age + constants (social_pension pack) -3. Emergency Relief Fund - Cached metrics (vulnerability_assessment pack) -4. Cash Transfer Program - Poverty threshold (cash_transfer_basic pack) -5. Disability Support Grant - Member existence (disability_assistance pack) -6. Food Assistance - Basic active check (no pack, simple CEL) +2. Conditional Child Grant - First 1,000 days with compliance (child_benefit pack) +3. Elderly Social Pension - Age + constants (social_pension pack) +4. Emergency Relief Fund - Cached metrics (vulnerability_assessment pack) +5. Cash Transfer Program - Poverty threshold (cash_transfer_basic pack) +6. Disability Support Grant - Member existence (disability_assistance pack) +7. Food Assistance - Basic active check (no pack, simple CEL) CEL Expression Patterns Demonstrated: - Field comparison: r.active == true @@ -24,6 +25,7 @@ - Aggregate variables: hh_total_income < poverty_line, child_count > 0 - Compound conditions: dependency_ratio >= 1.5 or (is_female_headed and elderly_count > 0) - Arithmetic with variables: base_child_grant * child_count, disabled_count * disability_grant_per_member +- Compliance criteria: members.exists(m, age_years(m.birthdate) < 5) """ # Demo programs aligned with spec and Logic Packs @@ -55,6 +57,37 @@ "Logic Pack: child_benefit", ], }, + { + "id": "conditional_child_grant", + "name": "Conditional Child Grant", + "description": "Monthly grant for households with pregnant women and children aged 0-2. " + "Targets the critical first 1,000 days of life to support nutrition and " + "health-seeking behavior. Compliance requires prenatal visits, health " + "checkups, and immunizations.", + "target_type": "group", + "entitlement_amount": 10.0, + "entitlement_formula": "first_1000_days_grant", + "cycle_duration": 30, # Monthly + # CEL: Households with children under 2 (first 1,000 days) + # Pattern: Member age check via members.exists() + "cel_expression": "r.is_group == true and members.exists(m, age_years(m.birthdate) < 2)", + # Compliance: prenatal visits, health checkups, immunizations + "compliance_cel_expression": "members.exists(m, age_years(m.birthdate) <= 2)", + # Link to Logic Pack + "logic_pack": "child_benefit", + "use_logic_studio": True, + "logic_name": "Conditional Child Grant Eligibility", + "expression_type": "filter", + "stories": [], + "demo_points": [ + "Conditional cash transfer", + "First 1,000 days targeting (0-2 years)", + "Health visit and immunization compliance", + "Compliance manager with CEL expression", + "CEL: members.exists() for eligibility and compliance", + "Logic Pack: child_benefit", + ], + }, { "id": "elderly_social_pension", "name": "Elderly Social Pension", diff --git a/spp_mis_demo_v2/models/demo_variables.py b/spp_mis_demo_v2/models/demo_variables.py index d344b001..3a57e0ff 100644 --- a/spp_mis_demo_v2/models/demo_variables.py +++ b/spp_mis_demo_v2/models/demo_variables.py @@ -46,6 +46,7 @@ # Fixed program amounts "elderly_pension_amount": 100, "cash_transfer_amount": 150, + "first_1000_days_grant": 10, # Monthly per-beneficiary } diff --git a/spp_mis_demo_v2/models/mis_demo_generator.py b/spp_mis_demo_v2/models/mis_demo_generator.py index 49a83846..83efec58 100644 --- a/spp_mis_demo_v2/models/mis_demo_generator.py +++ b/spp_mis_demo_v2/models/mis_demo_generator.py @@ -1095,6 +1095,10 @@ def _create_demo_programs(self, stats): if program_def.get("cycle_duration"): self._configure_cycle_manager(program, program_def) + # Configure compliance manager if compliance CEL expression specified + if program_def.get("compliance_cel_expression"): + self._configure_compliance_manager(program, program_def) + except Exception as e: _logger.error( "Error creating program (program_id=%s): %s", @@ -1199,6 +1203,50 @@ def _configure_cycle_manager(self, program, program_def): e, ) + def _configure_compliance_manager(self, program, program_def): + """Configure the compliance manager with a CEL expression. + + Sets the compliance CEL expression for ongoing beneficiary verification. + """ + try: + compliance_manager = program.get_manager(program.MANAGER_COMPLIANCE) + if not compliance_manager: + _logger.warning( + "No compliance manager found for program (program_id=%s)", + program.id, + ) + return + + cel_expression = program_def.get("compliance_cel_expression") + if not cel_expression: + return + + if "compliance_cel_expression" not in compliance_manager._fields: + _logger.info( + "Compliance CEL not available for program (program_id=%s)", + program.id, + ) + return + + compliance_manager.write( + { + "compliance_cel_mode": "cel", + "compliance_cel_expression": cel_expression, + } + ) + _logger.info( + "Configured compliance CEL for program (program_id=%s): %s", + program.id, + cel_expression, + ) + + except Exception as e: + _logger.warning( + "Could not configure compliance manager for program (program_id=%s): %s", + program.id, + e, + ) + def _configure_eligibility_manager(self, program, program_def): """Configure the eligibility manager with CEL expression. From 7ee52e17cc5ec85ec3a964002569902b9b4ad20b Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 18 Feb 2026 16:30:09 +0700 Subject: [PATCH 16/20] fix(spp_dci_demo): invalidate membership cache before enrollment and add system parameter Invalidate household group_membership_ids cache before iterating so the newly created child membership is included in auto-enrollment. Add missing spp_dci_demo.default_crvs_data_source system parameter with an empty default to system_parameters.xml so the parameter is defined on module install. --- spp_dci_demo/data/system_parameters.xml | 10 +++++++ spp_dci_demo/models/cr_apply_add_member.py | 31 ++++++++++++---------- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/spp_dci_demo/data/system_parameters.xml b/spp_dci_demo/data/system_parameters.xml index 8db75f66..7be9d41a 100644 --- a/spp_dci_demo/data/system_parameters.xml +++ b/spp_dci_demo/data/system_parameters.xml @@ -12,6 +12,16 @@ True + + + spp_dci_demo.default_crvs_data_source + + + - + spp.change.request.form.dci spp.change.request From 76e158e45d1ca40ed66dcfc1656ed5964c1565d6 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 18 Feb 2026 21:54:31 +0700 Subject: [PATCH 18/20] fix(spp_dci_demo): fix XML ID naming and remaining semgrep finding --- spp_dci_demo/models/cr_apply_add_member.py | 4 +++- spp_dci_demo/views/cr_detail_add_member_view.xml | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/spp_dci_demo/models/cr_apply_add_member.py b/spp_dci_demo/models/cr_apply_add_member.py index 1c5722ef..fd4f1333 100644 --- a/spp_dci_demo/models/cr_apply_add_member.py +++ b/spp_dci_demo/models/cr_apply_add_member.py @@ -135,7 +135,9 @@ def _auto_enroll_in_household_programs(self, individual, household): household: The household/group (res.partner) """ # Get program ID from system parameter - program_id_str = self.env["ir.config_parameter"].sudo().get_param("spp_dci_demo.enrollment_program_id", "") + # sudo() is intentional: system parameters require admin access + config_params = self.env["ir.config_parameter"].sudo() # nosemgrep: odoo-sudo-without-context + program_id_str = config_params.get_param("spp_dci_demo.enrollment_program_id", "") if not program_id_str: _logger.info("No enrollment program configured (spp_dci_demo.enrollment_program_id)") return diff --git a/spp_dci_demo/views/cr_detail_add_member_view.xml b/spp_dci_demo/views/cr_detail_add_member_view.xml index 123cf035..4d01b9f5 100644 --- a/spp_dci_demo/views/cr_detail_add_member_view.xml +++ b/spp_dci_demo/views/cr_detail_add_member_view.xml @@ -1,7 +1,7 @@ - + spp.cr.detail.add_member.form.dci spp.cr.detail.add_member Date: Wed, 18 Feb 2026 21:56:16 +0700 Subject: [PATCH 19/20] chore(spp_dci_demo): add auto-generated pyproject.toml from whool hook --- spp_dci_demo/pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 spp_dci_demo/pyproject.toml diff --git a/spp_dci_demo/pyproject.toml b/spp_dci_demo/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/spp_dci_demo/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" From f5fbed771106aeaf293c6d8f87b5f8dc17b702cc Mon Sep 17 00:00:00 2001 From: emjay0921 Date: Fri, 20 Feb 2026 17:51:53 +0800 Subject: [PATCH 20/20] fix(spp_api_v2): fix 3 failing outgoing log tests - display_name: fall back to url when endpoint is empty, add url to @api.depends - _sanitize_url: use quote_via to preserve asterisks in MASK_VALUE so urlencode does not percent-encode them --- spp_api_v2/models/api_outgoing_log.py | 5 +++-- spp_api_v2/services/outgoing_api_log_service.py | 8 ++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/spp_api_v2/models/api_outgoing_log.py b/spp_api_v2/models/api_outgoing_log.py index 7a2b073e..4e551289 100644 --- a/spp_api_v2/models/api_outgoing_log.py +++ b/spp_api_v2/models/api_outgoing_log.py @@ -138,11 +138,12 @@ class ApiOutgoingLog(models.Model): store=True, ) - @api.depends("http_method", "endpoint", "timestamp") + @api.depends("http_method", "endpoint", "url", "timestamp") def _compute_display_name(self): for record in self: timestamp_str = record.timestamp.strftime("%Y-%m-%d %H:%M") if record.timestamp else "" - record.display_name = f"{record.http_method} {record.endpoint or 'API Call'} @ {timestamp_str}" + path = record.endpoint or record.url or "API Call" + record.display_name = f"{record.http_method} {path} @ {timestamp_str}" # ========================================== # API Methods diff --git a/spp_api_v2/services/outgoing_api_log_service.py b/spp_api_v2/services/outgoing_api_log_service.py index 5831b60d..7772ae32 100644 --- a/spp_api_v2/services/outgoing_api_log_service.py +++ b/spp_api_v2/services/outgoing_api_log_service.py @@ -3,7 +3,7 @@ import json import logging -from urllib.parse import parse_qs, urlencode, urlparse, urlunparse +from urllib.parse import parse_qs, quote_plus, urlencode, urlparse, urlunparse import psycopg2 @@ -187,7 +187,11 @@ def _sanitize_url(self, url): key: ([self.MASK_VALUE] if key.lower() in self.SENSITIVE_KEYS else values) for key, values in params.items() } - sanitized_query = urlencode(sanitized_params, doseq=True) + sanitized_query = urlencode( + sanitized_params, + doseq=True, + quote_via=lambda s, safe="", encoding=None, errors=None: quote_plus(s, safe="*"), + ) sanitized = parsed._replace(query=sanitized_query) return urlunparse(sanitized)