Skip to content

Commit 2453182

Browse files
committed
fix(spp_api_v2_change_request): fix URL encoding, silent failures, and validation gaps
- Fix pagination URLs not being URL-encoded (broken links with pipe/colon chars) - Raise ValidationError on unresolved vocabulary/partner lookups instead of silently skipping - Add detail input validation against type schema (unknown, internal, readonly fields) - Fix manifest: PATCH→PUT, add missing $request-revision and $reset endpoints - Remove unused ChangeRequestAction and ChangeRequestSearchParams schemas - Remove unnecessary hasattr check for revision_notes (always on mixin) - Add comments explaining why _do_reject/_do_request_revision are called directly - Extract shared test fixtures into tests/common.py ChangeRequestTestCase - Add tests for reject/approve wrong state, unknown fields, unresolved vocab, readonly fields
1 parent 5ac7496 commit 2453182

9 files changed

Lines changed: 873 additions & 277 deletions

File tree

spp_api_v2_change_request/__manifest__.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,32 @@
2525
"summary": """
2626
REST API endpoints for Change Request V2.
2727
""",
28+
"description": """
29+
OpenSPP API V2 - Change Request
30+
================================
31+
32+
Extends OpenSPP API V2 with Change Request endpoints.
33+
34+
Endpoints
35+
---------
36+
- ``POST /ChangeRequest`` - Create a new change request
37+
- ``GET /ChangeRequest/{identifier}`` - Read a change request by reference
38+
- ``GET /ChangeRequest`` - Search change requests
39+
- ``PUT /ChangeRequest/{identifier}`` - Update change request detail data
40+
- ``POST /ChangeRequest/{identifier}/$submit`` - Submit for approval
41+
- ``POST /ChangeRequest/{identifier}/$approve`` - Approve (requires permission)
42+
- ``POST /ChangeRequest/{identifier}/$reject`` - Reject (requires permission)
43+
- ``POST /ChangeRequest/{identifier}/$request-revision`` - Request revision (requires permission)
44+
- ``POST /ChangeRequest/{identifier}/$apply`` - Apply changes to registrant
45+
- ``POST /ChangeRequest/{identifier}/$reset`` - Reset rejected/revision CR to draft
46+
- ``GET /ChangeRequest/$types`` - List all active CR types
47+
- ``GET /ChangeRequest/$types/{code}`` - Get field schema for a CR type
48+
49+
Design Principles
50+
-----------------
51+
- Uses CR reference (CR/2024/00001), NOT database IDs
52+
- Returns appropriate HTTP status codes
53+
- Follows OpenSPP API V2 patterns
54+
- Requires authentication via OAuth 2.0
55+
""",
2856
}

spp_api_v2_change_request/routers/change_request.py

Lines changed: 81 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import logging
55
from typing import Annotated
6+
from urllib.parse import urlencode
67

