From 709617ea8408490e0b803783d88fc03809edb416 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 7 Feb 2026 16:39:28 +0700 Subject: [PATCH 1/5] fix(spp_api_v2_change_request): fix URL encoding, silent failures, and validation gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- spp_api_v2_change_request/__manifest__.py | 28 ++ .../routers/change_request.py | 118 ++++-- .../schemas/change_request.py | 89 +++-- .../services/change_request_service.py | 347 ++++++++++++++++-- spp_api_v2_change_request/tests/__init__.py | 2 + spp_api_v2_change_request/tests/common.py | 133 +++++++ .../tests/test_change_request_api.py | 77 +--- .../tests/test_change_request_service.py | 180 +++++---- .../tests/test_change_request_type_schema.py | 176 +++++++++ 9 files changed, 873 insertions(+), 277 deletions(-) create mode 100644 spp_api_v2_change_request/tests/common.py create mode 100644 spp_api_v2_change_request/tests/test_change_request_type_schema.py diff --git a/spp_api_v2_change_request/__manifest__.py b/spp_api_v2_change_request/__manifest__.py index 234198b9..6cda90e1 100644 --- a/spp_api_v2_change_request/__manifest__.py +++ b/spp_api_v2_change_request/__manifest__.py @@ -25,4 +25,32 @@ "summary": """ REST API endpoints for Change Request V2. """, + "description": """ +OpenSPP API V2 - Change Request +================================ + +Extends OpenSPP API V2 with Change Request endpoints. + +Endpoints +--------- +- ``POST /ChangeRequest`` - Create a new change request +- ``GET /ChangeRequest/{identifier}`` - Read a change request by reference +- ``GET /ChangeRequest`` - Search change requests +- ``PUT /ChangeRequest/{identifier}`` - Update change request detail data +- ``POST /ChangeRequest/{identifier}/$submit`` - Submit for approval +- ``POST /ChangeRequest/{identifier}/$approve`` - Approve (requires permission) +- ``POST /ChangeRequest/{identifier}/$reject`` - Reject (requires permission) +- ``POST /ChangeRequest/{identifier}/$request-revision`` - Request revision (requires permission) +- ``POST /ChangeRequest/{identifier}/$apply`` - Apply changes to registrant +- ``POST /ChangeRequest/{identifier}/$reset`` - Reset rejected/revision CR to draft +- ``GET /ChangeRequest/$types`` - List all active CR types +- ``GET /ChangeRequest/$types/{code}`` - Get field schema for a CR type + +Design Principles +----------------- +- Uses CR reference (CR/2024/00001), NOT database IDs +- Returns appropriate HTTP status codes +- Follows OpenSPP API V2 patterns +- Requires authentication via OAuth 2.0 + """, } diff --git a/spp_api_v2_change_request/routers/change_request.py b/spp_api_v2_change_request/routers/change_request.py index 5f17b235..85a9945b 100644 --- a/spp_api_v2_change_request/routers/change_request.py +++ b/spp_api_v2_change_request/routers/change_request.py @@ -3,6 +3,7 @@ import logging from typing import Annotated +from urllib.parse import urlencode from odoo.api import Environment from odoo.exceptions import UserError, ValidationError @@ -29,6 +30,8 @@ ApproveActionData, ChangeRequestCreate, ChangeRequestResponse, + ChangeRequestTypeInfo, + ChangeRequestTypeSchema, ChangeRequestUpdate, RejectActionData, RequestRevisionActionData, @@ -92,6 +95,66 @@ async def create_change_request( return service.to_api_schema(cr) +# Type schema endpoints are registered before /{p1}/{p2}/{p3} so that FastAPI +# matches "$types" as a literal path segment rather than capturing it as +# path parameters. + + +@change_request_router.get( + "/$types", + response_model=list[ChangeRequestTypeInfo], +) +async def list_change_request_types( + env: Annotated[Environment, Depends(odoo_env)], + api_client: Annotated[dict, Depends(get_authenticated_client)], +): + """ + List all active Change Request types. + + Returns type code, name, target type, and whether an applicant is required. + """ + if not api_client.has_scope("change_request", "read"): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Client does not have permission to read change request types", + ) + + service = ChangeRequestService(env) + return service.get_type_list() + + +@change_request_router.get( + "/$types/{code}", + response_model=ChangeRequestTypeSchema, +) +async def get_change_request_type_schema( + code: Annotated[str, Path(description="CR type code (e.g., edit_individual)")], + env: Annotated[Environment, Depends(odoo_env)], + api_client: Annotated[dict, Depends(get_authenticated_client)], +): + """ + Get the full field schema for a Change Request type. + + Returns type info, field definitions, and document requirements. + """ + if not api_client.has_scope("change_request", "read"): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Client does not have permission to read change request types", + ) + + service = ChangeRequestService(env) + result = service.get_type_schema(code) + + if result is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Change request type not found: {code}", + ) + + return result + + @change_request_router.get("/{p1}/{p2}/{p3}", response_model=ChangeRequestResponse) async def read_change_request( p1: Annotated[str, Path(description="Reference part 1 (e.g., CR)")], @@ -182,45 +245,26 @@ async def search_change_requests( # Build pagination URLs base_url = "/api/v2/spp/ChangeRequest" - query_parts = [] - if registrant: - query_parts.append(f"registrant={registrant}") - if request_type: - query_parts.append(f"requestType={request_type}") - if cr_status: - query_parts.append(f"status={cr_status}") - if created_after: - query_parts.append(f"createdAfter={created_after}") - if created_before: - query_parts.append(f"createdBefore={created_before}") - query_params = "&".join(query_parts) - - # Build self URL - self_url = ( - f"{base_url}?{query_params}&_count={count}&_offset={offset}" - if query_params - else f"{base_url}?_count={count}&_offset={offset}" - ) + base_params = { + k: v + for k, v in { + "registrant": registrant, + "requestType": request_type, + "status": cr_status, + "createdAfter": created_after, + "createdBefore": created_before, + }.items() + if v is not None + } - # Build next URL - next_url = None - if offset + count < total: - next_offset = offset + count - next_url = ( - f"{base_url}?{query_params}&_count={count}&_offset={next_offset}" - if query_params - else f"{base_url}?_count={count}&_offset={next_offset}" - ) + def build_url(offset_val: int) -> str: + """Build properly URL-encoded pagination URL.""" + url_params = {**base_params, "_count": count, "_offset": offset_val} + return f"{base_url}?{urlencode(url_params)}" - # Build prev URL - prev_url = None - if offset > 0: - prev_offset = max(0, offset - count) - prev_url = ( - f"{base_url}?{query_params}&_count={count}&_offset={prev_offset}" - if query_params - else f"{base_url}?_count={count}&_offset={prev_offset}" - ) + self_url = build_url(offset) + next_url = build_url(offset + count) if offset + count < total else None + prev_url = build_url(max(0, offset - count)) if offset > 0 else None return create_search_result( data=data, diff --git a/spp_api_v2_change_request/schemas/change_request.py b/spp_api_v2_change_request/schemas/change_request.py index 3cda60dd..b6cffa38 100644 --- a/spp_api_v2_change_request/schemas/change_request.py +++ b/spp_api_v2_change_request/schemas/change_request.py @@ -1,7 +1,7 @@ # Part of OpenSPP. See LICENSE file for full copyright and licensing details. """Pydantic schemas for Change Request API.""" -from datetime import date, datetime +from datetime import datetime from typing import Any, Literal from pydantic import BaseModel, Field @@ -161,19 +161,6 @@ class Config: } -class ChangeRequestAction(BaseModel): - """Schema for CR action requests (submit, approve, reject).""" - - reason: str | None = Field( - None, - description="Reason for rejection or revision notes", - ) - comment: str | None = Field( - None, - description="Optional comment for approval", - ) - - class ApproveActionData(BaseModel): """Data for approve action.""" @@ -203,31 +190,61 @@ class RequestRevisionActionData(BaseModel): ) -class ChangeRequestSearchParams(BaseModel): - """Search parameters for change requests.""" +class FieldChoice(BaseModel): + """Value/label pair for selection choices, vocabulary codes, and documents.""" - registrant: str | None = Field( - None, - description="Registrant identifier (system|value)", - ) - request_type: str | None = Field( - None, - alias="requestType", - description="Type code to filter by", - ) - status: str | None = Field( - None, - description="Status to filter by", + value: str = Field(..., description="Machine-readable value or code") + label: str = Field(..., description="Human-readable display label") + + +class VocabularyInfo(BaseModel): + """Vocabulary namespace and available codes for a vocabulary field.""" + + namespace_uri: str = Field(..., alias="namespaceUri", description="Vocabulary namespace URI") + codes: list[FieldChoice] = Field(default_factory=list, description="Available codes") + + class Config: + populate_by_name = True + + +class FieldDefinition(BaseModel): + """Schema definition for a single field on a CR detail model.""" + + name: str = Field(..., description="Field name") + label: str = Field(..., description="Human-readable field label") + type: str = Field( + ..., + description="Field type (string, text, integer, float, boolean, date, datetime, selection, code, reference)", ) - created_after: date | None = Field( - None, - alias="createdAfter", - description="Created on or after this date", + required: bool = Field(False, description="Whether the field is required") + readonly: bool = Field(False, description="Whether the field is read-only or computed") + help: str | None = Field(None, description="Help text for the field") + choices: list[FieldChoice] | None = Field(None, description="Available choices for selection fields") + vocabulary: VocabularyInfo | None = Field(None, description="Vocabulary info for code fields") + + +class ChangeRequestTypeInfo(BaseModel): + """Summary info for a CR type.""" + + code: str = Field(..., description="Type code (e.g., add_member)") + name: str = Field(..., description="Human-readable type name") + target_type: str = Field(..., alias="targetType", description="Target registrant type (individual, group, both)") + requires_applicant: bool = Field(False, alias="requiresApplicant", description="Whether an applicant is required") + + class Config: + populate_by_name = True + + +class ChangeRequestTypeSchema(BaseModel): + """Full schema for a CR type including field definitions.""" + + type_info: ChangeRequestTypeInfo = Field(..., alias="typeInfo", description="Type summary") + fields: list[FieldDefinition] = Field(default_factory=list, description="Available detail fields") + available_documents: list[FieldChoice] = Field( + default_factory=list, alias="availableDocuments", description="Documents that can be attached" ) - created_before: date | None = Field( - None, - alias="createdBefore", - description="Created on or before this date", + required_documents: list[FieldChoice] = Field( + default_factory=list, alias="requiredDocuments", description="Documents that must be attached" ) class Config: diff --git a/spp_api_v2_change_request/services/change_request_service.py b/spp_api_v2_change_request/services/change_request_service.py index dafebe26..fab46151 100644 --- a/spp_api_v2_change_request/services/change_request_service.py +++ b/spp_api_v2_change_request/services/change_request_service.py @@ -1,6 +1,7 @@ # Part of OpenSPP. See LICENSE file for full copyright and licensing details. """Service for Change Request API operations.""" +import ast import logging from typing import Any @@ -13,6 +14,62 @@ _logger = logging.getLogger(__name__) +# Fields to exclude from detail serialization and field schema generation. +# Matches the exclusion lists in _serialize_detail and _compute_available_field_ids. +DETAIL_SKIP_FIELDS = { + "id", + "create_uid", + "create_date", + "write_uid", + "write_date", + "__last_update", + "display_name", + "change_request_id", + # Convenience computed fields from spp.cr.detail.base + "registrant_id", + "approval_state", + "is_applied", + # Messaging/activity fields (from mail.thread, mail.activity.mixin) + "message_ids", + "message_follower_ids", + "message_partner_ids", + "message_attachment_count", + "website_message_ids", + "activity_ids", + "activity_state", + "activity_user_id", + "activity_type_id", + "activity_date_deadline", + "activity_summary", + "message_is_follower", + "message_needaction", + "message_needaction_counter", + "message_has_error", + "message_has_error_counter", + "message_unread", + "message_unread_counter", + "message_main_attachment_id", + "has_message", + "rating_ids", +} + +# Mapping from Odoo field types to API field types +ODOO_TYPE_MAP = { + "char": "string", + "text": "text", + "html": "text", + "integer": "integer", + "float": "float", + "monetary": "float", + "boolean": "boolean", + "date": "date", + "datetime": "datetime", + "selection": "selection", +} + +# Field types that are skipped entirely in field definitions +SKIP_FIELD_TYPES = {"binary", "many2many", "one2many"} + class ChangeRequestService: """Service for Change Request resource CRUD and mapping.""" @@ -131,7 +188,7 @@ def to_api_schema(self, cr) -> dict[str, Any]: response["rejectedDate"] = cr.rejected_date.isoformat() if cr.rejection_reason: response["rejectionReason"] = cr.rejection_reason - if hasattr(cr, "revision_notes") and cr.revision_notes: + if cr.revision_notes: response["revisionNotes"] = cr.revision_notes # Description and notes @@ -162,38 +219,8 @@ def _serialize_detail(self, detail) -> dict[str, Any]: # Get fields from the model model_fields = detail._fields - # Skip internal, computed, and mail.thread fields - skip_fields = { - "id", - "create_uid", - "create_date", - "write_uid", - "write_date", - "__last_update", - "display_name", - "change_request_id", - # Convenience computed fields from base - "registrant_id", - "approval_state", - "is_applied", - # mail.thread fields - "message_ids", - "message_follower_ids", - "message_partner_ids", - "message_is_follower", - "has_message", - "message_needaction", - "message_needaction_counter", - "message_has_error", - "message_has_error_counter", - "message_attachment_count", - "message_has_sms_error", - "message_main_attachment_id", - "website_message_ids", - } - for field_name, field in model_fields.items(): - if field_name.startswith("_") or field_name in skip_fields: + if field_name.startswith("_") or field_name in DETAIL_SKIP_FIELDS: continue value = getattr(detail, field_name) @@ -314,6 +341,9 @@ def update_detail(self, cr, detail_data: dict[str, Any]): if not detail: raise ValidationError("Change request has no detail record") + # Validate fields against the type schema + self._validate_detail_input(cr.request_type_id, detail, detail_data) + # Convert API data to Odoo vals vals = self._deserialize_detail(detail, detail_data) if vals: @@ -324,17 +354,15 @@ def _deserialize_detail(self, detail, data: dict[str, Any]) -> dict[str, Any]: Convert API detail data to Odoo vals. Handles Many2one lookups by namespace_uri/code. + Raises ValidationError if any lookups fail. """ vals = {} + unresolved = [] model_fields = detail._fields for field_name, value in data.items(): if field_name not in model_fields: - _logger.warning( - "Unknown field %s for detail model %s", - field_name, - detail._name, - ) + # Unknown fields are caught by _validate_detail_input continue field = model_fields[field_name] @@ -352,6 +380,11 @@ def _deserialize_detail(self, detail, data: dict[str, Any]) -> dict[str, Any]: ) if code_rec: vals[field_name] = code_rec.id + else: + unresolved.append( + f"{field_name}: vocabulary code '{value['code']}' " + f"not found in namespace '{value['system']}'" + ) elif "system" in value and "value" in value: # Partner identifier partner = self.find_registrant_by_identifier( @@ -360,6 +393,8 @@ def _deserialize_detail(self, detail, data: dict[str, Any]) -> dict[str, Any]: ) if partner: vals[field_name] = partner.id + else: + unresolved.append(f"{field_name}: registrant '{value['system']}|{value['value']}' not found") elif field.type == "date" and isinstance(value, str): from datetime import date as dt_date @@ -371,8 +406,44 @@ def _deserialize_detail(self, detail, data: dict[str, Any]) -> dict[str, Any]: else: vals[field_name] = value + if unresolved: + raise ValidationError( + "Failed to resolve the following fields:\n" + "\n".join(f"- {msg}" for msg in unresolved) + ) + return vals + def _validate_detail_input(self, cr_type, detail_model, data: dict[str, Any]): + """ + Validate detail input data against the type's field schema. + + Rejects unknown fields and read-only fields so external consumers + get immediate feedback rather than silent data loss. + + Args: + cr_type: spp.change.request.type record + detail_model: The Odoo model instance for the detail + data: Dictionary of field values from API input + """ + errors = [] + model_fields = detail_model._fields + + # Build the set of valid writable field names from the schema + field_defs = self._build_field_definitions(cr_type, detail_model) + valid_fields = {f["name"] for f in field_defs} + readonly_fields = {f["name"] for f in field_defs if f["readonly"]} + + for field_name in data: + if field_name not in model_fields: + errors.append(f"Unknown field: '{field_name}'") + elif field_name not in valid_fields: + errors.append(f"Field '{field_name}' is not available for this CR type") + elif field_name in readonly_fields: + errors.append(f"Field '{field_name}' is read-only") + + if errors: + raise ValidationError("Detail validation errors:\n" + "\n".join(f"- {e}" for e in errors)) + def search(self, params: dict[str, Any]) -> tuple[list, int]: """ Search change requests. @@ -445,6 +516,9 @@ def reject(self, cr, reason: str): raise UserError("Only pending change requests can be rejected") if not reason: raise ValidationError("Rejection reason is required") + # action_reject() opens a wizard, so we call the underlying + # implementation directly. Same pattern as conflict_wizard and + # batch_approval_wizard. cr._do_reject(reason) def request_revision(self, cr, notes: str): @@ -453,6 +527,8 @@ def request_revision(self, cr, notes: str): raise UserError("Only pending change requests can have revision requested") if not notes: raise ValidationError("Revision notes are required") + # action_request_revision() opens a wizard, so we call the underlying + # implementation directly. Same pattern as batch_approval_wizard. cr._do_request_revision(notes) def apply(self, cr): @@ -468,3 +544,200 @@ def reset_to_draft(self, cr): if cr.approval_state not in ("rejected", "revision"): raise UserError("Only rejected or revision-requested change requests can be reset to draft") cr.action_reset_to_draft() + + # ══════════════════════════════════════════════════════════════════════════ + # TYPE SCHEMA ENDPOINTS + # ══════════════════════════════════════════════════════════════════════════ + + def get_type_list(self) -> list[dict[str, Any]]: + """ + Return a list of active CR types whose detail model exists. + + Returns: + List of dicts matching ChangeRequestTypeInfo schema. + """ + cr_types = self.env["spp.change.request.type"].search([]) + result = [] + for cr_type in cr_types: + if not cr_type.detail_model or cr_type.detail_model not in self.env: + continue + result.append( + { + "code": cr_type.code, + "name": cr_type.name, + "targetType": cr_type.target_type or "both", + "requiresApplicant": cr_type.is_requires_applicant, + } + ) + return result + + def get_type_schema(self, code: str) -> dict[str, Any] | None: + """ + Return the full field schema for a CR type by code. + + Args: + code: The CR type code (e.g. 'edit_individual'). + + Returns: + Dict matching ChangeRequestTypeSchema, or None if not found. + """ + cr_type = self.env["spp.change.request.type"].search( + [("code", "=", code)], + limit=1, + ) + if not cr_type: + return None + if not cr_type.detail_model or cr_type.detail_model not in self.env: + return None + + detail_model = self.env[cr_type.detail_model] + field_defs = self._build_field_definitions(cr_type, detail_model) + + # Documents + available_documents = [ + {"value": doc.code, "label": doc.display or doc.code} for doc in cr_type.available_document_ids + ] + required_documents = [ + {"value": doc.code, "label": doc.display or doc.code} for doc in cr_type.required_document_ids + ] + + return { + "typeInfo": { + "code": cr_type.code, + "name": cr_type.name, + "targetType": cr_type.target_type or "both", + "requiresApplicant": cr_type.is_requires_applicant, + }, + "fields": field_defs, + "availableDocuments": available_documents, + "requiredDocuments": required_documents, + } + + def _build_field_definitions(self, cr_type, detail_model) -> list[dict[str, Any]]: + """ + Build field definition dicts for all user-facing fields on a detail model. + + Args: + cr_type: spp.change.request.type record + detail_model: The Odoo model class for the detail + + Returns: + List of dicts matching FieldDefinition schema. + """ + result = [] + for field_name, field in detail_model._fields.items(): + if field_name.startswith("_"): + continue + if field_name in DETAIL_SKIP_FIELDS: + continue + if not field.store: + continue + if field.type in SKIP_FIELD_TYPES: + continue + + # Map Odoo type to API type + api_type = ODOO_TYPE_MAP.get(field.type) + if api_type is None and field.type == "many2one": + # Detect vocabulary vs generic reference + if field.comodel_name == "spp.vocabulary.code": + api_type = "code" + else: + api_type = "reference" + if api_type is None: + continue + + # Determine readonly: explicitly readonly OR has a compute method + is_readonly = bool(field.readonly) or bool(field.compute) + + field_def = { + "name": field_name, + "label": field.string or field_name, + "type": api_type, + "required": bool(field.required), + "readonly": is_readonly, + } + + if field.help: + field_def["help"] = field.help + + # Selection choices + if api_type == "selection": + field_def["choices"] = self._extract_selection_choices(field) + + # Vocabulary info for code fields + if api_type == "code": + domain_str = field.domain or "" + vocab_info = self._extract_vocabulary_info_from_domain( + str(domain_str) if domain_str else "", + field.comodel_name, + ) + field_def["vocabulary"] = vocab_info + + result.append(field_def) + + return result + + def _extract_selection_choices(self, field) -> list[dict[str, str]]: + """ + Extract selection choices from an Odoo field. + + Handles both list-of-tuples and callable selections. + """ + selection = field.selection + if callable(selection): + try: + selection = selection(self.env[field.model_name]) + except Exception: + _logger.debug("Could not evaluate callable selection for %s", field.name) + return [] + if not selection: + return [] + return [{"value": str(val), "label": label} for val, label in selection] + + def _extract_vocabulary_info_from_domain(self, domain_str: str, comodel_name: str) -> dict[str, Any] | None: + """ + Parse a domain string to extract vocabulary namespace and load codes. + + Args: + domain_str: String representation of an Odoo domain (e.g. + "[('namespace_uri', '=', 'urn:iso:std:iso:5218')]") + comodel_name: The comodel (expected to be 'spp.vocabulary.code') + + Returns: + Dict with namespaceUri and codes, or None if unparseable. + """ + if not domain_str: + return None + + try: + domain = ast.literal_eval(domain_str) + except (ValueError, SyntaxError): + # Domain contains Python name references (e.g., registrant_id) + return None + + if not isinstance(domain, list): + return None + + namespace_uri = None + for leaf in domain: + if not isinstance(leaf, (list, tuple)) or len(leaf) != 3: + continue + field_path, operator, value = leaf + if operator != "=": + continue + if field_path in ("namespace_uri", "vocabulary_id.namespace_uri"): + namespace_uri = value + break + + if not namespace_uri: + return None + + # Load codes from the vocabulary + codes = self.env[comodel_name].search( + [("namespace_uri", "=", namespace_uri)], + order="sequence, code", + ) + return { + "namespaceUri": namespace_uri, + "codes": [{"value": code.code, "label": code.display or code.code} for code in codes], + } diff --git a/spp_api_v2_change_request/tests/__init__.py b/spp_api_v2_change_request/tests/__init__.py index 86fc74eb..53612c7d 100644 --- a/spp_api_v2_change_request/tests/__init__.py +++ b/spp_api_v2_change_request/tests/__init__.py @@ -1,2 +1,4 @@ +from . import common from . import test_change_request_api from . import test_change_request_service +from . import test_change_request_type_schema diff --git a/spp_api_v2_change_request/tests/common.py b/spp_api_v2_change_request/tests/common.py new file mode 100644 index 00000000..1578b22c --- /dev/null +++ b/spp_api_v2_change_request/tests/common.py @@ -0,0 +1,133 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Common test utilities for Change Request API V2 tests.""" + +from odoo.tests import TransactionCase + +from ..services.change_request_service import ChangeRequestService + + +class ChangeRequestTestCase(TransactionCase): + """Base class for Change Request API V2 unit tests. + + Provides shared fixtures: vocabularies, registrants, CR types, + and a service helper. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner_model = cls.env["res.partner"] + cls.cr_model = cls.env["spp.change.request"] + + # Get or create ID Type vocabulary + id_type_vocab = cls.env["spp.vocabulary"].search([("namespace_uri", "=", "urn:openspp:vocab:id-type")], limit=1) + if not id_type_vocab: + id_type_vocab = cls.env["spp.vocabulary"].create( + { + "name": "ID Type", + "namespace_uri": "urn:openspp:vocab:id-type", + } + ) + + # Create ID type as vocabulary code + cls.id_type = cls.env["spp.vocabulary.code"].search( + [ + ("vocabulary_id", "=", id_type_vocab.id), + ("code", "=", "test_national_id"), + ], + limit=1, + ) + if not cls.id_type: + cls.id_type = cls.env["spp.vocabulary.code"].create( + { + "vocabulary_id": id_type_vocab.id, + "code": "test_national_id", + "display": "Test National ID", + "is_local": True, + "target_type": "individual", + } + ) + + # Create test registrant + cls.registrant = cls.partner_model.create( + { + "name": "Test Registrant", + "is_registrant": True, + "is_group": False, + } + ) + cls.env["spp.registry.id"].create( + { + "partner_id": cls.registrant.id, + "id_type_id": cls.id_type.id, + "value": "TEST-123", + } + ) + + # Create test group + cls.group = cls.partner_model.create( + { + "name": "Test Group", + "is_registrant": True, + "is_group": True, + } + ) + cls.env["spp.registry.id"].create( + { + "partner_id": cls.group.id, + "id_type_id": cls.id_type.id, + "value": "GROUP-123", + } + ) + + # Get or create CR type + cls.cr_type_edit = cls.env.ref( + "spp_change_request_v2.cr_type_edit_individual", + raise_if_not_found=False, + ) + if not cls.cr_type_edit: + cls.cr_type_edit = cls.env.ref( + "spp_cr_types_base.cr_type_edit_individual", + raise_if_not_found=False, + ) + if not cls.cr_type_edit: + cls.cr_type_edit = cls.env["spp.change.request.type"].search([("code", "=", "edit_individual")], limit=1) + if not cls.cr_type_edit: + cls.cr_type_edit = cls.env["spp.change.request.type"].create( + { + "name": "Edit Individual", + "code": "edit_individual", + "target_type": "individual", + "detail_model": "spp.cr.detail.edit_individual", + } + ) + + # Gender vocabulary (needed by type schema tests, available to all) + gender_vocab = cls.env["spp.vocabulary"].search([("namespace_uri", "=", "urn:iso:std:iso:5218")], limit=1) + if not gender_vocab: + gender_vocab = cls.env["spp.vocabulary"].create( + { + "name": "Gender (ISO 5218)", + "namespace_uri": "urn:iso:std:iso:5218", + } + ) + cls.gender_vocab = gender_vocab + + for code, display in [("1", "Male"), ("2", "Female")]: + existing = cls.env["spp.vocabulary.code"].search( + [("vocabulary_id", "=", gender_vocab.id), ("code", "=", code)], + limit=1, + ) + if not existing: + cls.env["spp.vocabulary.code"].create( + { + "vocabulary_id": gender_vocab.id, + "code": code, + "display": display, + } + ) + + @classmethod + def _get_service(cls): + """Return a ChangeRequestService instance.""" + return ChangeRequestService(cls.env) 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 28813acd..bd612c74 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 @@ -1,89 +1,16 @@ # Part of OpenSPP. See LICENSE file for full copyright and licensing details. """Tests for Change Request API endpoints.""" -from odoo.tests import TransactionCase +from .common import ChangeRequestTestCase -class TestChangeRequestAPI(TransactionCase): +class TestChangeRequestAPI(ChangeRequestTestCase): """Tests for Change Request API endpoints. These tests verify the API endpoint logic. Full integration tests require FastAPI test client setup. """ - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.partner_model = cls.env["res.partner"] - cls.cr_model = cls.env["spp.change.request"] - - # Get or create ID Type vocabulary - id_type_vocab = cls.env["spp.vocabulary"].search([("namespace_uri", "=", "urn:openspp:vocab:id-type")], limit=1) - if not id_type_vocab: - id_type_vocab = cls.env["spp.vocabulary"].create( - { - "name": "ID Type", - "namespace_uri": "urn:openspp:vocab:id-type", - } - ) - - # Create ID type as vocabulary code - cls.id_type = cls.env["spp.vocabulary.code"].search( - [ - ("vocabulary_id", "=", id_type_vocab.id), - ("code", "=", "test_national_id"), - ], - limit=1, - ) - if not cls.id_type: - cls.id_type = cls.env["spp.vocabulary.code"].create( - { - "vocabulary_id": id_type_vocab.id, - "code": "test_national_id", - "display": "Test National ID", - "is_local": True, - "target_type": "individual", - } - ) - - # Create test registrant - cls.registrant = cls.partner_model.create( - { - "name": "Test Registrant", - "is_registrant": True, - "is_group": False, - } - ) - cls.env["spp.registry.id"].create( - { - "partner_id": cls.registrant.id, - "id_type_id": cls.id_type.id, - "value": "TEST-123", - } - ) - - # Get or create CR type - cls.cr_type_edit = cls.env.ref( - "spp_change_request_v2.cr_type_edit_individual", - raise_if_not_found=False, - ) - if not cls.cr_type_edit: - cls.cr_type_edit = cls.env.ref( - "spp_cr_types_base.cr_type_edit_individual", - raise_if_not_found=False, - ) - if not cls.cr_type_edit: - cls.cr_type_edit = cls.env["spp.change.request.type"].search([("code", "=", "edit_individual")], limit=1) - if not cls.cr_type_edit: - cls.cr_type_edit = cls.env["spp.change.request.type"].create( - { - "name": "Edit Individual", - "code": "edit_individual", - "target_type": "individual", - "detail_model": "spp.cr.detail.edit_individual", - } - ) - def test_router_included(self): """Test that CR router is included in API V2.""" endpoint = self.env["fastapi.endpoint"].search( 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 6755f781..ed188152 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 @@ -1,8 +1,7 @@ # Part of OpenSPP. See LICENSE file for full copyright and licensing details. """Tests for ChangeRequestService.""" -from odoo.exceptions import ValidationError -from odoo.tests import TransactionCase +from odoo.exceptions import UserError, ValidationError from ..schemas.change_request import ( ChangeRequestCreate, @@ -10,100 +9,12 @@ RegistrantRef, ) from ..services.change_request_service import ChangeRequestService +from .common import ChangeRequestTestCase -class TestChangeRequestService(TransactionCase): +class TestChangeRequestService(ChangeRequestTestCase): """Tests for ChangeRequestService.""" - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.partner_model = cls.env["res.partner"] - cls.cr_model = cls.env["spp.change.request"] - - # Get or create ID Type vocabulary - id_type_vocab = cls.env["spp.vocabulary"].search([("namespace_uri", "=", "urn:openspp:vocab:id-type")], limit=1) - if not id_type_vocab: - id_type_vocab = cls.env["spp.vocabulary"].create( - { - "name": "ID Type", - "namespace_uri": "urn:openspp:vocab:id-type", - } - ) - - # Create ID type as vocabulary code - cls.id_type = cls.env["spp.vocabulary.code"].search( - [ - ("vocabulary_id", "=", id_type_vocab.id), - ("code", "=", "test_national_id"), - ], - limit=1, - ) - if not cls.id_type: - cls.id_type = cls.env["spp.vocabulary.code"].create( - { - "vocabulary_id": id_type_vocab.id, - "code": "test_national_id", - "display": "Test National ID", - "is_local": True, - "target_type": "individual", - } - ) - - # Create test registrant with identifier - cls.registrant = cls.partner_model.create( - { - "name": "Test Registrant", - "is_registrant": True, - "is_group": False, - } - ) - cls.env["spp.registry.id"].create( - { - "partner_id": cls.registrant.id, - "id_type_id": cls.id_type.id, - "value": "TEST-123", - } - ) - - # Create test group - cls.group = cls.partner_model.create( - { - "name": "Test Group", - "is_registrant": True, - "is_group": True, - } - ) - cls.env["spp.registry.id"].create( - { - "partner_id": cls.group.id, - "id_type_id": cls.id_type.id, - "value": "GROUP-123", - } - ) - - # Get or create CR type - cls.cr_type_edit = cls.env.ref( - "spp_change_request_v2.cr_type_edit_individual", - raise_if_not_found=False, - ) - if not cls.cr_type_edit: - cls.cr_type_edit = cls.env.ref( - "spp_cr_types_base.cr_type_edit_individual", - raise_if_not_found=False, - ) - if not cls.cr_type_edit: - cls.cr_type_edit = cls.env["spp.change.request.type"].search([("code", "=", "edit_individual")], limit=1) - if not cls.cr_type_edit: - cls.cr_type_edit = cls.env["spp.change.request.type"].create( - { - "name": "Edit Individual", - "code": "edit_individual", - "target_type": "individual", - "detail_model": "spp.cr.detail.edit_individual", - } - ) - def test_find_registrant_by_identifier(self): """Test finding registrant by external identifier.""" service = ChangeRequestService(self.env) @@ -249,3 +160,88 @@ def test_search(self): # Search by status records, total = service.search({"status": "draft"}) self.assertGreaterEqual(total, 2) + + # ────────────────────────────────────────────────────────────────────── + # State validation tests + # ────────────────────────────────────────────────────────────────────── + + def test_reject_non_pending_raises(self): + """Rejecting a 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.reject(cr, reason="test rejection") + + def test_approve_non_pending_raises(self): + """Approving a 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.approve(cr, comment="looks good") + + # ────────────────────────────────────────────────────────────────────── + # Detail validation tests + # ────────────────────────────────────────────────────────────────────── + + def test_update_detail_unknown_field_raises(self): + """Unknown fields in detail data raise ValidationError.""" + 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(ValidationError): + service.update_detail(cr, {"nonexistent_field_xyz": "value"}) + + def test_update_detail_unresolved_vocabulary_raises(self): + """Unresolved vocabulary code in detail data raises ValidationError.""" + 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(ValidationError): + service.update_detail( + cr, + { + "gender_id": { + "system": "urn:iso:std:iso:5218", + "code": "nonexistent_code", + }, + }, + ) + + def test_update_detail_readonly_field_raises(self): + """Sending a readonly/computed field in detail data raises ValidationError.""" + service = ChangeRequestService(self.env) + cr = self.cr_model.create( + { + "request_type_id": self.cr_type_edit.id, + "registrant_id": self.registrant.id, + } + ) + # Find a computed/readonly field from the schema + detail = cr.get_detail() + field_defs = service._build_field_definitions(self.cr_type_edit, detail) + readonly_fields = [f["name"] for f in field_defs if f["readonly"]] + if readonly_fields: + with self.assertRaises(ValidationError): + service.update_detail(cr, {readonly_fields[0]: "value"}) + else: + # No readonly fields on this detail model; verify validation + # still passes for valid fields + service.update_detail(cr, {"given_name": "Valid Name"}) diff --git a/spp_api_v2_change_request/tests/test_change_request_type_schema.py b/spp_api_v2_change_request/tests/test_change_request_type_schema.py new file mode 100644 index 00000000..b230b3bd --- /dev/null +++ b/spp_api_v2_change_request/tests/test_change_request_type_schema.py @@ -0,0 +1,176 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Tests for Change Request type schema endpoints.""" + +from .common import ChangeRequestTestCase + + +class TestChangeRequestTypeSchema(ChangeRequestTestCase): + """Tests for CR type list and schema service methods.""" + + # ────────────────────────────────────────────────────────────────────── + # get_type_list tests + # ────────────────────────────────────────────────────────────────────── + + def test_get_type_list(self): + """get_type_list returns a list including our known type.""" + service = self._get_service() + result = service.get_type_list() + + self.assertIsInstance(result, list) + self.assertTrue(len(result) > 0) + + codes = [t["code"] for t in result] + self.assertIn("edit_individual", codes) + + def test_get_type_list_only_contains_valid_models(self): + """All returned types have detail models that exist in the Odoo registry.""" + service = self._get_service() + result = service.get_type_list() + + for type_info in result: + cr_type = self.env["spp.change.request.type"].search([("code", "=", type_info["code"])], limit=1) + self.assertTrue( + cr_type.detail_model in self.env, + f"Type {type_info['code']} has invalid detail_model {cr_type.detail_model}", + ) + + # ────────────────────────────────────────────────────────────────────── + # get_type_schema tests + # ────────────────────────────────────────────────────────────────────── + + def test_get_type_schema(self): + """get_type_schema returns full schema with expected structure.""" + service = self._get_service() + result = service.get_type_schema("edit_individual") + + self.assertIsNotNone(result) + self.assertIn("typeInfo", result) + self.assertIn("fields", result) + self.assertIn("availableDocuments", result) + self.assertIn("requiredDocuments", result) + + type_info = result["typeInfo"] + self.assertEqual(type_info["code"], "edit_individual") + self.assertEqual(type_info["name"], "Edit Individual") + self.assertEqual(type_info["targetType"], "individual") + + def test_get_type_schema_not_found(self): + """get_type_schema returns None for an invalid code.""" + service = self._get_service() + result = service.get_type_schema("totally_fake_type_xyz") + self.assertIsNone(result) + + # ────────────────────────────────────────────────────────────────────── + # Field definition tests + # ────────────────────────────────────────────────────────────────────── + + def test_field_definitions_include_expected_fields(self): + """given_name, family_name, birthdate are present in the schema.""" + service = self._get_service() + result = service.get_type_schema("edit_individual") + + field_names = [f["name"] for f in result["fields"]] + self.assertIn("given_name", field_names) + self.assertIn("family_name", field_names) + self.assertIn("birthdate", field_names) + + def test_field_definitions_exclude_internal_fields(self): + """Internal fields like id, create_uid, message_ids, change_request_id are absent.""" + service = self._get_service() + result = service.get_type_schema("edit_individual") + + field_names = {f["name"] for f in result["fields"]} + self.assertNotIn("id", field_names) + self.assertNotIn("create_uid", field_names) + self.assertNotIn("message_ids", field_names) + self.assertNotIn("change_request_id", field_names) + self.assertNotIn("registrant_id", field_names) + self.assertNotIn("approval_state", field_names) + self.assertNotIn("is_applied", field_names) + + def test_field_types_mapped_correctly(self): + """Odoo field types map to the correct API types.""" + service = self._get_service() + result = service.get_type_schema("edit_individual") + + fields_by_name = {f["name"]: f for f in result["fields"]} + + # char -> string + self.assertEqual(fields_by_name["given_name"]["type"], "string") + # date -> date + self.assertEqual(fields_by_name["birthdate"]["type"], "date") + # many2one vocabulary -> code + self.assertEqual(fields_by_name["gender_id"]["type"], "code") + + def test_vocabulary_field_includes_namespace(self): + """A vocabulary many2one field has vocabulary with namespaceUri and codes.""" + service = self._get_service() + result = service.get_type_schema("edit_individual") + + fields_by_name = {f["name"]: f for f in result["fields"]} + gender_field = fields_by_name["gender_id"] + + self.assertIsNotNone(gender_field.get("vocabulary")) + vocab = gender_field["vocabulary"] + self.assertEqual(vocab["namespaceUri"], "urn:iso:std:iso:5218") + self.assertIsInstance(vocab["codes"], list) + self.assertTrue(len(vocab["codes"]) > 0) + + # Check code structure + code_values = [c["value"] for c in vocab["codes"]] + self.assertIn("1", code_values) + self.assertIn("2", code_values) + + def test_selection_field_includes_choices(self): + """Selection fields have a choices array (if any exist on the model).""" + service = self._get_service() + result = service.get_type_schema("edit_individual") + + # edit_individual may not have selection fields, so just verify the + # structure: if any field has type "selection", it must have choices + for field_def in result["fields"]: + if field_def["type"] == "selection": + self.assertIsNotNone(field_def.get("choices")) + self.assertIsInstance(field_def["choices"], list) + if field_def["choices"]: + self.assertIn("value", field_def["choices"][0]) + self.assertIn("label", field_def["choices"][0]) + + def test_computed_stored_field_is_readonly(self): + """Fields with a compute method are marked readonly.""" + service = self._get_service() + # Test the logic directly via _build_field_definitions to check + # that computed fields are marked as readonly. + detail_model = self.env["spp.cr.detail.edit_individual"] + field_defs = service._build_field_definitions(self.cr_type_edit, detail_model) + + for field_def in field_defs: + field_name = field_def["name"] + odoo_field = detail_model._fields.get(field_name) + if odoo_field and odoo_field.compute: + self.assertTrue( + field_def["readonly"], + f"Computed field {field_name} should be readonly", + ) + + def test_dynamic_domain_does_not_crash(self): + """A domain containing Python name references does not crash vocabulary extraction.""" + service = self._get_service() + # Simulate a domain string with a Python variable reference + info = service._extract_vocabulary_info_from_domain( + "[('id', '!=', registrant_id)]", + "spp.vocabulary.code", + ) + # Should return None gracefully, not crash + self.assertIsNone(info) + + def test_field_definition_has_label(self): + """All field definitions have a non-empty label.""" + service = self._get_service() + result = service.get_type_schema("edit_individual") + + for field_def in result["fields"]: + self.assertTrue( + field_def.get("label"), + f"Field {field_def['name']} should have a non-empty label", + ) From 72cdc168a484f787bcee6133dc72623282a44906 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 17 Feb 2026 12:01:49 +0700 Subject: [PATCH 2/5] refactor(spp_api_v2,spp_api_v2_change_request): replace bespoke CR type schema with JSON Schema 2020-12 Add generic OdooModelSchemaBuilder that converts any Odoo model's fields to a standard JSON Schema 2020-12 document. Refactor CR type schema endpoint to return detailSchema (JSON Schema) instead of proprietary FieldDefinition objects, enabling third-party tooling (ajv, jsonschema, react-jsonschema-form) to work out of the box. Key changes: - Add spp_api_v2/services/schema_builder.py with field type mapping, vocabulary extraction, and selection choice handling - Replace FieldDefinition/VocabularyInfo pydantic models with a plain dict[str, Any] detailSchema field on ChangeRequestTypeSchema - Optimize _validate_detail_input to use direct field introspection instead of building full schema (avoids unnecessary DB queries) - Use anyOf for many2one reference types (2020-12 conformance) --- spp_api_v2/services/__init__.py | 1 + spp_api_v2/services/schema_builder.py | 273 +++++++++++++++ spp_api_v2/tests/__init__.py | 1 + spp_api_v2/tests/test_schema_builder.py | 327 ++++++++++++++++++ .../schemas/change_request.py | 34 +- .../services/change_request_service.py | 190 ++-------- .../tests/test_change_request_service.py | 16 +- .../tests/test_change_request_type_schema.py | 183 ++++++---- 8 files changed, 767 insertions(+), 258 deletions(-) create mode 100644 spp_api_v2/services/schema_builder.py create mode 100644 spp_api_v2/tests/test_schema_builder.py diff --git a/spp_api_v2/services/__init__.py b/spp_api_v2/services/__init__.py index 0667f5aa..76ce7f34 100644 --- a/spp_api_v2/services/__init__.py +++ b/spp_api_v2/services/__init__.py @@ -7,4 +7,5 @@ from . import individual_service from . import program_membership_service from . import program_service +from . import schema_builder from . import search_service diff --git a/spp_api_v2/services/schema_builder.py b/spp_api_v2/services/schema_builder.py new file mode 100644 index 00000000..ef08b3ef --- /dev/null +++ b/spp_api_v2/services/schema_builder.py @@ -0,0 +1,273 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Generic builder that converts an Odoo model's fields to JSON Schema 2020-12.""" + +import ast +import logging +from typing import Any + +from odoo.api import Environment + +_logger = logging.getLogger(__name__) + +# Field types that are always skipped (no meaningful JSON Schema representation) +SKIP_FIELD_TYPES = {"binary", "many2many", "one2many"} + + +class OdooModelSchemaBuilder: + """Build a JSON Schema 2020-12 dict from an Odoo model's fields. + + Usage:: + + builder = OdooModelSchemaBuilder(env) + schema = builder.build_schema( + env["res.partner"], + skip_fields={"message_ids", "activity_ids"}, + title="Partner", + ) + """ + + def __init__(self, env: Environment): + self.env = env + + def build_schema( + self, + model, + *, + skip_fields: set[str] | None = None, + title: str | None = None, + ) -> dict[str, Any]: + """Build JSON Schema 2020-12 from an Odoo model's fields. + + Args: + model: An Odoo model recordset (e.g. ``env["res.partner"]``). + skip_fields: Field names to exclude from the schema. + title: Schema title. Falls back to the model's ``_description``. + + Returns: + A dict representing a valid JSON Schema 2020-12 document. + """ + skip = skip_fields or set() + properties: dict[str, Any] = {} + required: list[str] = [] + + for field_name, field in model._fields.items(): + if field_name.startswith("_"): + continue + if field_name in skip: + continue + if not field.store: + continue + if field.type in SKIP_FIELD_TYPES: + continue + + prop = self._field_to_property(field) + if prop is None: + continue + + # Standard metadata keywords + if field.string: + prop["title"] = field.string + if field.help: + prop["description"] = field.help + if bool(field.readonly) or bool(field.compute): + prop["readOnly"] = True + + properties[field_name] = prop + + if field.required: + required.append(field_name) + + schema: dict[str, Any] = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "title": title or getattr(model, "_description", model._name), + "properties": properties, + } + if required: + schema["required"] = sorted(required) + + return schema + + # ------------------------------------------------------------------ + # Field type mapping + # ------------------------------------------------------------------ + + def _field_to_property(self, field) -> dict[str, Any] | None: + """Convert a single Odoo field to a JSON Schema property dict. + + Returns None if the field type is not supported. + """ + handler = self._TYPE_HANDLERS.get(field.type) + if handler is not None: + return handler(self, field) + + # many2one requires special logic + if field.type == "many2one": + return self._handle_many2one(field) + + return None + + # --- simple types --------------------------------------------------- + + def _handle_char(self, field) -> dict[str, Any]: + return {"type": "string"} + + def _handle_text(self, field) -> dict[str, Any]: + return {"type": "string", "x-display": "multiline"} + + def _handle_integer(self, field) -> dict[str, Any]: + return {"type": "integer"} + + def _handle_float(self, field) -> dict[str, Any]: + return {"type": "number"} + + def _handle_boolean(self, field) -> dict[str, Any]: + return {"type": "boolean"} + + def _handle_date(self, field) -> dict[str, Any]: + return {"type": "string", "format": "date"} + + def _handle_datetime(self, field) -> dict[str, Any]: + return {"type": "string", "format": "date-time"} + + # --- selection ------------------------------------------------------- + + def _handle_selection(self, field) -> dict[str, Any]: + choices = self._extract_selection_choices(field) + if choices: + return {"oneOf": [{"const": c["value"], "title": c["label"]} for c in choices]} + return {"type": "string"} + + # --- many2one -------------------------------------------------------- + + def _handle_many2one(self, field) -> dict[str, Any]: + if field.comodel_name == "spp.vocabulary.code": + return self._handle_vocabulary(field) + return { + "anyOf": [{"type": "string"}, {"type": "integer"}], + "x-field-type": "reference", + "x-reference-model": field.comodel_name, + } + + def _handle_vocabulary(self, field) -> dict[str, Any]: + domain_str = str(field.domain) if field.domain else "" + vocab_info = self._extract_vocabulary_info_from_domain( + domain_str, + field.comodel_name, + ) + + prop: dict[str, Any] = { + "type": "object", + "properties": { + "system": {"type": "string"}, + "code": {"type": "string"}, + }, + "required": ["system", "code"], + "x-field-type": "vocabulary", + } + + if vocab_info: + namespace_uri = vocab_info["namespaceUri"] + prop["properties"]["system"] = {"type": "string", "const": namespace_uri} + prop["x-vocabulary-uri"] = namespace_uri + + codes = vocab_info["codes"] + if codes: + prop["properties"]["code"] = { + "oneOf": [{"const": c["value"], "title": c["label"]} for c in codes], + } + + return prop + + # ------------------------------------------------------------------ + # Helpers (selection & vocabulary extraction) + # ------------------------------------------------------------------ + + def _extract_selection_choices(self, field) -> list[dict[str, str]]: + """Extract selection choices from an Odoo field. + + Handles both list-of-tuples and callable selections. + """ + selection = field.selection + if callable(selection): + try: + selection = selection(self.env[field.model_name]) + except Exception: + _logger.warning("Could not evaluate callable selection for %s", field.name, exc_info=True) + return [] + if not selection: + return [] + result = [] + for item in selection: + if isinstance(item, (list, tuple)) and len(item) >= 2: + result.append({"value": item[0], "label": item[1]}) + else: + _logger.debug("Skipping unparseable selection item for %s: %r", field.name, item) + return result + + def _extract_vocabulary_info_from_domain( + self, + domain_str: str, + comodel_name: str, + ) -> dict[str, Any] | None: + """Parse a domain string to extract vocabulary namespace and load codes. + + Args: + domain_str: String representation of an Odoo domain (e.g. + ``[('namespace_uri', '=', 'urn:iso:std:iso:5218')]``) + comodel_name: The comodel (expected to be ``spp.vocabulary.code``) + + Returns: + Dict with ``namespaceUri`` and ``codes``, or None if unparseable. + """ + if not domain_str: + return None + + try: + domain = ast.literal_eval(domain_str) + except (ValueError, SyntaxError): + # Domain contains Python name references (e.g., registrant_id) + return None + + if not isinstance(domain, list): + return None + + namespace_uri = None + for leaf in domain: + if not isinstance(leaf, (list, tuple)) or len(leaf) != 3: + continue + field_path, operator, value = leaf + if operator != "=": + continue + if field_path in ("namespace_uri", "vocabulary_id.namespace_uri"): + namespace_uri = value + break + + if not namespace_uri: + return None + + codes = self.env[comodel_name].search( + [("namespace_uri", "=", namespace_uri)], + order="sequence, code", + ) + return { + "namespaceUri": namespace_uri, + "codes": [{"value": code.code, "label": code.display or code.code} for code in codes], + } + + # ------------------------------------------------------------------ + # Dispatch table (Odoo field type string → handler method) + # ------------------------------------------------------------------ + + _TYPE_HANDLERS: dict[str, Any] = { + "char": _handle_char, + "text": _handle_text, + "html": _handle_text, + "integer": _handle_integer, + "float": _handle_float, + "monetary": _handle_float, + "boolean": _handle_boolean, + "date": _handle_date, + "datetime": _handle_datetime, + "selection": _handle_selection, + } diff --git a/spp_api_v2/tests/__init__.py b/spp_api_v2/tests/__init__.py index 04576327..f1ba4517 100644 --- a/spp_api_v2/tests/__init__.py +++ b/spp_api_v2/tests/__init__.py @@ -33,4 +33,5 @@ from . import test_program_membership_service from . import test_program_service from . import test_scope_enforcement +from . import test_schema_builder from . import test_search_service diff --git a/spp_api_v2/tests/test_schema_builder.py b/spp_api_v2/tests/test_schema_builder.py new file mode 100644 index 00000000..77115b76 --- /dev/null +++ b/spp_api_v2/tests/test_schema_builder.py @@ -0,0 +1,327 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Tests for the generic OdooModelSchemaBuilder.""" + +from odoo.tests import TransactionCase + +from ..services.schema_builder import OdooModelSchemaBuilder + + +class TestSchemaBuilder(TransactionCase): + """Unit tests for OdooModelSchemaBuilder using res.partner as a real model.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.builder = OdooModelSchemaBuilder(cls.env) + + # Gender vocabulary (needed for vocabulary field tests on res.partner) + gender_vocab = cls.env["spp.vocabulary"].search([("namespace_uri", "=", "urn:iso:std:iso:5218")], limit=1) + if not gender_vocab: + gender_vocab = cls.env["spp.vocabulary"].create( + { + "name": "Gender (ISO 5218)", + "namespace_uri": "urn:iso:std:iso:5218", + } + ) + for code, display in [("1", "Male"), ("2", "Female")]: + existing = cls.env["spp.vocabulary.code"].search( + [("vocabulary_id", "=", gender_vocab.id), ("code", "=", code)], + limit=1, + ) + if not existing: + cls.env["spp.vocabulary.code"].create( + { + "vocabulary_id": gender_vocab.id, + "code": code, + "display": display, + } + ) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _find_field_property(self, schema, odoo_type, *, comodel_name=None): + """Find the first schema property whose Odoo field matches a given type. + + Returns (field_name, property_dict) or (None, None). + """ + partner_fields = self.env["res.partner"]._fields + for fname, prop in schema["properties"].items(): + odoo_field = partner_fields.get(fname) + if not odoo_field or odoo_field.type != odoo_type: + continue + if comodel_name and odoo_field.comodel_name != comodel_name: + continue + return fname, prop + return None, None + + # ------------------------------------------------------------------ + # Top-level schema structure + # ------------------------------------------------------------------ + + def test_schema_has_json_schema_keywords(self): + """The generated schema contains $schema, type, and properties.""" + schema = self.builder.build_schema(self.env["res.partner"]) + self.assertEqual(schema["$schema"], "https://json-schema.org/draft/2020-12/schema") + self.assertEqual(schema["type"], "object") + self.assertIn("properties", schema) + + def test_title_defaults_to_model_description(self): + """When no title is given, the model's _description is used.""" + schema = self.builder.build_schema(self.env["res.partner"]) + self.assertIn("title", schema) + self.assertTrue(len(schema["title"]) > 0) + + def test_title_override(self): + """An explicit title overrides the default.""" + schema = self.builder.build_schema( + self.env["res.partner"], + title="Custom Title", + ) + self.assertEqual(schema["title"], "Custom Title") + + # ------------------------------------------------------------------ + # Skip logic + # ------------------------------------------------------------------ + + def test_skip_fields_are_excluded(self): + """Fields listed in skip_fields do not appear in properties.""" + schema = self.builder.build_schema( + self.env["res.partner"], + skip_fields={"name", "email"}, + ) + self.assertNotIn("name", schema["properties"]) + self.assertNotIn("email", schema["properties"]) + + def test_binary_fields_skipped(self): + """Binary fields (e.g. image_1920) are not in the schema.""" + schema = self.builder.build_schema(self.env["res.partner"]) + self.assertNotIn("image_1920", schema["properties"]) + + def test_many2many_fields_skipped(self): + """Many2many fields are not in the schema.""" + schema = self.builder.build_schema(self.env["res.partner"]) + # category_id is a many2many on res.partner + self.assertNotIn("category_id", schema["properties"]) + + def test_one2many_fields_skipped(self): + """One2many fields are not in the schema.""" + schema = self.builder.build_schema(self.env["res.partner"]) + self.assertNotIn("child_ids", schema["properties"]) + + def test_non_stored_fields_skipped(self): + """Non-stored (non-persisted) fields are not in the schema.""" + schema = self.builder.build_schema(self.env["res.partner"]) + partner_fields = self.env["res.partner"]._fields + for field_name in schema.get("properties", {}): + odoo_field = partner_fields.get(field_name) + if odoo_field: + self.assertTrue( + odoo_field.store, + f"Non-stored field {field_name} should not appear in schema", + ) + + # ------------------------------------------------------------------ + # Field type mapping + # ------------------------------------------------------------------ + + def test_char_maps_to_string(self): + """Char fields map to {"type": "string"}.""" + schema = self.builder.build_schema(self.env["res.partner"]) + name_prop = schema["properties"].get("name") + self.assertIsNotNone(name_prop, "res.partner should have a 'name' field") + self.assertEqual(name_prop["type"], "string") + + def test_date_maps_to_string_date(self): + """Date fields map to {"type": "string", "format": "date"}.""" + schema = self.builder.build_schema(self.env["res.partner"]) + fname, prop = self._find_field_property(schema, "date") + self.assertIsNotNone(fname, "res.partner should have at least one date field in schema") + self.assertEqual(prop["type"], "string") + self.assertEqual(prop["format"], "date") + + def test_integer_maps_to_integer(self): + """Integer fields map to {"type": "integer"}.""" + schema = self.builder.build_schema(self.env["res.partner"]) + fname, prop = self._find_field_property(schema, "integer") + self.assertIsNotNone(fname, "res.partner should have at least one integer field in schema") + self.assertEqual(prop["type"], "integer") + + def test_float_maps_to_number(self): + """Float fields map to {"type": "number"}.""" + schema = self.builder.build_schema(self.env["res.partner"]) + fname, prop = self._find_field_property(schema, "float") + self.assertIsNotNone(fname, "res.partner should have at least one float field in schema") + self.assertEqual(prop["type"], "number") + + def test_monetary_maps_to_number(self): + """Monetary fields map to {"type": "number"} (same as float).""" + schema = self.builder.build_schema(self.env["res.partner"]) + fname, prop = self._find_field_property(schema, "monetary") + if fname is None: + # monetary fields may not exist on res.partner; verify the handler directly + handler_result = self.builder._handle_float( + self.env["res.partner"]._fields.get("name") # placeholder + ) + self.assertEqual(handler_result["type"], "number") + return + self.assertEqual(prop["type"], "number") + + def test_boolean_maps_to_boolean(self): + """Boolean fields map to {"type": "boolean"}.""" + schema = self.builder.build_schema(self.env["res.partner"]) + fname, prop = self._find_field_property(schema, "boolean") + self.assertIsNotNone(fname, "res.partner should have at least one boolean field in schema") + self.assertEqual(prop["type"], "boolean") + + def test_text_maps_to_string_multiline(self): + """Text fields map to {"type": "string", "x-display": "multiline"}.""" + schema = self.builder.build_schema(self.env["res.partner"]) + fname, prop = self._find_field_property(schema, "text") + self.assertIsNotNone(fname, "res.partner should have at least one text field in schema") + self.assertEqual(prop["type"], "string") + self.assertEqual(prop.get("x-display"), "multiline") + + def test_html_maps_to_string_multiline(self): + """Html fields map to {"type": "string", "x-display": "multiline"} (same as text).""" + schema = self.builder.build_schema(self.env["res.partner"]) + fname, prop = self._find_field_property(schema, "html") + if fname is None: + # html fields may not exist on res.partner; verify the handler directly + handler_result = self.builder._handle_text( + self.env["res.partner"]._fields.get("name") # placeholder + ) + self.assertEqual(handler_result["type"], "string") + self.assertEqual(handler_result["x-display"], "multiline") + return + self.assertEqual(prop["type"], "string") + self.assertEqual(prop.get("x-display"), "multiline") + + def test_selection_maps_to_oneof(self): + """Selection fields map to oneOf with const/title entries.""" + schema = self.builder.build_schema(self.env["res.partner"]) + fname, prop = self._find_field_property(schema, "selection") + self.assertIsNotNone(fname, "res.partner should have at least one selection field in schema") + # Should have oneOf or fallback type: string + if "oneOf" in prop: + for entry in prop["oneOf"]: + self.assertIn("const", entry) + self.assertIn("title", entry) + else: + self.assertEqual(prop["type"], "string") + + # ------------------------------------------------------------------ + # Many2one handling + # ------------------------------------------------------------------ + + def test_vocabulary_field_produces_object(self): + """A many2one to spp.vocabulary.code produces an object with x-field-type=vocabulary.""" + schema = self.builder.build_schema(self.env["res.partner"]) + gender_prop = schema["properties"].get("gender_id") + self.assertIsNotNone(gender_prop, "res.partner should have a gender_id field in schema") + self.assertEqual(gender_prop["type"], "object") + self.assertEqual(gender_prop.get("x-field-type"), "vocabulary") + self.assertIn("system", gender_prop["properties"]) + self.assertIn("code", gender_prop["properties"]) + + def test_vocabulary_field_has_oneof_codes(self): + """A vocabulary field with a parseable domain includes oneOf codes.""" + schema = self.builder.build_schema(self.env["res.partner"]) + gender_prop = schema["properties"].get("gender_id") + self.assertIsNotNone(gender_prop, "res.partner should have a gender_id field in schema") + code_prop = gender_prop["properties"].get("code", {}) + self.assertIn("oneOf", code_prop, "Vocabulary code property should have oneOf with choices") + values = [e["const"] for e in code_prop["oneOf"]] + self.assertIn("1", values) + self.assertIn("2", values) + + def test_reference_field_uses_anyof(self): + """A many2one to a non-vocabulary model uses anyOf with string and integer types.""" + schema = self.builder.build_schema(self.env["res.partner"]) + partner_fields = self.env["res.partner"]._fields + found = False + for fname, prop in schema["properties"].items(): + odoo_field = partner_fields.get(fname) + if odoo_field and odoo_field.type == "many2one" and odoo_field.comodel_name != "spp.vocabulary.code": + self.assertIn("anyOf", prop, f"Reference field {fname} should use anyOf") + type_set = {entry["type"] for entry in prop["anyOf"]} + self.assertEqual(type_set, {"string", "integer"}) + self.assertEqual(prop.get("x-field-type"), "reference") + self.assertEqual(prop.get("x-reference-model"), odoo_field.comodel_name) + found = True + break + self.assertTrue(found, "res.partner should have at least one non-vocabulary many2one field") + + # ------------------------------------------------------------------ + # Metadata keywords + # ------------------------------------------------------------------ + + def test_required_fields_in_required_array(self): + """Fields with required=True appear in the schema's required array.""" + schema = self.builder.build_schema(self.env["res.partner"]) + required = schema.get("required", []) + partner_fields = self.env["res.partner"]._fields + for field_name in required: + odoo_field = partner_fields.get(field_name) + self.assertTrue( + odoo_field.required, + f"Field {field_name} is in 'required' but Odoo field is not required", + ) + + def test_readonly_or_computed_fields_marked(self): + """Fields that are readonly or computed have readOnly: true.""" + schema = self.builder.build_schema(self.env["res.partner"]) + partner_fields = self.env["res.partner"]._fields + for field_name, prop in schema["properties"].items(): + odoo_field = partner_fields.get(field_name) + if odoo_field and (odoo_field.readonly or odoo_field.compute): + self.assertTrue( + prop.get("readOnly"), + f"Field {field_name} (readonly={odoo_field.readonly}, " + f"compute={bool(odoo_field.compute)}) should have readOnly=true", + ) + + def test_title_from_field_string(self): + """Each property has a 'title' taken from the Odoo field string.""" + schema = self.builder.build_schema(self.env["res.partner"]) + for fname, prop in schema["properties"].items(): + self.assertIn("title", prop, f"Property {fname} should have a title") + + def test_description_from_help(self): + """Fields with help text have a 'description' property.""" + schema = self.builder.build_schema(self.env["res.partner"]) + partner_fields = self.env["res.partner"]._fields + for fname, prop in schema["properties"].items(): + odoo_field = partner_fields.get(fname) + if odoo_field and odoo_field.help: + self.assertEqual( + prop.get("description"), + odoo_field.help, + f"Property {fname} description should match field help", + ) + + # ------------------------------------------------------------------ + # Vocabulary extraction edge cases + # ------------------------------------------------------------------ + + def test_dynamic_domain_does_not_crash(self): + """A domain containing Python variable references returns None gracefully.""" + info = self.builder._extract_vocabulary_info_from_domain( + "[('id', '!=', registrant_id)]", + "spp.vocabulary.code", + ) + self.assertIsNone(info) + + def test_empty_domain_returns_none(self): + """An empty domain string returns None.""" + info = self.builder._extract_vocabulary_info_from_domain("", "spp.vocabulary.code") + self.assertIsNone(info) + + def test_domain_without_namespace_returns_none(self): + """A domain without namespace_uri returns None.""" + info = self.builder._extract_vocabulary_info_from_domain( + "[('active', '=', True)]", + "spp.vocabulary.code", + ) + self.assertIsNone(info) diff --git a/spp_api_v2_change_request/schemas/change_request.py b/spp_api_v2_change_request/schemas/change_request.py index b6cffa38..e143b905 100644 --- a/spp_api_v2_change_request/schemas/change_request.py +++ b/spp_api_v2_change_request/schemas/change_request.py @@ -197,32 +197,6 @@ class FieldChoice(BaseModel): label: str = Field(..., description="Human-readable display label") -class VocabularyInfo(BaseModel): - """Vocabulary namespace and available codes for a vocabulary field.""" - - namespace_uri: str = Field(..., alias="namespaceUri", description="Vocabulary namespace URI") - codes: list[FieldChoice] = Field(default_factory=list, description="Available codes") - - class Config: - populate_by_name = True - - -class FieldDefinition(BaseModel): - """Schema definition for a single field on a CR detail model.""" - - name: str = Field(..., description="Field name") - label: str = Field(..., description="Human-readable field label") - type: str = Field( - ..., - description="Field type (string, text, integer, float, boolean, date, datetime, selection, code, reference)", - ) - required: bool = Field(False, description="Whether the field is required") - readonly: bool = Field(False, description="Whether the field is read-only or computed") - help: str | None = Field(None, description="Help text for the field") - choices: list[FieldChoice] | None = Field(None, description="Available choices for selection fields") - vocabulary: VocabularyInfo | None = Field(None, description="Vocabulary info for code fields") - - class ChangeRequestTypeInfo(BaseModel): """Summary info for a CR type.""" @@ -236,10 +210,14 @@ class Config: class ChangeRequestTypeSchema(BaseModel): - """Full schema for a CR type including field definitions.""" + """Full schema for a CR type including JSON Schema for detail fields.""" type_info: ChangeRequestTypeInfo = Field(..., alias="typeInfo", description="Type summary") - fields: list[FieldDefinition] = Field(default_factory=list, description="Available detail fields") + detail_schema: dict[str, Any] = Field( + ..., + alias="detailSchema", + description="JSON Schema 2020-12 describing the detail payload", + ) available_documents: list[FieldChoice] = Field( default_factory=list, alias="availableDocuments", description="Documents that can be attached" ) diff --git a/spp_api_v2_change_request/services/change_request_service.py b/spp_api_v2_change_request/services/change_request_service.py index fab46151..e09babc1 100644 --- a/spp_api_v2_change_request/services/change_request_service.py +++ b/spp_api_v2_change_request/services/change_request_service.py @@ -1,13 +1,14 @@ # Part of OpenSPP. See LICENSE file for full copyright and licensing details. """Service for Change Request API operations.""" -import ast import logging from typing import Any from odoo.api import Environment from odoo.exceptions import UserError, ValidationError +from odoo.addons.spp_api_v2.services.schema_builder import OdooModelSchemaBuilder + from ..schemas.change_request import ( ChangeRequestCreate, ) @@ -53,23 +54,6 @@ "rating_ids", } -# Mapping from Odoo field types to API field types -ODOO_TYPE_MAP = { - "char": "string", - "text": "text", - "html": "text", - "integer": "integer", - "float": "float", - "monetary": "float", - "boolean": "boolean", - "date": "date", - "datetime": "datetime", - "selection": "selection", -} - -# Field types that are skipped entirely in field definitions -SKIP_FIELD_TYPES = {"binary", "many2many", "one2many"} - class ChangeRequestService: """Service for Change Request resource CRUD and mapping.""" @@ -341,8 +325,8 @@ def update_detail(self, cr, detail_data: dict[str, Any]): if not detail: raise ValidationError("Change request has no detail record") - # Validate fields against the type schema - self._validate_detail_input(cr.request_type_id, detail, detail_data) + # Validate fields against the detail model + self._validate_detail_input(detail, detail_data) # Convert API data to Odoo vals vals = self._deserialize_detail(detail, detail_data) @@ -413,25 +397,39 @@ def _deserialize_detail(self, detail, data: dict[str, Any]) -> dict[str, Any]: return vals - def _validate_detail_input(self, cr_type, detail_model, data: dict[str, Any]): + def _validate_detail_input(self, detail_model, data: dict[str, Any]): """ - Validate detail input data against the type's field schema. + Validate detail input data against the detail model's fields. Rejects unknown fields and read-only fields so external consumers get immediate feedback rather than silent data loss. Args: - cr_type: spp.change.request.type record detail_model: The Odoo model instance for the detail data: Dictionary of field values from API input """ + from odoo.addons.spp_api_v2.services.schema_builder import SKIP_FIELD_TYPES + errors = [] model_fields = detail_model._fields - # Build the set of valid writable field names from the schema - field_defs = self._build_field_definitions(cr_type, detail_model) - valid_fields = {f["name"] for f in field_defs} - readonly_fields = {f["name"] for f in field_defs if f["readonly"]} + # Determine valid and readonly fields by inspecting Odoo fields directly, + # avoiding an expensive build_schema call (which triggers DB queries for + # vocabulary codes that are unnecessary for input validation). + valid_fields: set[str] = set() + readonly_fields: set[str] = set() + for field_name, field in model_fields.items(): + if field_name.startswith("_"): + continue + if field_name in DETAIL_SKIP_FIELDS: + continue + if not field.store: + continue + if field.type in SKIP_FIELD_TYPES: + continue + valid_fields.add(field_name) + if field.readonly or field.compute: + readonly_fields.add(field_name) for field_name in data: if field_name not in model_fields: @@ -591,7 +589,12 @@ def get_type_schema(self, code: str) -> dict[str, Any] | None: return None detail_model = self.env[cr_type.detail_model] - field_defs = self._build_field_definitions(cr_type, detail_model) + builder = OdooModelSchemaBuilder(self.env) + detail_schema = builder.build_schema( + detail_model, + skip_fields=DETAIL_SKIP_FIELDS, + title=f"{cr_type.name} Detail", + ) # Documents available_documents = [ @@ -608,136 +611,7 @@ def get_type_schema(self, code: str) -> dict[str, Any] | None: "targetType": cr_type.target_type or "both", "requiresApplicant": cr_type.is_requires_applicant, }, - "fields": field_defs, + "detailSchema": detail_schema, "availableDocuments": available_documents, "requiredDocuments": required_documents, } - - def _build_field_definitions(self, cr_type, detail_model) -> list[dict[str, Any]]: - """ - Build field definition dicts for all user-facing fields on a detail model. - - Args: - cr_type: spp.change.request.type record - detail_model: The Odoo model class for the detail - - Returns: - List of dicts matching FieldDefinition schema. - """ - result = [] - for field_name, field in detail_model._fields.items(): - if field_name.startswith("_"): - continue - if field_name in DETAIL_SKIP_FIELDS: - continue - if not field.store: - continue - if field.type in SKIP_FIELD_TYPES: - continue - - # Map Odoo type to API type - api_type = ODOO_TYPE_MAP.get(field.type) - if api_type is None and field.type == "many2one": - # Detect vocabulary vs generic reference - if field.comodel_name == "spp.vocabulary.code": - api_type = "code" - else: - api_type = "reference" - if api_type is None: - continue - - # Determine readonly: explicitly readonly OR has a compute method - is_readonly = bool(field.readonly) or bool(field.compute) - - field_def = { - "name": field_name, - "label": field.string or field_name, - "type": api_type, - "required": bool(field.required), - "readonly": is_readonly, - } - - if field.help: - field_def["help"] = field.help - - # Selection choices - if api_type == "selection": - field_def["choices"] = self._extract_selection_choices(field) - - # Vocabulary info for code fields - if api_type == "code": - domain_str = field.domain or "" - vocab_info = self._extract_vocabulary_info_from_domain( - str(domain_str) if domain_str else "", - field.comodel_name, - ) - field_def["vocabulary"] = vocab_info - - result.append(field_def) - - return result - - def _extract_selection_choices(self, field) -> list[dict[str, str]]: - """ - Extract selection choices from an Odoo field. - - Handles both list-of-tuples and callable selections. - """ - selection = field.selection - if callable(selection): - try: - selection = selection(self.env[field.model_name]) - except Exception: - _logger.debug("Could not evaluate callable selection for %s", field.name) - return [] - if not selection: - return [] - return [{"value": str(val), "label": label} for val, label in selection] - - def _extract_vocabulary_info_from_domain(self, domain_str: str, comodel_name: str) -> dict[str, Any] | None: - """ - Parse a domain string to extract vocabulary namespace and load codes. - - Args: - domain_str: String representation of an Odoo domain (e.g. - "[('namespace_uri', '=', 'urn:iso:std:iso:5218')]") - comodel_name: The comodel (expected to be 'spp.vocabulary.code') - - Returns: - Dict with namespaceUri and codes, or None if unparseable. - """ - if not domain_str: - return None - - try: - domain = ast.literal_eval(domain_str) - except (ValueError, SyntaxError): - # Domain contains Python name references (e.g., registrant_id) - return None - - if not isinstance(domain, list): - return None - - namespace_uri = None - for leaf in domain: - if not isinstance(leaf, (list, tuple)) or len(leaf) != 3: - continue - field_path, operator, value = leaf - if operator != "=": - continue - if field_path in ("namespace_uri", "vocabulary_id.namespace_uri"): - namespace_uri = value - break - - if not namespace_uri: - return None - - # Load codes from the vocabulary - codes = self.env[comodel_name].search( - [("namespace_uri", "=", namespace_uri)], - order="sequence, code", - ) - return { - "namespaceUri": namespace_uri, - "codes": [{"value": code.code, "label": code.display or code.code} for code in codes], - } 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 ed188152..11f1839f 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 @@ -234,10 +234,20 @@ def test_update_detail_readonly_field_raises(self): "registrant_id": self.registrant.id, } ) - # Find a computed/readonly field from the schema + # Find a computed/readonly field by inspecting the detail model directly detail = cr.get_detail() - field_defs = service._build_field_definitions(self.cr_type_edit, detail) - readonly_fields = [f["name"] for f in field_defs if f["readonly"]] + 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 + + readonly_fields = [] + for field_name, field in detail._fields.items(): + if field_name.startswith("_") or field_name in DETAIL_SKIP_FIELDS: + continue + if not field.store or field.type in SKIP_FIELD_TYPES: + continue + if field.readonly or field.compute: + readonly_fields.append(field_name) + if readonly_fields: with self.assertRaises(ValidationError): service.update_detail(cr, {readonly_fields[0]: "value"}) diff --git a/spp_api_v2_change_request/tests/test_change_request_type_schema.py b/spp_api_v2_change_request/tests/test_change_request_type_schema.py index b230b3bd..413aa409 100644 --- a/spp_api_v2_change_request/tests/test_change_request_type_schema.py +++ b/spp_api_v2_change_request/tests/test_change_request_type_schema.py @@ -45,7 +45,7 @@ def test_get_type_schema(self): self.assertIsNotNone(result) self.assertIn("typeInfo", result) - self.assertIn("fields", result) + self.assertIn("detailSchema", result) self.assertIn("availableDocuments", result) self.assertIn("requiredDocuments", result) @@ -61,116 +61,161 @@ def test_get_type_schema_not_found(self): self.assertIsNone(result) # ────────────────────────────────────────────────────────────────────── - # Field definition tests + # JSON Schema structure tests # ────────────────────────────────────────────────────────────────────── - def test_field_definitions_include_expected_fields(self): - """given_name, family_name, birthdate are present in the schema.""" + def test_detail_schema_is_valid_json_schema(self): + """detailSchema has $schema, type=object, and properties.""" service = self._get_service() result = service.get_type_schema("edit_individual") + schema = result["detailSchema"] - field_names = [f["name"] for f in result["fields"]] - self.assertIn("given_name", field_names) - self.assertIn("family_name", field_names) - self.assertIn("birthdate", field_names) + self.assertEqual(schema["$schema"], "https://json-schema.org/draft/2020-12/schema") + self.assertEqual(schema["type"], "object") + self.assertIn("properties", schema) + self.assertIsInstance(schema["properties"], dict) - def test_field_definitions_exclude_internal_fields(self): + def test_detail_schema_has_title(self): + """detailSchema has a title derived from the CR type name.""" + service = self._get_service() + result = service.get_type_schema("edit_individual") + schema = result["detailSchema"] + + self.assertEqual(schema["title"], "Edit Individual Detail") + + # ────────────────────────────────────────────────────────────────────── + # Field property tests + # ────────────────────────────────────────────────────────────────────── + + def test_properties_include_expected_fields(self): + """given_name, family_name, birthdate are present in the schema properties.""" + service = self._get_service() + result = service.get_type_schema("edit_individual") + properties = result["detailSchema"]["properties"] + + self.assertIn("given_name", properties) + self.assertIn("family_name", properties) + self.assertIn("birthdate", properties) + + def test_properties_exclude_internal_fields(self): """Internal fields like id, create_uid, message_ids, change_request_id are absent.""" service = self._get_service() result = service.get_type_schema("edit_individual") + properties = result["detailSchema"]["properties"] - field_names = {f["name"] for f in result["fields"]} - self.assertNotIn("id", field_names) - self.assertNotIn("create_uid", field_names) - self.assertNotIn("message_ids", field_names) - self.assertNotIn("change_request_id", field_names) - self.assertNotIn("registrant_id", field_names) - self.assertNotIn("approval_state", field_names) - self.assertNotIn("is_applied", field_names) + self.assertNotIn("id", properties) + self.assertNotIn("create_uid", properties) + self.assertNotIn("message_ids", properties) + self.assertNotIn("change_request_id", properties) + self.assertNotIn("registrant_id", properties) + self.assertNotIn("approval_state", properties) + self.assertNotIn("is_applied", properties) def test_field_types_mapped_correctly(self): - """Odoo field types map to the correct API types.""" + """Odoo field types map to the correct JSON Schema types.""" service = self._get_service() result = service.get_type_schema("edit_individual") + properties = result["detailSchema"]["properties"] - fields_by_name = {f["name"]: f for f in result["fields"]} - - # char -> string - self.assertEqual(fields_by_name["given_name"]["type"], "string") - # date -> date - self.assertEqual(fields_by_name["birthdate"]["type"], "date") - # many2one vocabulary -> code - self.assertEqual(fields_by_name["gender_id"]["type"], "code") + # char -> {"type": "string"} + self.assertEqual(properties["given_name"]["type"], "string") + # date -> {"type": "string", "format": "date"} + self.assertEqual(properties["birthdate"]["type"], "string") + self.assertEqual(properties["birthdate"]["format"], "date") + # many2one vocabulary -> {"type": "object", "x-field-type": "vocabulary"} + self.assertEqual(properties["gender_id"]["type"], "object") + self.assertEqual(properties["gender_id"]["x-field-type"], "vocabulary") def test_vocabulary_field_includes_namespace(self): - """A vocabulary many2one field has vocabulary with namespaceUri and codes.""" + """A vocabulary field has system const and oneOf codes.""" service = self._get_service() result = service.get_type_schema("edit_individual") + properties = result["detailSchema"]["properties"] + gender_prop = properties["gender_id"] - fields_by_name = {f["name"]: f for f in result["fields"]} - gender_field = fields_by_name["gender_id"] + self.assertEqual(gender_prop.get("x-vocabulary-uri"), "urn:iso:std:iso:5218") - self.assertIsNotNone(gender_field.get("vocabulary")) - vocab = gender_field["vocabulary"] - self.assertEqual(vocab["namespaceUri"], "urn:iso:std:iso:5218") - self.assertIsInstance(vocab["codes"], list) - self.assertTrue(len(vocab["codes"]) > 0) + # system property should have const with the namespace URI + system_prop = gender_prop["properties"]["system"] + self.assertEqual(system_prop["const"], "urn:iso:std:iso:5218") - # Check code structure - code_values = [c["value"] for c in vocab["codes"]] + # code property should have oneOf with vocabulary codes + code_prop = gender_prop["properties"]["code"] + self.assertIn("oneOf", code_prop) + code_values = [entry["const"] for entry in code_prop["oneOf"]] self.assertIn("1", code_values) self.assertIn("2", code_values) def test_selection_field_includes_choices(self): - """Selection fields have a choices array (if any exist on the model).""" + """Selection fields have oneOf with const/title entries (if any exist on the model).""" service = self._get_service() result = service.get_type_schema("edit_individual") + properties = result["detailSchema"]["properties"] - # edit_individual may not have selection fields, so just verify the - # structure: if any field has type "selection", it must have choices - for field_def in result["fields"]: - if field_def["type"] == "selection": - self.assertIsNotNone(field_def.get("choices")) - self.assertIsInstance(field_def["choices"], list) - if field_def["choices"]: - self.assertIn("value", field_def["choices"][0]) - self.assertIn("label", field_def["choices"][0]) + # Check any selection-type Odoo field on the detail model + detail_model = self.env["spp.cr.detail.edit_individual"] + for field_name, field in detail_model._fields.items(): + if field.type == "selection" and field_name in properties: + prop = properties[field_name] + if "oneOf" in prop: + for entry in prop["oneOf"]: + self.assertIn("const", entry) + self.assertIn("title", entry) def test_computed_stored_field_is_readonly(self): - """Fields with a compute method are marked readonly.""" + """Fields with a compute method are marked readOnly in the schema.""" service = self._get_service() - # Test the logic directly via _build_field_definitions to check - # that computed fields are marked as readonly. - detail_model = self.env["spp.cr.detail.edit_individual"] - field_defs = service._build_field_definitions(self.cr_type_edit, detail_model) + result = service.get_type_schema("edit_individual") + properties = result["detailSchema"]["properties"] - for field_def in field_defs: - field_name = field_def["name"] + detail_model = self.env["spp.cr.detail.edit_individual"] + for field_name, prop in properties.items(): odoo_field = detail_model._fields.get(field_name) if odoo_field and odoo_field.compute: self.assertTrue( - field_def["readonly"], - f"Computed field {field_name} should be readonly", + prop.get("readOnly"), + f"Computed field {field_name} should have readOnly=true", ) - def test_dynamic_domain_does_not_crash(self): - """A domain containing Python name references does not crash vocabulary extraction.""" + def test_required_fields_in_required_array(self): + """Required Odoo fields appear in the schema's required array.""" service = self._get_service() - # Simulate a domain string with a Python variable reference - info = service._extract_vocabulary_info_from_domain( - "[('id', '!=', registrant_id)]", - "spp.vocabulary.code", - ) - # Should return None gracefully, not crash - self.assertIsNone(info) + result = service.get_type_schema("edit_individual") + schema = result["detailSchema"] + + required = schema.get("required", []) + detail_model = self.env["spp.cr.detail.edit_individual"] + for field_name in required: + odoo_field = detail_model._fields.get(field_name) + self.assertTrue( + odoo_field and odoo_field.required, + f"Field {field_name} is in 'required' but Odoo field is not required", + ) - def test_field_definition_has_label(self): - """All field definitions have a non-empty label.""" + def test_field_property_has_title(self): + """All field properties have a non-empty title.""" service = self._get_service() result = service.get_type_schema("edit_individual") + properties = result["detailSchema"]["properties"] - for field_def in result["fields"]: + for field_name, prop in properties.items(): self.assertTrue( - field_def.get("label"), - f"Field {field_def['name']} should have a non-empty label", + prop.get("title"), + f"Property {field_name} should have a non-empty title", ) + + # ────────────────────────────────────────────────────────────────────── + # Vocabulary extraction edge cases + # ────────────────────────────────────────────────────────────────────── + + def test_dynamic_domain_does_not_crash(self): + """A domain containing Python name references does not crash vocabulary extraction.""" + from odoo.addons.spp_api_v2.services.schema_builder import OdooModelSchemaBuilder + + builder = OdooModelSchemaBuilder(self.env) + info = builder._extract_vocabulary_info_from_domain( + "[('id', '!=', registrant_id)]", + "spp.vocabulary.code", + ) + # Should return None gracefully, not crash + self.assertIsNone(info) From 570e42fbe39be31d6e27c76a253ed0db672e1c1d Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 18 Feb 2026 16:45:26 +0700 Subject: [PATCH 3/5] fix(spp_api_v2_change_request): use Literal type for finite-valued str fields Replace plain str with Literal for fields that have a known, fixed set of valid values: ChangeRequestResponse.status and ChangeRequestTypeInfo.target_type. This provides stricter validation and clearer API documentation. --- spp_api_v2/services/schema_builder.py | 5 ++-- spp_api_v2_change_request/__manifest__.py | 28 ------------------- .../schemas/change_request.py | 7 +++-- 3 files changed, 7 insertions(+), 33 deletions(-) diff --git a/spp_api_v2/services/schema_builder.py b/spp_api_v2/services/schema_builder.py index ef08b3ef..b858ebed 100644 --- a/spp_api_v2/services/schema_builder.py +++ b/spp_api_v2/services/schema_builder.py @@ -189,11 +189,12 @@ def _extract_selection_choices(self, field) -> list[dict[str, str]]: Handles both list-of-tuples and callable selections. """ selection = field.selection + field_name = field.name if callable(selection): try: selection = selection(self.env[field.model_name]) except Exception: - _logger.warning("Could not evaluate callable selection for %s", field.name, exc_info=True) + _logger.warning("Could not evaluate callable selection for %s", field_name, exc_info=True) return [] if not selection: return [] @@ -202,7 +203,7 @@ def _extract_selection_choices(self, field) -> list[dict[str, str]]: if isinstance(item, (list, tuple)) and len(item) >= 2: result.append({"value": item[0], "label": item[1]}) else: - _logger.debug("Skipping unparseable selection item for %s: %r", field.name, item) + _logger.debug("Skipping unparseable selection item for %s: %r", field_name, item) return result def _extract_vocabulary_info_from_domain( diff --git a/spp_api_v2_change_request/__manifest__.py b/spp_api_v2_change_request/__manifest__.py index 6cda90e1..234198b9 100644 --- a/spp_api_v2_change_request/__manifest__.py +++ b/spp_api_v2_change_request/__manifest__.py @@ -25,32 +25,4 @@ "summary": """ REST API endpoints for Change Request V2. """, - "description": """ -OpenSPP API V2 - Change Request -================================ - -Extends OpenSPP API V2 with Change Request endpoints. - -Endpoints ---------- -- ``POST /ChangeRequest`` - Create a new change request -- ``GET /ChangeRequest/{identifier}`` - Read a change request by reference -- ``GET /ChangeRequest`` - Search change requests -- ``PUT /ChangeRequest/{identifier}`` - Update change request detail data -- ``POST /ChangeRequest/{identifier}/$submit`` - Submit for approval -- ``POST /ChangeRequest/{identifier}/$approve`` - Approve (requires permission) -- ``POST /ChangeRequest/{identifier}/$reject`` - Reject (requires permission) -- ``POST /ChangeRequest/{identifier}/$request-revision`` - Request revision (requires permission) -- ``POST /ChangeRequest/{identifier}/$apply`` - Apply changes to registrant -- ``POST /ChangeRequest/{identifier}/$reset`` - Reset rejected/revision CR to draft -- ``GET /ChangeRequest/$types`` - List all active CR types -- ``GET /ChangeRequest/$types/{code}`` - Get field schema for a CR type - -Design Principles ------------------ -- Uses CR reference (CR/2024/00001), NOT database IDs -- Returns appropriate HTTP status codes -- Follows OpenSPP API V2 patterns -- Requires authentication via OAuth 2.0 - """, } diff --git a/spp_api_v2_change_request/schemas/change_request.py b/spp_api_v2_change_request/schemas/change_request.py index e143b905..8c4d914f 100644 --- a/spp_api_v2_change_request/schemas/change_request.py +++ b/spp_api_v2_change_request/schemas/change_request.py @@ -103,10 +103,9 @@ class ChangeRequestResponse(BaseModel): request_type: ChangeRequestType = Field(..., alias="requestType") # Status - status: str = Field( + status: Literal["draft", "pending", "revision", "approved", "rejected", "applied"] = Field( ..., description="Current status", - json_schema_extra={"enum": ["draft", "pending", "revision", "approved", "rejected", "applied"]}, ) # Target registrant @@ -202,7 +201,9 @@ class ChangeRequestTypeInfo(BaseModel): code: str = Field(..., description="Type code (e.g., add_member)") name: str = Field(..., description="Human-readable type name") - target_type: str = Field(..., alias="targetType", description="Target registrant type (individual, group, both)") + target_type: Literal["individual", "group", "both"] = Field( + ..., alias="targetType", description="Target registrant type" + ) requires_applicant: bool = Field(False, alias="requiresApplicant", description="Whether an applicant is required") class Config: From 136faa1a0d296947d8903ceba8c05582c685af05 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 19 Feb 2026 20:50:31 +0700 Subject: [PATCH 4/5] chore: regenerate addon READMEs and update readme template --- endpoint_route_handler/README.rst | 7 +--- .../static/description/index.html | 33 ++++++--------- spp_alerts/README.rst | 6 +-- spp_alerts/static/description/index.html | 34 +++++++-------- spp_api_v2/README.rst | 6 +-- spp_api_v2/static/description/index.html | 34 +++++++-------- spp_api_v2_change_request/README.rst | 6 +-- .../static/description/index.html | 36 +++++++--------- spp_api_v2_cycles/README.rst | 6 +-- .../static/description/index.html | 34 +++++++-------- spp_api_v2_data/README.rst | 6 +-- spp_api_v2_data/static/description/index.html | 34 +++++++-------- spp_api_v2_entitlements/README.rst | 6 +-- .../static/description/index.html | 34 +++++++-------- spp_api_v2_products/README.rst | 6 +-- .../static/description/index.html | 34 +++++++-------- spp_api_v2_service_points/README.rst | 6 +-- .../static/description/index.html | 34 +++++++-------- spp_api_v2_vocabulary/README.rst | 6 +-- .../static/description/index.html | 32 ++++++--------- spp_approval/README.rst | 6 +-- spp_approval/static/description/index.html | 36 +++++++--------- spp_area/README.rst | 6 +-- spp_area/static/description/index.html | 36 +++++++--------- spp_area_hdx/README.rst | 6 +-- spp_audit/README.rst | 6 +-- spp_audit/static/description/index.html | 34 +++++++-------- spp_banking/README.rst | 6 +-- spp_banking/static/description/index.html | 34 +++++++-------- spp_base_common/README.rst | 6 +-- spp_base_common/static/description/index.html | 34 +++++++-------- spp_base_setting/README.rst | 6 +-- .../static/description/index.html | 36 +++++++--------- spp_branding_kit/README.rst | 6 +-- spp_cel_domain/README.rst | 6 +-- spp_cel_domain/static/description/index.html | 34 +++++++-------- spp_cel_event/README.rst | 6 +-- spp_cel_event/static/description/index.html | 34 +++++++-------- spp_cel_registry_search/README.rst | 6 +-- .../static/description/index.html | 34 +++++++-------- spp_cel_vocabulary/README.rst | 7 +--- .../static/description/index.html | 37 +++++++---------- spp_cel_widget/README.rst | 6 +-- spp_cel_widget/static/description/index.html | 38 ++++++++--------- spp_change_request_v2/README.rst | 6 +-- .../static/description/index.html | 36 +++++++--------- spp_claim_169/README.rst | 6 +-- spp_consent/README.rst | 6 +-- spp_consent/static/description/index.html | 34 +++++++-------- spp_cr_types_advanced/README.rst | 6 +-- .../static/description/index.html | 34 +++++++-------- spp_cr_types_base/README.rst | 6 +-- .../static/description/index.html | 34 +++++++-------- spp_custom_field/README.rst | 6 +-- .../static/description/index.html | 34 +++++++-------- spp_dci/README.rst | 7 +--- spp_dci/static/description/index.html | 39 ++++++++---------- spp_dci_client/README.rst | 7 +--- spp_dci_client/static/description/index.html | 41 ++++++++----------- spp_dci_client_crvs/README.rst | 7 +--- .../static/description/index.html | 37 +++++++---------- spp_dci_client_dr/README.rst | 7 +--- .../static/description/index.html | 39 ++++++++---------- spp_dci_client_ibr/README.rst | 7 +--- .../static/description/index.html | 37 +++++++---------- spp_dci_server/README.rst | 7 +--- spp_dci_server/static/description/index.html | 37 +++++++---------- spp_demo/README.rst | 6 +-- spp_demo/static/description/index.html | 34 +++++++-------- spp_dms/README.rst | 6 +-- spp_dms/static/description/index.html | 34 +++++++-------- spp_drims/README.rst | 6 +-- spp_drims/static/description/index.html | 34 +++++++-------- spp_drims_sl/README.rst | 7 +--- spp_drims_sl/static/description/index.html | 39 ++++++++---------- spp_drims_sl_demo/README.rst | 7 +--- .../static/description/index.html | 39 ++++++++---------- spp_event_data/README.rst | 6 +-- spp_event_data/static/description/index.html | 34 +++++++-------- spp_gis/README.rst | 6 +-- spp_gis/static/description/index.html | 34 +++++++-------- spp_gis_report/README.rst | 6 +-- spp_gis_report/static/description/index.html | 36 +++++++--------- spp_gis_report_programs/README.rst | 6 +-- .../static/description/index.html | 34 +++++++-------- spp_grm/README.rst | 6 +-- spp_grm/static/description/index.html | 34 +++++++-------- spp_grm_demo/README.rst | 7 +--- spp_grm_demo/static/description/index.html | 39 ++++++++---------- spp_hide_menus_base/README.rst | 6 +-- .../static/description/index.html | 34 +++++++-------- spp_key_management/README.rst | 6 +-- .../static/description/index.html | 34 +++++++-------- spp_mis_demo_v2/README.rst | 6 +-- spp_mis_demo_v2/static/description/index.html | 36 +++++++--------- spp_programs/README.rst | 6 +-- spp_programs/static/description/index.html | 34 +++++++-------- spp_registry/README.rst | 6 +-- spp_registry/static/description/index.html | 34 +++++++-------- spp_registry_search/README.rst | 6 +-- .../static/description/index.html | 34 +++++++-------- spp_security/README.rst | 6 +-- spp_security/static/description/index.html | 34 +++++++-------- spp_service_points/README.rst | 6 +-- .../static/description/index.html | 36 +++++++--------- spp_source_tracking/README.rst | 6 +-- .../static/description/index.html | 34 +++++++-------- spp_starter_social_registry/README.rst | 6 +-- .../static/description/index.html | 30 ++++++-------- spp_starter_sp_mis/README.rst | 6 +-- .../static/description/index.html | 34 +++++++-------- spp_studio/README.rst | 6 +-- spp_studio/static/description/index.html | 34 +++++++-------- spp_studio_api_v2/README.rst | 6 +-- .../static/description/index.html | 36 +++++++--------- spp_studio_change_requests/README.rst | 6 +-- .../static/description/index.html | 34 +++++++-------- spp_studio_events/README.rst | 6 +-- .../static/description/index.html | 34 +++++++-------- spp_user_roles/README.rst | 6 +-- spp_user_roles/static/description/index.html | 36 +++++++--------- spp_versioning/README.rst | 6 +-- spp_versioning/static/description/index.html | 34 +++++++-------- spp_vocabulary/README.rst | 6 +-- spp_vocabulary/static/description/index.html | 34 +++++++-------- tools/readme_template.rst.jinja | 1 - 126 files changed, 943 insertions(+), 1588 deletions(-) diff --git a/endpoint_route_handler/README.rst b/endpoint_route_handler/README.rst index 5ea91c0a..ef50918e 100644 --- a/endpoint_route_handler/README.rst +++ b/endpoint_route_handler/README.rst @@ -1,12 +1,8 @@ -.. image:: https://odoo-community.org/readme-banner-image - :target: https://odoo-community.org/get-involved?utm_source=readme - :alt: Odoo Community Association - ====================== Endpoint route handler ====================== -.. +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! @@ -34,7 +30,6 @@ Can be used as a mixin or as a tool. .. IMPORTANT:: This is an alpha version, the data model and design can change at any time without warning. Only for development or testing purpose, do not use in production. - `More details on development status `_ **Table of contents** diff --git a/endpoint_route_handler/static/description/index.html b/endpoint_route_handler/static/description/index.html index 2bbee3c1..42c23095 100644 --- a/endpoint_route_handler/static/description/index.html +++ b/endpoint_route_handler/static/description/index.html @@ -3,7 +3,7 @@ -README.rst +Endpoint route handler -
+
+

Endpoint route handler

- - -Odoo Community Association - -
-

Endpoint route handler