diff --git a/spp_api_v2_change_request/README.rst b/spp_api_v2_change_request/README.rst index 5858f9ba..5772c0ed 100644 --- a/spp_api_v2_change_request/README.rst +++ b/spp_api_v2_change_request/README.rst @@ -10,9 +10,9 @@ OpenSPP API V2 - Change Request !! source digest: sha256:ac0243c931848a9215f89c328fce09d312e30b92ac9c3c1f141bfe26b4fef453 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png +.. |badge1| image:: https://img.shields.io/badge/maturity-Production%2FStable-green.png :target: https://odoo-community.org/page/development-status - :alt: Beta + :alt: Production/Stable .. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html :alt: License: LGPL-3 @@ -27,6 +27,8 @@ OpenSPP API V2 framework. Allows external systems to create change requests, submit them for approval, and apply approved changes to registrants via OAuth 2.0 authenticated API calls. Uses CR reference numbers (CR/2024/00001) instead of database IDs for all operations. +Auto-installs when both ``spp_api_v2`` and ``spp_change_request_v2`` are +present. Key Capabilities ~~~~~~~~~~~~~~~~ @@ -35,12 +37,15 @@ Key Capabilities - Read individual change requests by reference or search with filters (registrant, type, status, dates) - Update detail data on draft change requests with optimistic locking - via If-Match headers + via If-Match/ETag headers - Submit draft requests for approval workflow - Approve, reject, or request revision on pending requests (requires approval scope) - Apply approved change requests to registrant records - Reset rejected/revision requests to draft for resubmission +- Discover available CR types and retrieve JSON Schema for type-specific + detail fields +- Paginated search results with next/prev navigation URLs Key Models ~~~~~~~~~~ @@ -53,9 +58,13 @@ This module extends existing models and does not define new ones. | ``fastapi.endpoint`` | Extended to register ChangeRequest | | | router with API V2 | +-----------------------------+----------------------------------------+ +| ``spp.api.client.scope`` | Extended to add ``change_request`` as | +| | a resource selection | ++-----------------------------+----------------------------------------+ | ``spp.change.request`` | CRUD operations via REST API | +-----------------------------+----------------------------------------+ -| ``spp.change.request.type`` | Looked up by code in create requests | +| ``spp.change.request.type`` | Looked up by code; provides type | +| | metadata and field schemas | +-----------------------------+----------------------------------------+ | ``spp.registry.id`` | Used to resolve registrant identifiers | | | (system|value) | @@ -73,7 +82,8 @@ To configure OAuth 2.0 clients with appropriate scopes: by ``spp_api_v2``) 2. 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 @@ -84,7 +94,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 @@ -93,14 +103,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 ~~~~~~~~~~~~~~~~ @@ -115,7 +129,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. @@ -129,6 +143,488 @@ Dependencies .. contents:: :local: +Usage +===== + +Prerequisites +~~~~~~~~~~~~~ + +Before testing the Change Request API, ensure you have: + +1. A running OpenSPP instance with ``spp_api_v2_change_request`` + installed +2. An API client configured with ``change_request:all`` scope (or + individual scopes per endpoint) +3. At least one registrant with an external identifier (e.g., via + ``spp.registry.id``) +4. 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: + +.. code:: bash + + # 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: + +.. code:: json + + { + "access_token": "eyJhbGci...", + "token_type": "Bearer", + "expires_in": 86400, + "scope": "change_request:all" + } + +Set the token for subsequent requests: + +.. code:: bash + + TOKEN="eyJhbGci..." + +API Endpoints +~~~~~~~~~~~~~ + +**1. List Change Request Types** + +Discover available CR types and their target registrant types. + +.. code:: bash + + curl -s http://localhost:8069/api/v2/spp/ChangeRequest/\$types \ + -H "Authorization: Bearer $TOKEN" + +Response: + +.. code:: json + + [ + { + "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. + +.. code:: bash + + curl -s http://localhost:8069/api/v2/spp/ChangeRequest/\$types/edit_individual \ + -H "Authorization: Bearer $TOKEN" + +Response (abbreviated): + +.. code:: json + + { + "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). + +.. code:: bash + + 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``): + +.. code:: json + + { + "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``): + +.. code:: bash + + 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``). + +.. code:: bash + + 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. + +.. code:: bash + + # 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: + +.. code:: json + + { + "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. + +.. code:: bash + + 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: + +.. code:: bash + + 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. + +.. code:: bash + + 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. + +.. code:: bash + + 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. + +.. code:: bash + + 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. + +.. code:: bash + + 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. + +.. code:: bash + + 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. + +.. code:: bash + + 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: + +.. code:: json + + { "detail": "Change request not found: CR/9999/99999" } + +Full Lifecycle Example +~~~~~~~~~~~~~~~~~~~~~~ + +A complete workflow from creation to application: + +.. code:: bash + + # 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 =========== diff --git a/spp_api_v2_change_request/__manifest__.py b/spp_api_v2_change_request/__manifest__.py index 234198b9..d497dff2 100644 --- a/spp_api_v2_change_request/__manifest__.py +++ b/spp_api_v2_change_request/__manifest__.py @@ -6,7 +6,7 @@ "author": "OpenSPP.org", "website": "https://github.com/OpenSPP/OpenSPP2", "license": "LGPL-3", - "development_status": "Beta", + "development_status": "Production/Stable", "maintainers": ["jeremi", "gonzalesedwin1123", "reichie020212", "emjay0921"], "depends": [ "spp_api_v2", diff --git a/spp_api_v2_change_request/readme/DESCRIPTION.md b/spp_api_v2_change_request/readme/DESCRIPTION.md index 49a229df..5bb3e457 100644 --- a/spp_api_v2_change_request/readme/DESCRIPTION.md +++ b/spp_api_v2_change_request/readme/DESCRIPTION.md @@ -1,14 +1,16 @@ -Exposes REST API endpoints for managing change requests through the OpenSPP API V2 framework. Allows external systems to create change requests, submit them for approval, and apply approved changes to registrants via OAuth 2.0 authenticated API calls. Uses CR reference numbers (CR/2024/00001) instead of database IDs for all operations. +Exposes REST API endpoints for managing change requests through the OpenSPP API V2 framework. Allows external systems to create change requests, submit them for approval, and apply approved changes to registrants via OAuth 2.0 authenticated API calls. Uses CR reference numbers (CR/2024/00001) instead of database IDs for all operations. Auto-installs when both `spp_api_v2` and `spp_change_request_v2` are present. ### Key Capabilities - Create change requests in draft status with registrant and detail data - Read individual change requests by reference or search with filters (registrant, type, status, dates) -- Update detail data on draft change requests with optimistic locking via If-Match headers +- Update detail data on draft change requests with optimistic locking via If-Match/ETag headers - Submit draft requests for approval workflow - Approve, reject, or request revision on pending requests (requires approval scope) - Apply approved change requests to registrant records - Reset rejected/revision requests to draft for resubmission +- Discover available CR types and retrieve JSON Schema for type-specific detail fields +- Paginated search results with next/prev navigation URLs ### Key Models @@ -17,8 +19,9 @@ This module extends existing models and does not define new ones. | Model | Usage | | -------------------------- | ----------------------------------------------------------- | | `fastapi.endpoint` | Extended to register ChangeRequest router with API V2 | +| `spp.api.client.scope` | Extended to add `change_request` as a resource selection | | `spp.change.request` | CRUD operations via REST API | -| `spp.change.request.type` | Looked up by code in create requests | +| `spp.change.request.type` | Looked up by code; provides type metadata and field schemas | | `spp.registry.id` | Used to resolve registrant identifiers (system\|value) | ### Configuration @@ -29,7 +32,7 @@ To configure OAuth 2.0 clients with appropriate scopes: 1. Navigate to **Settings > Technical > FastAPI > Endpoints** (provided by `spp_api_v2`) 2. 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 @@ -39,7 +42,7 @@ To configure OAuth 2.0 clients with appropriate scopes: - `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 @@ -47,10 +50,12 @@ To configure OAuth 2.0 clients with appropriate scopes: - `POST /ChangeRequest/{reference}/$request-revision` - Request 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 @@ -60,7 +65,7 @@ No model access rules - this module extends existing models only. Access is cont ### UI Location -No standalone menu - API-only module. The ChangeRequest router is automatically registered with the API V2 endpoint when this module is installed. +No standalone menu — API-only module. The ChangeRequest router is automatically registered with the API V2 endpoint when this module is installed. ### Dependencies diff --git a/spp_api_v2_change_request/readme/USAGE.md b/spp_api_v2_change_request/readme/USAGE.md new file mode 100644 index 00000000..d91f4e1e --- /dev/null +++ b/spp_api_v2_change_request/readme/USAGE.md @@ -0,0 +1,456 @@ +### Prerequisites + +Before testing the Change Request API, ensure you have: + +1. A running OpenSPP instance with `spp_api_v2_change_request` installed +2. An API client configured with `change_request:all` scope (or individual scopes per endpoint) +3. At least one registrant with an external identifier (e.g., via `spp.registry.id`) +4. 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: + +```bash +# 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: + +```json +{ + "access_token": "eyJhbGci...", + "token_type": "Bearer", + "expires_in": 86400, + "scope": "change_request:all" +} +``` + +Set the token for subsequent requests: + +```bash +TOKEN="eyJhbGci..." +``` + +### API Endpoints + +**1. List Change Request Types** + +Discover available CR types and their target registrant types. + +```bash +curl -s http://localhost:8069/api/v2/spp/ChangeRequest/\$types \ + -H "Authorization: Bearer $TOKEN" +``` + +Response: + +```json +[ + { + "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. + +```bash +curl -s http://localhost:8069/api/v2/spp/ChangeRequest/\$types/edit_individual \ + -H "Authorization: Bearer $TOKEN" +``` + +Response (abbreviated): + +```json +{ + "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). + +```bash +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`): + +```json +{ + "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`): + +```bash +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`). + +```bash +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. + +```bash +# 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: + +```json +{ + "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. + +```bash +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: + +```bash +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. + +```bash +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. + +```bash +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. + +```bash +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. + +```bash +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. + +```bash +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. + +```bash +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: + +```json +{ "detail": "Change request not found: CR/9999/99999" } +``` + +### Full Lifecycle Example + +A complete workflow from creation to application: + +```bash +# 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 +``` diff --git a/spp_api_v2_change_request/static/description/index.html b/spp_api_v2_change_request/static/description/index.html index 4c0c788f..735f9b3f 100644 --- a/spp_api_v2_change_request/static/description/index.html +++ b/spp_api_v2_change_request/static/description/index.html @@ -369,12 +369,14 @@