78
from odoo.api import Environment
89
from odoo.exceptions import UserError, ValidationError
@@ -29,6 +30,8 @@
2930
ApproveActionData,
3031
ChangeRequestCreate,
3132
ChangeRequestResponse,
33+
ChangeRequestTypeInfo,
34+
ChangeRequestTypeSchema,
3235
ChangeRequestUpdate,
3336
RejectActionData,
3437
RequestRevisionActionData,
@@ -92,6 +95,66 @@ async def create_change_request(
9295
return service.to_api_schema(cr)
9396

9497

98+
# Type schema endpoints are registered before /{p1}/{p2}/{p3} so that FastAPI
99+
# matches "$types" as a literal path segment rather than capturing it as
100+
# path parameters.
101+
102+
103+
@change_request_router.get(
104+
"/$types",
105+
response_model=list[ChangeRequestTypeInfo],
106+
)
107+
async def list_change_request_types(
108+
env: Annotated[Environment, Depends(odoo_env)],
109+
api_client: Annotated[dict, Depends(get_authenticated_client)],
110+
):
111+
"""
112+
List all active Change Request types.
113+
114+
Returns type code, name, target type, and whether an applicant is required.
115+
"""
116+
if not api_client.has_scope("change_request", "read"):
117+
raise HTTPException(
118+
status_code=status.HTTP_403_FORBIDDEN,
119+
detail="Client does not have permission to read change request types",
120+
)
121+
122+
service = ChangeRequestService(env)
123+
return service.get_type_list()
124+
125+
126+
@change_request_router.get(
127+
"/$types/{code}",
128+
response_model=ChangeRequestTypeSchema,
129+
)
130+
async def get_change_request_type_schema(
131+
code: Annotated[str, Path(description="CR type code (e.g., edit_individual)")],
132+
env: Annotated[Environment, Depends(odoo_env)],
133+
api_client: Annotated[dict, Depends(get_authenticated_client)],
134+
):
135+
"""
136+
Get the full field schema for a Change Request type.
137+
138+
Returns type info, field definitions, and document requirements.
139+
"""
140+
if not api_client.has_scope("change_request", "read"):
141+
raise HTTPException(
142+
status_code=status.HTTP_403_FORBIDDEN,
143+
detail="Client does not have permission to read change request types",
144+
)
145+
146+
service = ChangeRequestService(env)
147+
result = service.get_type_schema(code)
148+
149+
if result is None:
150+
raise HTTPException(
151+
status_code=status.HTTP_404_NOT_FOUND,
152+
detail=f"Change request type not found: {code}",
153+
)
154+
155+
return result
156+
157+
95158
@change_request_router.get("/{p1}/{p2}/{p3}", response_model=ChangeRequestResponse)
96159
async def read_change_request(
97160
p1: Annotated[str, Path(description="Reference part 1 (e.g., CR)")],
@@ -182,45 +245,26 @@ async def search_change_requests(
182245

183246
# Build pagination URLs
184247
base_url = "/api/v2/spp/ChangeRequest"
185-
query_parts = []
186-
if registrant:
187-
query_parts.append(f"registrant={registrant}")
188-
if request_type:
189-
query_parts.append(f"requestType={request_type}")
190-
if cr_status:
191-
query_parts.append(f"status={cr_status}")
192-
if created_after:
193-
query_parts.append(f"createdAfter={created_after}")
194-
if created_before:
195-
query_parts.append(f"createdBefore={created_before}")
196-
query_params = "&".join(query_parts)
197-
198-
# Build self URL
199-
self_url = (
200-
f"{base_url}?{query_params}&_count={count}&_offset={offset}"
201-
if query_params
202-
else f"{base_url}?_count={count}&_offset={offset}"
203-
)
248+
base_params = {
249+
k: v
250+
for k, v in {
251+
"registrant": registrant,
252+
"requestType": request_type,
253+
"status": cr_status,
254+
"createdAfter": created_after,
255+
"createdBefore": created_before,
256+
}.items()
257+
if v is not None
258+
}
204259

205-
# Build next URL
206-
next_url = None
207-
if offset + count < total:
208-
next_offset = offset + count
209-
next_url = (
210-
f"{base_url}?{query_params}&_count={count}&_offset={next_offset}"
211-
if query_params
212-
else f"{base_url}?_count={count}&_offset={next_offset}"
213-
)
260+
def build_url(offset_val: int) -> str:
261+
"""Build properly URL-encoded pagination URL."""
262+
url_params = {**base_params, "_count": count, "_offset": offset_val}
263+
return f"{base_url}?{urlencode(url_params)}"
214264

215-
# Build prev URL
216-
prev_url = None
217-
if offset > 0:
218-
prev_offset = max(0, offset - count)
219-
prev_url = (
220-
f"{base_url}?{query_params}&_count={count}&_offset={prev_offset}"
221-
if query_params
222-
else f"{base_url}?_count={count}&_offset={prev_offset}"
223-
)
265+
self_url = build_url(offset)
266+
next_url = build_url(offset + count) if offset + count < total else None
267+
prev_url = build_url(max(0, offset - count)) if offset > 0 else None
224268

225269
return create_search_result(
226270
data=data,

spp_api_v2_change_request/schemas/change_request.py

Lines changed: 53 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
22
"""Pydantic schemas for Change Request API."""
33

4-
from datetime import date, datetime
4+
from datetime import datetime
55
from typing import Any, Literal
66

77
from pydantic import BaseModel, Field
@@ -161,19 +161,6 @@ class Config:
161161
}
162162

163163

164-
class ChangeRequestAction(BaseModel):
165-
"""Schema for CR action requests (submit, approve, reject)."""
166-
167-
reason: str | None = Field(
168-
None,
169-
description="Reason for rejection or revision notes",
170-
)
171-
comment: str | None = Field(
172-
None,
173-
description="Optional comment for approval",
174-
)
175-
176-
177164
class ApproveActionData(BaseModel):
178165
"""Data for approve action."""
179166

@@ -203,31 +190,61 @@ class RequestRevisionActionData(BaseModel):
203190
)
204191

205192

206-
class ChangeRequestSearchParams(BaseModel):
207-
"""Search parameters for change requests."""
193+
class FieldChoice(BaseModel):
194+
"""Value/label pair for selection choices, vocabulary codes, and documents."""
208195

209-
registrant: str | None = Field(
210-
None,
211-
description="Registrant identifier (system|value)",
212-
)
213-
request_type: str | None = Field(
214-
None,
215-
alias="requestType",
216-
description="Type code to filter by",
217-
)
218-
status: str | None = Field(
219-
None,
220-
description="Status to filter by",
196+
value: str = Field(..., description="Machine-readable value or code")
197+
label: str = Field(..., description="Human-readable display label")
198+
199+
200+
class VocabularyInfo(BaseModel):
201+
"""Vocabulary namespace and available codes for a vocabulary field."""
202+
203+
namespace_uri: str = Field(..., alias="namespaceUri", description="Vocabulary namespace URI")
204+
codes: list[FieldChoice] = Field(default_factory=list, description="Available codes")
205+
206+
class Config:
207+
populate_by_name = True
208+
209+
210+
class FieldDefinition(BaseModel):
211+
"""Schema definition for a single field on a CR detail model."""
212+
213+
name: str = Field(..., description="Field name")
214+
label: str = Field(..., description="Human-readable field label")
215+
type: str = Field(
216+
...,
217+
description="Field type (string, text, integer, float, boolean, date, datetime, selection, code, reference)",
221218
)
222-
created_after: date | None = Field(
223-
None,
224-
alias="createdAfter",
225-
description="Created on or after this date",
219+
required: bool = Field(False, description="Whether the field is required")
220+
readonly: bool = Field(False, description="Whether the field is read-only or computed")
221+
help: str | None = Field(None, description="Help text for the field")
222+
choices: list[FieldChoice] | None = Field(None, description="Available choices for selection fields")
223+
vocabulary: VocabularyInfo | None = Field(None, description="Vocabulary info for code fields")
224+
225+
226+
class ChangeRequestTypeInfo(BaseModel):
227+
"""Summary info for a CR type."""
228+
229+
code: str = Field(..., description="Type code (e.g., add_member)")
230+
name: str = Field(..., description="Human-readable type name")
231+
target_type: str = Field(..., alias="targetType", description="Target registrant type (individual, group, both)")
232+
requires_applicant: bool = Field(False, alias="requiresApplicant", description="Whether an applicant is required")
233+
234+
class Config:
235+
populate_by_name = True
236+
237+
238+
class ChangeRequestTypeSchema(BaseModel):
239+
"""Full schema for a CR type including field definitions."""
240+
241+
type_info: ChangeRequestTypeInfo = Field(..., alias="typeInfo", description="Type summary")
242+
fields: list[FieldDefinition] = Field(default_factory=list, description="Available detail fields")
243+
available_documents: list[FieldChoice] = Field(
244+
default_factory=list, alias="availableDocuments", description="Documents that can be attached"
226245
)
227-
created_before: date | None = Field(
228-
None,
229-
alias="createdBefore",
230-
description="Created on or before this date",
246+
required_documents: list[FieldChoice] = Field(
247+
default_factory=list, alias="requiredDocuments", description="Documents that must be attached"
231248
)
232249

233250
class Config:

0 commit comments

Comments
 (0)