| spp.registry.id |
Used to resolve registrant identifiers
@@ -430,7 +440,8 @@ Configuration
Navigate to Settings > Technical > FastAPI > Endpoints (provided
by spp_api_v2)
Configure OAuth 2.0 clients with appropriate scopes:
-- change_request:read - Read and search change requests
+- change_request:read - Read and search change requests, list CR
+types
- change_request:create - Create new change requests
- change_request:update - Update, submit, and reset requests
- change_request:approve - Approve, reject, or request revision
@@ -444,7 +455,7 @@ API Endpoints
- POST /ChangeRequest - Create new change request
- GET /ChangeRequest/{reference} - Read by reference
-- GET /ChangeRequest - Search with filters
+- GET /ChangeRequest - Search with filters and pagination
- PUT /ChangeRequest/{reference} - Update detail data
- POST /ChangeRequest/{reference}/$submit - Submit for approval
- POST /ChangeRequest/{reference}/$approve - Approve request
@@ -453,14 +464,18 @@ API Endpoints
revision
POST /ChangeRequest/{reference}/$apply - Apply to registrant
POST /ChangeRequest/{reference}/$reset - Reset to draft
+GET /ChangeRequest/$types - List available CR types
+GET /ChangeRequest/$types/{code} - Get JSON Schema for a CR type’s
+detail fields
Security
- No model access rules - this module extends existing models only. Access
-is controlled via OAuth 2.0 scopes mapped to permissions. The API
-enforces scope checks on each endpoint. Users must authenticate via the
-spp_api_v2 OAuth 2.0 provider.
+ No model access rules — this module extends existing models only. Access
+is controlled via OAuth 2.0 scopes on the change_request resource.
+Each endpoint checks has_scope(resource, action) and returns 403
+Forbidden if the client lacks the required scope. Users must
+authenticate via the spp_api_v2 OAuth 2.0 provider.
Extension Points
@@ -475,7 +490,7 @@ Extension Points
UI Location
- No standalone menu - API-only module. The ChangeRequest router is
+ No standalone menu — API-only module. The ChangeRequest router is
automatically registered with the API V2 endpoint when this module is
installed.
@@ -485,16 +500,450 @@ Dependencies
Table of contents
+
+
+
+ Prerequisites
+ Before testing the Change Request API, ensure you have:
+
+- A running OpenSPP instance with spp_api_v2_change_request
+installed
+- An API client configured with change_request:all scope (or
+individual scopes per endpoint)
+- At least one registrant with an external identifier (e.g., via
+spp.registry.id)
+- At least one active Change Request type (e.g., edit_individual)
+
+ Setting up an API client
+ Navigate to Settings > Technical > FastAPI > Endpoints, open the
+OpenSPP API V2 endpoint, and create an API client under the Clients
+tab. Configure scopes for the change_request resource. Use
+All Actions for testing, or assign granular scopes:
+
+- Read — list types, read and search change requests
+- Create — create new change requests
+- Update — update detail data, submit for approval, reset to draft
+- Approve and Apply actions require the All Actions scope
+ Obtaining an access token
+ All endpoints (except $types listing) require a Bearer token. Obtain
+one via the OAuth 2.0 Client Credentials flow:
+
+# Replace with your client_id and client_secret
+curl -s -X POST http://localhost:8069/api/v2/spp/oauth/token \
+ -H "Content-Type: application/json" \
+ -d '{
+ "client_id": "your_client_id",
+ "client_secret": "your_client_secret",
+ "grant_type": "client_credentials"
+ }'
+
+ Response:
+
+{
+ "access_token": "eyJhbGci...",
+ "token_type": "Bearer",
+ "expires_in": 86400,
+ "scope": "change_request:all"
+}
+
+ Set the token for subsequent requests:
+
+TOKEN="eyJhbGci..."
+
+
+
+ API Endpoints
+ 1. List Change Request Types
+ Discover available CR types and their target registrant types.
+
+curl -s http://localhost:8069/api/v2/spp/ChangeRequest/\$types \
+ -H "Authorization: Bearer $TOKEN"
+
+ Response:
+
+[
+ {
+ "code": "edit_individual",
+ "name": "Edit Individual",
+ "targetType": "individual",
+ "requiresApplicant": false
+ },
+ {
+ "code": "add_member",
+ "name": "Add Member",
+ "targetType": "group",
+ "requiresApplicant": true
+ }
+]
+
+ 2. Get Type Field Schema
+ Retrieve the JSON Schema for a CR type’s detail fields. Use this to
+discover which fields are available, their types, and valid values for
+vocabulary fields.
+
+curl -s http://localhost:8069/api/v2/spp/ChangeRequest/\$types/edit_individual \
+ -H "Authorization: Bearer $TOKEN"
+
+ Response (abbreviated):
+
+{
+ "typeInfo": {
+ "code": "edit_individual",
+ "name": "Edit Individual",
+ "targetType": "individual",
+ "requiresApplicant": false
+ },
+ "detailSchema": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "title": "Edit Individual Detail",
+ "properties": {
+ "given_name": {
+ "type": "string",
+ "title": "Given Name"
+ },
+ "family_name": {
+ "type": "string",
+ "title": "Family Name"
+ },
+ "birthdate": {
+ "type": "string",
+ "format": "date",
+ "title": "Date of Birth"
+ },
+ "gender_id": {
+ "type": "object",
+ "title": "Gender",
+ "x-field-type": "vocabulary",
+ "x-vocabulary-uri": "urn:iso:std:iso:5218",
+ "properties": {
+ "system": { "const": "urn:iso:std:iso:5218" },
+ "code": {
+ "oneOf": [
+ { "const": "1", "title": "Male" },
+ { "const": "2", "title": "Female" }
+ ]
+ }
+ }
+ },
+ "phone": {
+ "type": "string",
+ "title": "Phone Number"
+ },
+ "email": {
+ "type": "string",
+ "title": "Email"
+ }
+ }
+ },
+ "availableDocuments": [],
+ "requiredDocuments": []
+}
+
+ 3. Create a Change Request
+ Create a new CR in draft status. The registrant field uses the
+external identifier format system|value (not database IDs).
+
+curl -s -X POST http://localhost:8069/api/v2/spp/ChangeRequest \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "type": "ChangeRequest",
+ "requestType": { "code": "edit_individual" },
+ "registrant": {
+ "system": "urn:openspp:vocab:id-type",
+ "value": "PH-123456789"
+ },
+ "detail": {
+ "given_name": "Maria Elena",
+ "family_name": "Santos",
+ "phone": "+639171234567"
+ },
+ "description": "Name correction request"
+ }'
+
+ Response (201 Created):
+
+{
+ "type": "ChangeRequest",
+ "reference": "CR/2026/00001",
+ "requestType": { "code": "edit_individual", "name": "Edit Individual" },
+ "status": "draft",
+ "registrant": {
+ "system": "urn:openspp:vocab:id-type",
+ "value": "PH-123456789",
+ "display": "SANTOS, MARIA"
+ },
+ "detail": {
+ "given_name": "Maria Elena",
+ "family_name": "Santos",
+ "phone": "+639171234567"
+ },
+ "isApplied": false,
+ "description": "Name correction request",
+ "meta": {
+ "versionId": "1710000000000000",
+ "lastUpdated": "2026-03-16T01:00:00.000000",
+ "source": "urn:openspp:api-client:your_client_id"
+ }
+}
+
+ The response includes a Location header:
+/api/v2/spp/ChangeRequest/CR/2026/00001.
+ Create with vocabulary fields
+ Use the system/code format for vocabulary-typed fields (e.g.,
+gender_id):
+
+curl -s -X POST http://localhost:8069/api/v2/spp/ChangeRequest \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "type": "ChangeRequest",
+ "requestType": { "code": "edit_individual" },
+ "registrant": {
+ "system": "urn:openspp:vocab:id-type",
+ "value": "PH-123456789"
+ },
+ "detail": {
+ "given_name": "Maria",
+ "gender_id": {
+ "system": "urn:iso:std:iso:5218",
+ "code": "2"
+ },
+ "birthdate": "1990-05-15"
+ }
+ }'
+
+ 4. Read a Change Request
+ Read a CR by its reference number. The reference has three path segments
+(e.g., CR/2026/00001).
+
+curl -s http://localhost:8069/api/v2/spp/ChangeRequest/CR/2026/00001 \
+ -H "Authorization: Bearer $TOKEN"
+
+ The response includes an ETag header with the versionId, which
+can be used for optimistic locking on updates.
+ 5. Search Change Requests
+ Search with filters and pagination. All filter parameters are optional.
+
+# Search by registrant
+curl -s 'http://localhost:8069/api/v2/spp/ChangeRequest?registrant=urn:openspp:vocab:id-type|PH-123456789' \
+ -H "Authorization: Bearer $TOKEN"
+
+# Search by type and status with pagination
+curl -s 'http://localhost:8069/api/v2/spp/ChangeRequest?requestType=edit_individual&status=draft&_count=10&_offset=0' \
+ -H "Authorization: Bearer $TOKEN"
+
+# Search by date range
+curl -s 'http://localhost:8069/api/v2/spp/ChangeRequest?createdAfter=2026-01-01&createdBefore=2026-12-31' \
+ -H "Authorization: Bearer $TOKEN"
+
+ Response:
+
+{
+ "data": [
+ {
+ "type": "ChangeRequest",
+ "reference": "CR/2026/00001",
+ "requestType": { "code": "edit_individual", "name": "Edit Individual" },
+ "status": "draft",
+ "registrant": {
+ "system": "urn:openspp:vocab:id-type",
+ "value": "PH-123456789",
+ "display": "SANTOS, MARIA"
+ },
+ "isApplied": false,
+ "meta": { "versionId": "1710000000000000" }
+ }
+ ],
+ "meta": {
+ "total": 1,
+ "count": 1,
+ "offset": 0
+ },
+ "links": {
+ "self": "/api/v2/spp/ChangeRequest?_count=10&_offset=0"
+ }
+}
+
+ The links object includes next and prev URLs when more pages
+are available.
+ 6. Update Change Request Detail
+ Update detail fields on a draft CR. Only fields present in the
+request body are updated; omitted fields are left unchanged.
+
+curl -s -X PUT http://localhost:8069/api/v2/spp/ChangeRequest/CR/2026/00001 \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "detail": {
+ "given_name": "Maria Elena",
+ "phone": "+639171234568"
+ }
+ }'
+
+ Optimistic locking with If-Match
+ Use the ETag value from a previous read to prevent concurrent
+modification conflicts:
+
+curl -s -X PUT http://localhost:8069/api/v2/spp/ChangeRequest/CR/2026/00001 \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json" \
+ -H 'If-Match: "1710000000000000"' \
+ -d '{
+ "detail": { "email": "maria@example.com" }
+ }'
+
+ Returns 409 Conflict if the resource was modified since the ETag
+was obtained.
+ 7. Submit for Approval
+ Submit a draft CR for the approval workflow.
+
+curl -s -X POST http://localhost:8069/api/v2/spp/ChangeRequest/CR/2026/00001/\$submit \
+ -H "Authorization: Bearer $TOKEN"
+
+ The CR status changes to pending (or approved if auto-approval
+is configured).
+ 8. Approve a Change Request
+ Approve a pending CR. An optional comment can be provided.
+
+curl -s -X POST http://localhost:8069/api/v2/spp/ChangeRequest/CR/2026/00001/\$approve \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{ "comment": "Verified with supporting documents" }'
+
+ 9. Reject a Change Request
+ Reject a pending CR. A reason is required.
+
+curl -s -X POST http://localhost:8069/api/v2/spp/ChangeRequest/CR/2026/00001/\$reject \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{ "reason": "Supporting documents are incomplete" }'
+
+ The CR status changes to rejected. The reason is stored in
+rejectionReason.
+ 10. Request Revision
+ Request changes on a pending CR before it can be approved. Notes are
+required.
+
+curl -s -X POST http://localhost:8069/api/v2/spp/ChangeRequest/CR/2026/00001/\$request-revision \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{ "notes": "Please provide a valid phone number" }'
+
+ The CR status changes to revision. The notes are stored in
+revisionNotes.
+ 11. Apply an Approved Change Request
+ Apply an approved CR to update the registrant’s record.
+
+curl -s -X POST http://localhost:8069/api/v2/spp/ChangeRequest/CR/2026/00001/\$apply \
+ -H "Authorization: Bearer $TOKEN"
+
+ On success, isApplied becomes true and appliedDate is set.
+If application fails, applyError contains the error message.
+ 12. Reset to Draft
+ Reset a rejected or revision CR back to draft for
+resubmission.
+
+curl -s -X POST http://localhost:8069/api/v2/spp/ChangeRequest/CR/2026/00001/\$reset \
+ -H "Authorization: Bearer $TOKEN"
+
+ After resetting, you can update the detail data and re-submit.
+
+
+ Error Responses
+
+
+
+
+
+
+
+| Status |
+Meaning |
+Example Cause |
+
+
+
+| 401 |
+Unauthorized |
+Missing or expired Bearer token |
+
+| 403 |
+Forbidden |
+Client lacks the required scope |
+
+| 404 |
+Not Found |
+CR reference does not exist |
+
+| 409 |
+Conflict |
+Version mismatch, wrong state for action |
+
+| 422 |
+Unprocessable Entity |
+Invalid type code, unknown detail fields |
+
+| 500 |
+Internal Server Error |
+Unexpected server-side failure |
+
+
+
+ Error response body:
+
+{ "detail": "Change request not found: CR/9999/99999" }
+
+
+ Full Lifecycle Example
+ A complete workflow from creation to application:
+
+# 1. Get token
+TOKEN=$(curl -s -X POST http://localhost:8069/api/v2/spp/oauth/token \
+ -H "Content-Type: application/json" \
+ -d '{"client_id":"your_id","client_secret":"your_secret","grant_type":"client_credentials"}' \
+ | python3 -c "import json,sys; print(json.load(sys.stdin)['access_token'])")
+
+# 2. Discover available fields for the edit_individual type
+curl -s http://localhost:8069/api/v2/spp/ChangeRequest/\$types/edit_individual \
+ -H "Authorization: Bearer $TOKEN" | python3 -m json.tool
+
+# 3. Create a change request
+REF=$(curl -s -X POST http://localhost:8069/api/v2/spp/ChangeRequest \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "type": "ChangeRequest",
+ "requestType": {"code": "edit_individual"},
+ "registrant": {"system": "urn:openspp:vocab:id-type", "value": "PH-123456789"},
+ "detail": {"given_name": "Maria Elena", "phone": "+639171234567"}
+ }' | python3 -c "import json,sys; print(json.load(sys.stdin)['reference'])")
+echo "Created: $REF"
+
+# 4. Submit for approval (REF is e.g. CR/2026/00001)
+curl -s -X POST "http://localhost:8069/api/v2/spp/ChangeRequest/${REF}/\$submit" \
+ -H "Authorization: Bearer $TOKEN" | python3 -m json.tool
+
+# 5. Approve
+curl -s -X POST "http://localhost:8069/api/v2/spp/ChangeRequest/${REF}/\$approve" \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{"comment": "Approved"}' | python3 -m json.tool
+
+# 6. Apply to registrant
+curl -s -X POST "http://localhost:8069/api/v2/spp/ChangeRequest/${REF}/\$apply" \
+ -H "Authorization: Bearer $TOKEN" | python3 -m json.tool
+
-
+ Bug Tracker
Bugs are tracked on GitHub Issues.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
@@ -502,15 +951,15 @@
Do not contact contributors directly about support or help with technical issues.
-
+ Credits
-
+ Maintainers
Current maintainers:

This module is part of the OpenSPP/OpenSPP2 project on GitHub.
diff --git a/spp_api_v2_change_request/tests/test_change_request_api.py b/spp_api_v2_change_request/tests/test_change_request_api.py
index bd612c74..f158c6f9 100644
--- a/spp_api_v2_change_request/tests/test_change_request_api.py
+++ b/spp_api_v2_change_request/tests/test_change_request_api.py
@@ -218,3 +218,195 @@ def test_search_with_pagination(self):
)
self.assertLessEqual(len(records2), 2)
self.assertEqual(total, total2)
+
+ # ──────────────────────────────────────────────────────────────────────
+ # Router helper tests
+ # ──────────────────────────────────────────────────────────────────────
+
+ def test_build_reference(self):
+ """_build_reference reconstructs CR reference from path segments."""
+ from ..routers.change_request import _build_reference
+
+ self.assertEqual(_build_reference("CR", "2026", "00001"), "CR/2026/00001")
+ self.assertEqual(_build_reference("CR", "2024", "12345"), "CR/2024/12345")
+
+ # ──────────────────────────────────────────────────────────────────────
+ # Model extension tests
+ # ──────────────────────────────────────────────────────────────────────
+
+ def test_api_client_scope_has_change_request_resource(self):
+ """spp.api.client.scope includes change_request as a resource option."""
+ scope_model = self.env["spp.api.client.scope"]
+ resource_field = scope_model._fields["resource"]
+ selection_keys = [key for key, _label in resource_field.selection]
+ self.assertIn("change_request", selection_keys)
+
+ # ──────────────────────────────────────────────────────────────────────
+ # End-to-end workflow tests
+ # ──────────────────────────────────────────────────────────────────────
+
+ def test_create_update_read_workflow(self):
+ """Full create → update detail → read back workflow via service."""
+ from ..schemas.change_request import (
+ ChangeRequestCreate,
+ ChangeRequestType,
+ RegistrantRef,
+ )
+ from ..services.change_request_service import ChangeRequestService
+
+ service = ChangeRequestService(self.env)
+
+ # Create
+ schema = ChangeRequestCreate(
+ type="ChangeRequest",
+ requestType=ChangeRequestType(code="edit_individual"),
+ registrant=RegistrantRef(
+ system="urn:openspp:vocab:id-type",
+ value="TEST-123",
+ ),
+ detail={"given_name": "Initial"},
+ )
+ cr = service.create(schema, source="urn:test:workflow")
+
+ # Update
+ service.update_detail(cr, {"given_name": "Updated", "family_name": "Name"})
+
+ # Read back via to_api_schema
+ data = service.to_api_schema(cr)
+ self.assertEqual(data["detail"]["given_name"], "Updated")
+ self.assertEqual(data["detail"]["family_name"], "Name")
+ self.assertEqual(data["status"], "draft")
+ self.assertFalse(data["isApplied"])
+
+ def test_version_id_is_derived_from_write_date(self):
+ """versionId in meta is derived from CR write_date."""
+ from ..services.change_request_service import ChangeRequestService
+
+ service = ChangeRequestService(self.env)
+
+ cr = self.cr_model.create(
+ {
+ "request_type_id": self.cr_type_edit.id,
+ "registrant_id": self.registrant.id,
+ }
+ )
+
+ data = service.to_api_schema(cr)
+ version_id = data["meta"]["versionId"]
+
+ # versionId should be a numeric string derived from write_date
+ self.assertTrue(version_id.isdigit())
+ expected = str(int(cr.write_date.timestamp() * 1000000))
+ self.assertEqual(version_id, expected)
+
+ # ──────────────────────────────────────────────────────────────────────
+ # Router-level logic tests (testing logic without HTTP transport)
+ # ──────────────────────────────────────────────────────────────────────
+
+ def test_update_non_draft_cr_rejected(self):
+ """Updating a non-draft CR should be rejected (mirrors router 409 logic)."""
+ cr = self.cr_model.create(
+ {
+ "request_type_id": self.cr_type_edit.id,
+ "registrant_id": self.registrant.id,
+ }
+ )
+ cr.approval_state = "pending"
+
+ # The router checks approval_state != "draft" and returns 409.
+ # Verify the state check that the router relies on.
+ self.assertNotEqual(cr.approval_state, "draft")
+
+ def test_optimistic_locking_version_mismatch(self):
+ """Version mismatch detection for If-Match header (mirrors router logic)."""
+ cr = self.cr_model.create(
+ {
+ "request_type_id": self.cr_type_edit.id,
+ "registrant_id": self.registrant.id,
+ }
+ )
+
+ # Compute current version the same way the router does
+ current_version = str(int(cr.write_date.timestamp() * 1000000))
+
+ # Matching version should pass
+ if_match_value = f'"{current_version}"'
+ if_match_clean = if_match_value.strip('"')
+ self.assertEqual(if_match_clean, current_version)
+
+ # Mismatched version should be detected
+ stale_version = "9999999999999999"
+ self.assertNotEqual(stale_version, current_version)
+
+ def test_etag_header_value_format(self):
+ """ETag value follows the quoted versionId format."""
+ from ..services.change_request_service import ChangeRequestService
+
+ service = ChangeRequestService(self.env)
+
+ cr = self.cr_model.create(
+ {
+ "request_type_id": self.cr_type_edit.id,
+ "registrant_id": self.registrant.id,
+ }
+ )
+
+ data = service.to_api_schema(cr)
+ version_id = data.get("meta", {}).get("versionId")
+
+ # ETag would be formatted as: '" "'
+ self.assertIsNotNone(version_id)
+ etag = f'"{version_id}"'
+ self.assertTrue(etag.startswith('"'))
+ self.assertTrue(etag.endswith('"'))
+
+ def test_cr_not_found_returns_falsy(self):
+ """find_by_reference returns falsy for nonexistent CR (mirrors router 404)."""
+ from ..services.change_request_service import ChangeRequestService
+
+ service = ChangeRequestService(self.env)
+ result = service.find_by_reference("CR/9999/99999")
+ self.assertFalse(result)
+
+ def test_type_schema_not_found_returns_none(self):
+ """get_type_schema returns None for nonexistent type (mirrors router 404)."""
+ from ..services.change_request_service import ChangeRequestService
+
+ service = ChangeRequestService(self.env)
+ result = service.get_type_schema("nonexistent_type_xyz")
+ self.assertIsNone(result)
+
+ def test_reset_revision_state_to_draft(self):
+ """Reset a revision-state CR to draft."""
+ from ..services.change_request_service import ChangeRequestService
+
+ service = ChangeRequestService(self.env)
+
+ cr = self.cr_model.create(
+ {
+ "request_type_id": self.cr_type_edit.id,
+ "registrant_id": self.registrant.id,
+ }
+ )
+ cr.approval_state = "revision"
+
+ service.reset_to_draft(cr)
+ self.assertEqual(cr.approval_state, "draft")
+
+ def test_search_defaults(self):
+ """Search with empty params uses defaults (offset=0, count=20)."""
+ from ..services.change_request_service import ChangeRequestService
+
+ service = ChangeRequestService(self.env)
+
+ # Create a CR so results aren't empty
+ self.cr_model.create(
+ {
+ "request_type_id": self.cr_type_edit.id,
+ "registrant_id": self.registrant.id,
+ }
+ )
+
+ records, total = service.search({})
+ self.assertGreaterEqual(total, 1)
+ self.assertLessEqual(len(records), 20)
diff --git a/spp_api_v2_change_request/tests/test_change_request_service.py b/spp_api_v2_change_request/tests/test_change_request_service.py
index 11f1839f..140dfa36 100644
--- a/spp_api_v2_change_request/tests/test_change_request_service.py
+++ b/spp_api_v2_change_request/tests/test_change_request_service.py
@@ -237,7 +237,8 @@ def test_update_detail_readonly_field_raises(self):
# Find a computed/readonly field by inspecting the detail model directly
detail = cr.get_detail()
from odoo.addons.spp_api_v2.services.schema_builder import SKIP_FIELD_TYPES
- from odoo.addons.spp_api_v2_change_request.services.change_request_service import DETAIL_SKIP_FIELDS
+
+ from ..services.change_request_service import DETAIL_SKIP_FIELDS
readonly_fields = []
for field_name, field in detail._fields.items():
@@ -255,3 +256,636 @@ def test_update_detail_readonly_field_raises(self):
# No readonly fields on this detail model; verify validation
# still passes for valid fields
service.update_detail(cr, {"given_name": "Valid Name"})
+
+ # ──────────────────────────────────────────────────────────────────────
+ # to_api_schema edge cases
+ # ──────────────────────────────────────────────────────────────────────
+
+ def test_to_api_schema_empty_recordset(self):
+ """to_api_schema returns empty dict for empty recordset."""
+ service = ChangeRequestService(self.env)
+ empty = self.env["spp.change.request"]
+ self.assertEqual(service.to_api_schema(empty), {})
+
+ def test_to_api_schema_registrant_without_identifier(self):
+ """to_api_schema falls back to internal identifier when registrant has no reg_ids."""
+ service = ChangeRequestService(self.env)
+
+ # Create registrant without external identifier
+ partner_no_id = self.partner_model.create(
+ {
+ "name": "No ID Registrant",
+ "is_registrant": True,
+ "is_group": False,
+ }
+ )
+ cr = self.cr_model.create(
+ {
+ "request_type_id": self.cr_type_edit.id,
+ "registrant_id": partner_no_id.id,
+ }
+ )
+
+ data = service.to_api_schema(cr)
+ self.assertEqual(data["registrant"]["system"], "urn:openspp:internal")
+ self.assertTrue(data["registrant"]["value"].startswith("partner-"))
+
+ def test_to_api_schema_with_description_and_notes(self):
+ """to_api_schema includes description and notes when set."""
+ service = ChangeRequestService(self.env)
+ cr = self.cr_model.create(
+ {
+ "request_type_id": self.cr_type_edit.id,
+ "registrant_id": self.registrant.id,
+ "description": "Test description",
+ "notes": "Test notes",
+ }
+ )
+
+ data = service.to_api_schema(cr)
+ self.assertEqual(data["description"], "Test description")
+ self.assertEqual(data["notes"], "Test notes")
+
+ def test_to_api_schema_with_applicant(self):
+ """to_api_schema includes applicant when set."""
+ service = ChangeRequestService(self.env)
+
+ # Create an applicant with an external identifier
+ applicant = self.partner_model.create(
+ {
+ "name": "Test Applicant",
+ "is_registrant": True,
+ "is_group": False,
+ }
+ )
+ self.env["spp.registry.id"].create(
+ {
+ "partner_id": applicant.id,
+ "id_type_id": self.id_type.id,
+ "value": "APPLICANT-001",
+ }
+ )
+
+ cr = self.cr_model.create(
+ {
+ "request_type_id": self.cr_type_edit.id,
+ "registrant_id": self.registrant.id,
+ "applicant_id": applicant.id,
+ "applicant_phone": "+1234567890",
+ }
+ )
+
+ data = service.to_api_schema(cr)
+ self.assertIn("applicant", data)
+ self.assertEqual(data["applicant"]["value"], "APPLICANT-001")
+ self.assertEqual(data["applicantPhone"], "+1234567890")
+
+ def test_to_api_schema_with_detail_data(self):
+ """to_api_schema serializes detail data."""
+ service = ChangeRequestService(self.env)
+ cr = self.cr_model.create(
+ {
+ "request_type_id": self.cr_type_edit.id,
+ "registrant_id": self.registrant.id,
+ }
+ )
+ service.update_detail(cr, {"given_name": "Jane", "family_name": "Doe"})
+
+ data = service.to_api_schema(cr)
+ self.assertIn("detail", data)
+ self.assertEqual(data["detail"]["given_name"], "Jane")
+ self.assertEqual(data["detail"]["family_name"], "Doe")
+
+ def test_to_api_schema_meta_source(self):
+ """to_api_schema includes source_reference in meta."""
+ service = ChangeRequestService(self.env)
+ cr = self.cr_model.create(
+ {
+ "request_type_id": self.cr_type_edit.id,
+ "registrant_id": self.registrant.id,
+ "source_reference": "urn:test:source",
+ }
+ )
+
+ data = service.to_api_schema(cr)
+ self.assertEqual(data["meta"]["source"], "urn:test:source")
+ self.assertIsNotNone(data["meta"]["lastUpdated"])
+
+ # ──────────────────────────────────────────────────────────────────────
+ # _serialize_detail tests
+ # ──────────────────────────────────────────────────────────────────────
+
+ def test_serialize_detail_vocabulary_field(self):
+ """_serialize_detail serializes many2one vocabulary fields."""
+ service = ChangeRequestService(self.env)
+ cr = self.cr_model.create(
+ {
+ "request_type_id": self.cr_type_edit.id,
+ "registrant_id": self.registrant.id,
+ }
+ )
+
+ # Set gender via deserialization
+ service.update_detail(
+ cr,
+ {
+ "gender_id": {
+ "system": "urn:iso:std:iso:5218",
+ "code": "1",
+ },
+ },
+ )
+
+ detail = cr.get_detail()
+ result = service._serialize_detail(detail)
+
+ self.assertIn("gender_id", result)
+ self.assertEqual(result["gender_id"]["system"], "urn:iso:std:iso:5218")
+ self.assertEqual(result["gender_id"]["code"], "1")
+ self.assertIn("display", result["gender_id"])
+
+ def test_serialize_detail_date_field(self):
+ """_serialize_detail serializes date fields to ISO format."""
+ from datetime import date
+
+ service = ChangeRequestService(self.env)
+ cr = self.cr_model.create(
+ {
+ "request_type_id": self.cr_type_edit.id,
+ "registrant_id": self.registrant.id,
+ }
+ )
+ service.update_detail(cr, {"birthdate": "1990-01-15"})
+
+ detail = cr.get_detail()
+ result = service._serialize_detail(detail)
+
+ self.assertEqual(result["birthdate"], date(1990, 1, 15).isoformat())
+
+ def test_serialize_detail_date_field_none(self):
+ """_serialize_detail returns None for empty date fields."""
+ service = ChangeRequestService(self.env)
+ cr = self.cr_model.create(
+ {
+ "request_type_id": self.cr_type_edit.id,
+ "registrant_id": self.registrant.id,
+ }
+ )
+
+ detail = cr.get_detail()
+ result = service._serialize_detail(detail)
+
+ # birthdate not set, should be None
+ self.assertIsNone(result.get("birthdate"))
+
+ def test_serialize_detail_char_fields(self):
+ """_serialize_detail includes char fields."""
+ service = ChangeRequestService(self.env)
+ cr = self.cr_model.create(
+ {
+ "request_type_id": self.cr_type_edit.id,
+ "registrant_id": self.registrant.id,
+ }
+ )
+ service.update_detail(cr, {"phone": "+639171234567", "email": "test@example.com"})
+
+ detail = cr.get_detail()
+ result = service._serialize_detail(detail)
+
+ self.assertEqual(result["phone"], "+639171234567")
+ self.assertEqual(result["email"], "test@example.com")
+
+ def test_serialize_detail_excludes_skip_fields(self):
+ """_serialize_detail excludes DETAIL_SKIP_FIELDS."""
+ from ..services.change_request_service import DETAIL_SKIP_FIELDS
+
+ service = ChangeRequestService(self.env)
+ cr = self.cr_model.create(
+ {
+ "request_type_id": self.cr_type_edit.id,
+ "registrant_id": self.registrant.id,
+ }
+ )
+
+ detail = cr.get_detail()
+ result = service._serialize_detail(detail)
+
+ for skip_field in DETAIL_SKIP_FIELDS:
+ self.assertNotIn(skip_field, result)
+
+ # ──────────────────────────────────────────────────────────────────────
+ # _deserialize_detail tests
+ # ──────────────────────────────────────────────────────────────────────
+
+ def test_deserialize_detail_vocabulary_code(self):
+ """_deserialize_detail resolves vocabulary code references."""
+ service = ChangeRequestService(self.env)
+ cr = self.cr_model.create(
+ {
+ "request_type_id": self.cr_type_edit.id,
+ "registrant_id": self.registrant.id,
+ }
+ )
+ detail = cr.get_detail()
+
+ vals = service._deserialize_detail(
+ detail,
+ {
+ "gender_id": {
+ "system": "urn:iso:std:iso:5218",
+ "code": "2",
+ },
+ },
+ )
+
+ self.assertIn("gender_id", vals)
+ # Should be an integer ID
+ self.assertIsInstance(vals["gender_id"], int)
+ # Verify it resolves to the Female code
+ code_rec = self.env["spp.vocabulary.code"].browse(vals["gender_id"])
+ self.assertEqual(code_rec.code, "2")
+
+ def test_deserialize_detail_date_string(self):
+ """_deserialize_detail parses ISO date strings."""
+ from datetime import date
+
+ service = ChangeRequestService(self.env)
+ cr = self.cr_model.create(
+ {
+ "request_type_id": self.cr_type_edit.id,
+ "registrant_id": self.registrant.id,
+ }
+ )
+ detail = cr.get_detail()
+
+ vals = service._deserialize_detail(detail, {"birthdate": "1985-06-15"})
+
+ self.assertEqual(vals["birthdate"], date(1985, 6, 15))
+
+ def test_deserialize_detail_char_passthrough(self):
+ """_deserialize_detail passes char values through."""
+ service = ChangeRequestService(self.env)
+ cr = self.cr_model.create(
+ {
+ "request_type_id": self.cr_type_edit.id,
+ "registrant_id": self.registrant.id,
+ }
+ )
+ detail = cr.get_detail()
+
+ vals = service._deserialize_detail(detail, {"given_name": "Alice"})
+ self.assertEqual(vals["given_name"], "Alice")
+
+ def test_deserialize_detail_unknown_field_skipped(self):
+ """_deserialize_detail skips fields not in model (caught by validation)."""
+ service = ChangeRequestService(self.env)
+ cr = self.cr_model.create(
+ {
+ "request_type_id": self.cr_type_edit.id,
+ "registrant_id": self.registrant.id,
+ }
+ )
+ detail = cr.get_detail()
+
+ vals = service._deserialize_detail(detail, {"nonexistent_xyz": "value"})
+ self.assertNotIn("nonexistent_xyz", vals)
+
+ def test_deserialize_detail_partner_identifier(self):
+ """_deserialize_detail resolves partner identifiers via system/value."""
+ service = ChangeRequestService(self.env)
+ cr = self.cr_model.create(
+ {
+ "request_type_id": self.cr_type_edit.id,
+ "registrant_id": self.registrant.id,
+ }
+ )
+ detail = cr.get_detail()
+
+ # Check if this detail model has any many2one partner fields
+ # We'll test with a field that points to res.partner, if any exists
+ partner_fields = []
+ for field_name, field in detail._fields.items():
+ if field.type == "many2one" and field.comodel_name == "res.partner":
+ if field_name not in ("registrant_id", "create_uid", "write_uid"):
+ partner_fields.append(field_name)
+
+ if partner_fields:
+ vals = service._deserialize_detail(
+ detail,
+ {
+ partner_fields[0]: {
+ "system": "urn:openspp:vocab:id-type",
+ "value": "TEST-123",
+ },
+ },
+ )
+ self.assertIn(partner_fields[0], vals)
+ self.assertEqual(vals[partner_fields[0]], self.registrant.id)
+
+ # ──────────────────────────────────────────────────────────────────────
+ # get_primary_identifier tests
+ # ──────────────────────────────────────────────────────────────────────
+
+ def test_get_primary_identifier_with_reg_ids(self):
+ """get_primary_identifier returns identifier dict for partner with reg_ids."""
+ service = ChangeRequestService(self.env)
+ result = service.get_primary_identifier(self.registrant)
+
+ self.assertIsNotNone(result)
+ self.assertEqual(result["system"], "urn:openspp:vocab:id-type")
+ self.assertEqual(result["value"], "TEST-123")
+ self.assertEqual(result["display"], self.registrant.name)
+
+ def test_get_primary_identifier_without_reg_ids(self):
+ """get_primary_identifier returns None for partner without reg_ids."""
+ service = ChangeRequestService(self.env)
+
+ partner_no_id = self.partner_model.create(
+ {
+ "name": "No ID Partner",
+ "is_registrant": True,
+ "is_group": False,
+ }
+ )
+ result = service.get_primary_identifier(partner_no_id)
+ self.assertIsNone(result)
+
+ # ──────────────────────────────────────────────────────────────────────
+ # create with optional fields tests
+ # ──────────────────────────────────────────────────────────────────────
+
+ def test_create_with_description_and_notes(self):
+ """create sets description and notes when provided."""
+ service = ChangeRequestService(self.env)
+
+ schema = ChangeRequestCreate(
+ type="ChangeRequest",
+ requestType=ChangeRequestType(code="edit_individual"),
+ registrant=RegistrantRef(
+ system="urn:openspp:vocab:id-type",
+ value="TEST-123",
+ ),
+ description="My description",
+ notes="Some notes here",
+ )
+
+ cr = service.create(schema, source="urn:test:api")
+ self.assertEqual(cr.description, "My description")
+ self.assertEqual(cr.notes, "Some notes here")
+
+ def test_create_with_applicant(self):
+ """create sets applicant when provided."""
+ service = ChangeRequestService(self.env)
+
+ # Create an applicant
+ applicant = self.partner_model.create(
+ {
+ "name": "Applicant Person",
+ "is_registrant": True,
+ "is_group": False,
+ }
+ )
+ self.env["spp.registry.id"].create(
+ {
+ "partner_id": applicant.id,
+ "id_type_id": self.id_type.id,
+ "value": "APPLICANT-002",
+ }
+ )
+
+ schema = ChangeRequestCreate(
+ type="ChangeRequest",
+ requestType=ChangeRequestType(code="edit_individual"),
+ registrant=RegistrantRef(
+ system="urn:openspp:vocab:id-type",
+ value="TEST-123",
+ ),
+ applicant=RegistrantRef(
+ system="urn:openspp:vocab:id-type",
+ value="APPLICANT-002",
+ ),
+ applicantPhone="+9876543210",
+ )
+
+ cr = service.create(schema, source="urn:test:api")
+ self.assertEqual(cr.applicant_id, applicant)
+ self.assertEqual(cr.applicant_phone, "+9876543210")
+
+ def test_create_with_nonexistent_applicant(self):
+ """create proceeds without setting applicant_id if applicant not found."""
+ service = ChangeRequestService(self.env)
+
+ schema = ChangeRequestCreate(
+ type="ChangeRequest",
+ requestType=ChangeRequestType(code="edit_individual"),
+ registrant=RegistrantRef(
+ system="urn:openspp:vocab:id-type",
+ value="TEST-123",
+ ),
+ applicant=RegistrantRef(
+ system="urn:openspp:vocab:id-type",
+ value="NONEXISTENT-APPLICANT",
+ ),
+ )
+
+ cr = service.create(schema, source="urn:test:api")
+ # CR should be created but without applicant
+ self.assertTrue(cr.id)
+ self.assertFalse(cr.applicant_id)
+
+ def test_create_without_detail(self):
+ """create works without detail data."""
+ service = ChangeRequestService(self.env)
+
+ schema = ChangeRequestCreate(
+ type="ChangeRequest",
+ requestType=ChangeRequestType(code="edit_individual"),
+ registrant=RegistrantRef(
+ system="urn:openspp:vocab:id-type",
+ value="TEST-123",
+ ),
+ )
+
+ cr = service.create(schema, source="urn:test:api")
+ self.assertTrue(cr.id)
+ self.assertTrue(cr.name.startswith("CR/"))
+
+ # ──────────────────────────────────────────────────────────────────────
+ # search edge cases
+ # ──────────────────────────────────────────────────────────────────────
+
+ def test_search_with_date_filters(self):
+ """search filters by created_after and created_before."""
+ service = ChangeRequestService(self.env)
+
+ # Create a CR
+ cr = self.cr_model.create(
+ {
+ "request_type_id": self.cr_type_edit.id,
+ "registrant_id": self.registrant.id,
+ }
+ )
+
+ # Search with date range that includes the CR
+ records, total = service.search(
+ {
+ "created_after": "2020-01-01",
+ "created_before": "2099-12-31",
+ }
+ )
+ self.assertGreaterEqual(total, 1)
+ self.assertIn(cr.id, records.ids)
+
+ # Search with date range that excludes the CR
+ records, total = service.search(
+ {
+ "created_after": "2099-01-01",
+ }
+ )
+ self.assertEqual(total, 0)
+
+ def test_search_nonexistent_registrant_returns_empty(self):
+ """search returns empty when registrant identifier doesn't exist."""
+ service = ChangeRequestService(self.env)
+ records, total = service.search({"registrant": "urn:test:fake|NONEXISTENT"})
+ self.assertEqual(total, 0)
+ self.assertEqual(len(records), 0)
+
+ def test_search_without_pipe_in_registrant(self):
+ """search ignores registrant filter without pipe separator."""
+ service = ChangeRequestService(self.env)
+
+ self.cr_model.create(
+ {
+ "request_type_id": self.cr_type_edit.id,
+ "registrant_id": self.registrant.id,
+ }
+ )
+
+ # Registrant without pipe — no filter applied
+ records, total = service.search({"registrant": "no-pipe-here"})
+ # Should return results since no registrant filter is applied
+ self.assertGreaterEqual(total, 1)
+
+ # ──────────────────────────────────────────────────────────────────────
+ # Additional state validation tests
+ # ──────────────────────────────────────────────────────────────────────
+
+ def test_reject_empty_reason_raises(self):
+ """Rejecting with empty reason raises ValidationError."""
+ service = ChangeRequestService(self.env)
+ cr = self.cr_model.create(
+ {
+ "request_type_id": self.cr_type_edit.id,
+ "registrant_id": self.registrant.id,
+ }
+ )
+ cr.approval_state = "pending"
+
+ with self.assertRaises(ValidationError):
+ service.reject(cr, reason="")
+
+ def test_request_revision_non_pending_raises(self):
+ """Requesting revision on non-pending CR raises UserError."""
+ service = ChangeRequestService(self.env)
+ cr = self.cr_model.create(
+ {
+ "request_type_id": self.cr_type_edit.id,
+ "registrant_id": self.registrant.id,
+ }
+ )
+ with self.assertRaises(UserError):
+ service.request_revision(cr, notes="needs changes")
+
+ def test_request_revision_empty_notes_raises(self):
+ """Requesting revision with empty notes raises ValidationError."""
+ service = ChangeRequestService(self.env)
+ cr = self.cr_model.create(
+ {
+ "request_type_id": self.cr_type_edit.id,
+ "registrant_id": self.registrant.id,
+ }
+ )
+ cr.approval_state = "pending"
+
+ with self.assertRaises(ValidationError):
+ service.request_revision(cr, notes="")
+
+ def test_apply_already_applied_raises(self):
+ """Applying already-applied CR raises UserError."""
+ service = ChangeRequestService(self.env)
+ cr = self.cr_model.create(
+ {
+ "request_type_id": self.cr_type_edit.id,
+ "registrant_id": self.registrant.id,
+ }
+ )
+ cr.approval_state = "approved"
+ cr.is_applied = True
+
+ with self.assertRaises(UserError):
+ service.apply(cr)
+
+ # ──────────────────────────────────────────────────────────────────────
+ # find_by_reference edge cases
+ # ──────────────────────────────────────────────────────────────────────
+
+ def test_find_by_reference_not_found(self):
+ """find_by_reference returns empty recordset for unknown reference."""
+ service = ChangeRequestService(self.env)
+ result = service.find_by_reference("CR/9999/99999")
+ self.assertFalse(result)
+
+ # ──────────────────────────────────────────────────────────────────────
+ # update_detail edge cases
+ # ──────────────────────────────────────────────────────────────────────
+
+ def test_update_detail_with_vocabulary_success(self):
+ """update_detail successfully sets vocabulary field via system/code."""
+ service = ChangeRequestService(self.env)
+ cr = self.cr_model.create(
+ {
+ "request_type_id": self.cr_type_edit.id,
+ "registrant_id": self.registrant.id,
+ }
+ )
+
+ service.update_detail(
+ cr,
+ {
+ "gender_id": {
+ "system": "urn:iso:std:iso:5218",
+ "code": "2",
+ },
+ },
+ )
+
+ detail = cr.get_detail()
+ self.assertTrue(detail.gender_id)
+ self.assertEqual(detail.gender_id.code, "2")
+
+ def test_update_detail_multiple_fields(self):
+ """update_detail handles multiple fields at once."""
+ service = ChangeRequestService(self.env)
+ cr = self.cr_model.create(
+ {
+ "request_type_id": self.cr_type_edit.id,
+ "registrant_id": self.registrant.id,
+ }
+ )
+
+ service.update_detail(
+ cr,
+ {
+ "given_name": "Alice",
+ "family_name": "Smith",
+ "phone": "+1111111111",
+ "birthdate": "1992-03-20",
+ },
+ )
+
+ detail = cr.get_detail()
+ self.assertEqual(detail.given_name, "Alice")
+ self.assertEqual(detail.family_name, "Smith")
+ self.assertEqual(detail.phone, "+1111111111")
+ self.assertEqual(detail.birthdate.isoformat(), "1992-03-20")
|