OpenSPP API V2 - Change Request

!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! !! source digest: sha256:ac0243c931848a9215f89c328fce09d312e30b92ac9c3c1f141bfe26b4fef453 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! --> -

Beta License: LGPL-3 OpenSPP/OpenSPP2

+

Production/Stable License: LGPL-3 OpenSPP/OpenSPP2

Exposes REST API endpoints for managing change requests through the OpenSPP API V2 framework. Allows external systems to create change requests, submit them for approval, and apply approved changes to registrants via OAuth 2.0 authenticated API calls. Uses CR reference -numbers (CR/2024/00001) instead of database IDs for all operations.

+numbers (CR/2024/00001) instead of database IDs for all operations. +Auto-installs when both spp_api_v2 and spp_change_request_v2 are +present.

Key Capabilities

@@ -408,11 +413,16 @@

Key Models

Extended to register ChangeRequest router with API V2 +spp.api.client.scope +Extended to add change_request as +a resource selection + spp.change.request CRUD operations via REST API spp.change.request.type -Looked up by code in create requests +Looked up by code; provides type +metadata and field schemas 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:
  • 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

    +
    +

    Usage

    +
    + +
    +

    Prerequisites

    +

    Before testing the Change Request API, ensure you have:

    +
      +
    1. A running OpenSPP instance with spp_api_v2_change_request +installed
    2. +
    3. An API client configured with change_request:all scope (or +individual scopes per endpoint)
    4. +
    5. At least one registrant with an external identifier (e.g., via +spp.registry.id)
    6. +
    7. At least one active Change Request type (e.g., edit_individual)
    8. +
    +

    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:

    + +

    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

    + +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    StatusMeaningExample Cause
    401UnauthorizedMissing or expired Bearer token
    403ForbiddenClient lacks the required scope
    404Not FoundCR reference does not exist
    409ConflictVersion mismatch, wrong state for action
    422Unprocessable EntityInvalid type code, unknown detail fields
    500Internal Server ErrorUnexpected 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

    +

    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 @@

    Bug Tracker

    Do not contact contributors directly about support or help with technical issues.

    -

    Credits

    +

    Credits

    -

    Authors

    +

    Authors

    • OpenSPP.org
    -

    Maintainers

    +

    Maintainers

    Current maintainers:

    jeremi gonzalesedwin1123 reichie020212 emjay0921

    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")