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/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..4e551289 --- /dev/null +++ b/spp_api_v2/models/api_outgoing_log.py @@ -0,0 +1,222 @@ +# 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, + groups="spp_api_v2.group_api_v2_auditor", + help="Full URL called (may contain sensitive query parameters)", + ) + + 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", "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 "" + path = record.endpoint or record.url or "API Call" + record.display_name = f"{record.http_method} {path} @ {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/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/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/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..7772ae32 --- /dev/null +++ b/spp_api_v2/services/outgoing_api_log_service.py @@ -0,0 +1,223 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Service for logging outgoing API calls.""" + +import json +import logging +from urllib.parse import parse_qs, quote_plus, urlencode, urlparse, urlunparse + +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 + + # 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, + 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: + # 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. + 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__) + return None + except (psycopg2.Error, ValueError, RuntimeError): + _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, + quote_via=lambda s, safe="", encoding=None, errors=None: quote_plus(s, safe="*"), + ) + sanitized = parsed._replace(query=sanitized_query) + return urlunparse(sanitized) + + 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_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..28745525 --- /dev/null +++ b/spp_api_v2/tests/test_api_outgoing_log.py @@ -0,0 +1,309 @@ +# 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 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) + 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.url + 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.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(["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 new file mode 100644 index 00000000..c3882ada --- /dev/null +++ b/spp_api_v2/tests/test_outgoing_api_log_service.py @@ -0,0 +1,377 @@ +# 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 + + +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 + 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", + ) + + 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) + + 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 new file mode 100644 index 00000000..296a645f --- /dev/null +++ b/spp_api_v2/views/api_outgoing_log_views.xml @@ -0,0 +1,147 @@ + + + + + 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. +

+
+
+
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" /> + + + diff --git a/spp_approval/models/approval_mixin.py b/spp_approval/models/approval_mixin.py index 92816eaf..cfc9c65b 100644 --- a/spp_approval/models/approval_mixin.py +++ b/spp_approval/models/approval_mixin.py @@ -332,6 +332,38 @@ 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. + + 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 + """ + 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, auto=True) # nosemgrep: odoo-sudo-without-context + _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() 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/models/data_source.py b/spp_dci_client/models/data_source.py index a58913c5..a1cb4d90 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() # nosemgrep: odoo-sudo-without-context 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, diff --git a/spp_dci_client/services/client.py b/spp_dci_client/services/client.py index 187dacc0..526905d1 100644 --- a/spp_dci_client/services/client.py +++ b/spp_dci_client/services/client.py @@ -1,6 +1,8 @@ """DCI Client Service for making signed API requests.""" +import json import logging +import time import uuid from datetime import UTC, datetime from typing import Any @@ -167,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). @@ -177,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 @@ -189,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, @@ -229,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) @@ -377,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) @@ -399,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, @@ -417,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, @@ -427,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", @@ -904,6 +959,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 +992,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 json.JSONDecodeError: + log_response_data = None self.data_source.clear_oauth2_token_cache() return self._make_request(endpoint, envelope, _retry_auth=False) @@ -938,6 +1007,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,18 +1020,23 @@ 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: technical_detail += f": {response_text}" - except Exception: + except (json.JSONDecodeError, KeyError, TypeError): 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 +1054,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_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_client/tests/test_outgoing_log_integration.py b/spp_dci_client/tests/test_outgoing_log_integration.py new file mode 100644 index 00000000..7cefec24 --- /dev/null +++ b/spp_dci_client/tests/test_outgoing_log_integration.py @@ -0,0 +1,445 @@ +# 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)") + + # 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[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): + """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 diff --git a/spp_dci_demo/__init__.py b/spp_dci_demo/__init__.py new file mode 100644 index 00000000..bd701f00 --- /dev/null +++ b/spp_dci_demo/__init__.py @@ -0,0 +1,5 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from . import models +from . import utils +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..3c4e0d6e --- /dev/null +++ b/spp_dci_demo/__manifest__.py @@ -0,0 +1,29 @@ +# 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://github.com/OpenSPP/OpenSPP2", + "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/system_parameters.xml", + "data/demo_household.xml", + "views/cr_detail_add_member_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", +} 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/data/system_parameters.xml b/spp_dci_demo/data/system_parameters.xml new file mode 100644 index 00000000..7be9d41a --- /dev/null +++ b/spp_dci_demo/data/system_parameters.xml @@ -0,0 +1,33 @@ + + + + + + + spp_dci_demo.auto_approve_on_match + True + + + + + spp_dci_demo.default_crvs_data_source + + + + + 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..b03ebbeb --- /dev/null +++ b/spp_dci_demo/hooks.py @@ -0,0 +1,41 @@ +# 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 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 + + # Set the system parameter + # sudo() is intentional: system parameters require admin access + config_params = env["ir.config_parameter"].sudo() # nosemgrep: odoo-sudo-without-context + config_params.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..fd4f1333 --- /dev/null +++ b/spp_dci_demo/models/cr_apply_add_member.py @@ -0,0 +1,224 @@ +# 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 + # 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 + + 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 + # Invalidate cache so the newly created membership is included + household.invalidate_recordset(["group_membership_ids"]) + 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..0a29dcc9 --- /dev/null +++ b/spp_dci_demo/models/cr_detail_add_member.py @@ -0,0 +1,308 @@ +# 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", + ) + 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): + """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.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. + + Looks for a system parameter or finds the first active CRVS data source. + """ + # Try system parameter first + # sudo() is intentional: system parameters require admin access + config_params = self.env["ir.config_parameter"].sudo() # nosemgrep: odoo-sudo-without-context + param_value = config_params.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, + ) + + # Return notification + if verification_status == "verified": + if 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 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" 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..97dd8b91 --- /dev/null +++ b/spp_dci_demo/security/ir.model.access.csv @@ -0,0 +1 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink diff --git a/spp_dci_demo/tests/__init__.py b/spp_dci_demo/tests/__init__.py new file mode 100644 index 00000000..9634f3e6 --- /dev/null +++ b/spp_dci_demo/tests/__init__.py @@ -0,0 +1,5 @@ +# 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 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..db708ae8 --- /dev/null +++ b/spp_dci_demo/utils/dci_verification.py @@ -0,0 +1,170 @@ +# 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): + # 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): + 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/change_request_view.xml b/spp_dci_demo/views/change_request_view.xml new file mode 100644 index 00000000..b8406b0b --- /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..4d01b9f5 --- /dev/null +++ b/spp_dci_demo/views/cr_detail_add_member_view.xml @@ -0,0 +1,82 @@ + + + + + spp.cr.detail.add_member.form.dci + spp.cr.detail.add_member + + + + + 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. 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 {} []