Skip to content

Commit d3a10d8

Browse files
committed
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)
1 parent 387a638 commit d3a10d8

21 files changed

Lines changed: 864 additions & 328 deletions

spp_api_v2/services/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@
88
from . import individual_service
99
from . import program_membership_service
1010
from . import program_service
11+
from . import schema_builder
1112
from . import search_service

spp_api_v2/services/outgoing_api_log_service.py

Lines changed: 16 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -80,25 +80,22 @@ def log_call(
8080
truncated_request = self._truncate_payload(request_summary)
8181
truncated_response = self._truncate_payload(response_summary)
8282

83-
return (
84-
self.env["spp.api.outgoing.log"]
85-
.sudo()
86-
.log_call(
87-
url=url,
88-
endpoint=endpoint,
89-
http_method=http_method,
90-
request_summary=truncated_request,
91-
response_summary=truncated_response,
92-
response_status_code=response_status_code,
93-
user_id=self.user_id,
94-
origin_model=origin_model,
95-
origin_record_id=origin_record_id,
96-
duration_ms=duration_ms,
97-
service_name=self.service_name,
98-
service_code=self.service_code,
99-
status=status,
100-
error_detail=error_detail,
101-
)
83+
log_model = self.env["spp.api.outgoing.log"].sudo() # nosemgrep: odoo-sudo-without-context
84+
return log_model.log_call(
85+
url=url,
86+
endpoint=endpoint,
87+
http_method=http_method,
88+
request_summary=truncated_request,
89+
response_summary=truncated_response,
90+
response_status_code=response_status_code,
91+
user_id=self.user_id,
92+
origin_model=origin_model,
93+
origin_record_id=origin_record_id,
94+
duration_ms=duration_ms,
95+
service_name=self.service_name,
96+
service_code=self.service_code,
97+
status=status,
98+
error_detail=error_detail,
10299
)
103100
except (KeyError, AttributeError, TypeError) as e:
104101
_logger.warning("Failed to log outgoing API call due to data error: %s", type(e).__name__)
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
2+
"""Generic builder that converts an Odoo model's fields to JSON Schema 2020-12."""
3+
4+
import ast
5+
import logging
6+
from typing import Any
7+
8+
from odoo.api import Environment
9+
10+
_logger = logging.getLogger(__name__)
11+
12+
# Field types that are always skipped (no meaningful JSON Schema representation)
13+
SKIP_FIELD_TYPES = {"binary", "many2many", "one2many"}
14+
15+
16+
class OdooModelSchemaBuilder:
17+
"""Build a JSON Schema 2020-12 dict from an Odoo model's fields.
18+
19+
Usage::
20+
21+
builder = OdooModelSchemaBuilder(env)
22+
schema = builder.build_schema(
23+
env["res.partner"],
24+
skip_fields={"message_ids", "activity_ids"},
25+
title="Partner",
26+
)
27+
"""
28+
29+
def __init__(self, env: Environment):
30+
self.env = env
31+
32+
def build_schema(
33+
self,
34+
model,
35+
*,
36+
skip_fields: set[str] | None = None,
37+
title: str | None = None,
38+
) -> dict[str, Any]:
39+
"""Build JSON Schema 2020-12 from an Odoo model's fields.
40+
41+
Args:
42+
model: An Odoo model recordset (e.g. ``env["res.partner"]``).
43+
skip_fields: Field names to exclude from the schema.
44+
title: Schema title. Falls back to the model's ``_description``.
45+
46+
Returns:
47+
A dict representing a valid JSON Schema 2020-12 document.
48+
"""
49+
skip = skip_fields or set()
50+
properties: dict[str, Any] = {}
51+
required: list[str] = []
52+
53+
for field_name, field in model._fields.items():
54+
if field_name.startswith("_"):
55+
continue
56+
if field_name in skip:
57+
continue
58+
if not field.store:
59+
continue
60+
if field.type in SKIP_FIELD_TYPES:
61+
continue
62+
63+
prop = self._field_to_property(field)
64+
if prop is None:
65+
continue
66+
67+
# Standard metadata keywords
68+
if field.string:
69+
prop["title"] = field.string
70+
if field.help:
71+
prop["description"] = field.help
72+
if bool(field.readonly) or bool(field.compute):
73+
prop["readOnly"] = True
74+
75+
properties[field_name] = prop
76+
77+
if field.required:
78+
required.append(field_name)
79+
80+
schema: dict[str, Any] = {
81+
"$schema": "https://json-schema.org/draft/2020-12/schema",
82+
"type": "object",
83+
"title": title or getattr(model, "_description", model._name),
84+
"properties": properties,
85+
}
86+
if required:
87+
schema["required"] = sorted(required)
88+
89+
return schema
90+
91+
# ------------------------------------------------------------------
92+
# Field type mapping
93+
# ------------------------------------------------------------------
94+
95+
def _field_to_property(self, field) -> dict[str, Any] | None:
96+
"""Convert a single Odoo field to a JSON Schema property dict.
97+
98+
Returns None if the field type is not supported.
99+
"""
100+
handler = self._TYPE_HANDLERS.get(field.type)
101+
if handler is not None:
102+
return handler(self, field)
103+
104+
# many2one requires special logic
105+
if field.type == "many2one":
106+
return self._handle_many2one(field)
107+
108+
return None
109+
110+
# --- simple types ---------------------------------------------------
111+
112+
def _handle_char(self, field) -> dict[str, Any]:
113+
return {"type": "string"}
114+
115+
def _handle_text(self, field) -> dict[str, Any]:
116+
return {"type": "string", "x-display": "multiline"}
117+
118+
def _handle_integer(self, field) -> dict[str, Any]:
119+
return {"type": "integer"}
120+
121+
def _handle_float(self, field) -> dict[str, Any]:
122+
return {"type": "number"}
123+
124+
def _handle_boolean(self, field) -> dict[str, Any]:
125+
return {"type": "boolean"}
126+
127+
def _handle_date(self, field) -> dict[str, Any]:
128+
return {"type": "string", "format": "date"}
129+
130+
def _handle_datetime(self, field) -> dict[str, Any]:
131+
return {"type": "string", "format": "date-time"}
132+
133+
# --- selection -------------------------------------------------------
134+
135+
def _handle_selection(self, field) -> dict[str, Any]:
136+
choices = self._extract_selection_choices(field)
137+
if choices:
138+
return {"oneOf": [{"const": c["value"], "title": c["label"]} for c in choices]}
139+
return {"type": "string"}
140+
141+
# --- many2one --------------------------------------------------------
142+
143+
def _handle_many2one(self, field) -> dict[str, Any]:
144+
if field.comodel_name == "spp.vocabulary.code":
145+
return self._handle_vocabulary(field)
146+
return {
147+
"anyOf": [{"type": "string"}, {"type": "integer"}],
148+
"x-field-type": "reference",
149+
"x-reference-model": field.comodel_name,
150+
}
151+
152+
def _handle_vocabulary(self, field) -> dict[str, Any]:
153+
domain_str = str(field.domain) if field.domain else ""
154+
vocab_info = self._extract_vocabulary_info_from_domain(
155+
domain_str,
156+
field.comodel_name,
157+
)
158+
159+
prop: dict[str, Any] = {
160+
"type": "object",
161+
"properties": {
162+
"system": {"type": "string"},
163+
"code": {"type": "string"},
164+
},
165+
"required": ["system", "code"],
166+
"x-field-type": "vocabulary",
167+
}
168+
169+
if vocab_info:
170+
namespace_uri = vocab_info["namespaceUri"]
171+
prop["properties"]["system"] = {"type": "string", "const": namespace_uri}
172+
prop["x-vocabulary-uri"] = namespace_uri
173+
174+
codes = vocab_info["codes"]
175+
if codes:
176+
prop["properties"]["code"] = {
177+
"oneOf": [{"const": c["value"], "title": c["label"]} for c in codes],
178+
}
179+
180+
return prop
181+
182+
# ------------------------------------------------------------------
183+
# Helpers (selection & vocabulary extraction)
184+
# ------------------------------------------------------------------
185+
186+
def _extract_selection_choices(self, field) -> list[dict[str, str]]:
187+
"""Extract selection choices from an Odoo field.
188+
189+
Handles both list-of-tuples and callable selections.
190+
"""
191+
selection = field.selection
192+
field_name = field.name
193+
if callable(selection):
194+
try:
195+
selection = selection(self.env[field.model_name])
196+
except Exception:
197+
_logger.warning("Could not evaluate callable selection for %s", field_name, exc_info=True)
198+
return []
199+
if not selection:
200+
return []
201+
result = []
202+
for item in selection:
203+
if isinstance(item, (list, tuple)) and len(item) >= 2:
204+
result.append({"value": item[0], "label": item[1]})
205+
else:
206+
_logger.debug("Skipping unparseable selection item for %s: %r", field_name, item)
207+
return result
208+
209+
def _extract_vocabulary_info_from_domain(
210+
self,
211+
domain_str: str,
212+
comodel_name: str,
213+
) -> dict[str, Any] | None:
214+
"""Parse a domain string to extract vocabulary namespace and load codes.
215+
216+
Args:
217+
domain_str: String representation of an Odoo domain (e.g.
218+
``[('namespace_uri', '=', 'urn:iso:std:iso:5218')]``)
219+
comodel_name: The comodel (expected to be ``spp.vocabulary.code``)
220+
221+
Returns:
222+
Dict with ``namespaceUri`` and ``codes``, or None if unparseable.
223+
"""
224+
if not domain_str:
225+
return None
226+
227+
try:
228+
domain = ast.literal_eval(domain_str)
229+
except (ValueError, SyntaxError):
230+
# Domain contains Python name references (e.g., registrant_id)
231+
return None
232+
233+
if not isinstance(domain, list):
234+
return None
235+
236+
namespace_uri = None
237+
for leaf in domain:
238+
if not isinstance(leaf, (list, tuple)) or len(leaf) != 3:
239+
continue
240+
field_path, operator, value = leaf
241+
if operator != "=":
242+
continue
243+
if field_path in ("namespace_uri", "vocabulary_id.namespace_uri"):
244+
namespace_uri = value
245+
break
246+
247+
if not namespace_uri:
248+
return None
249+
250+
codes = self.env[comodel_name].search(
251+
[("namespace_uri", "=", namespace_uri)],
252+
order="sequence, code",
253+
)
254+
return {
255+
"namespaceUri": namespace_uri,
256+
"codes": [{"value": code.code, "label": code.display or code.code} for code in codes],
257+
}
258+
259+
# ------------------------------------------------------------------
260+
# Dispatch table (Odoo field type string → handler method)
261+
# ------------------------------------------------------------------
262+
263+
_TYPE_HANDLERS: dict[str, Any] = {
264+
"char": _handle_char,
265+
"text": _handle_text,
266+
"html": _handle_text,
267+
"integer": _handle_integer,
268+
"float": _handle_float,
269+
"monetary": _handle_float,
270+
"boolean": _handle_boolean,
271+
"date": _handle_date,
272+
"datetime": _handle_datetime,
273+
"selection": _handle_selection,
274+
}

spp_api_v2/tests/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,5 @@
3535
from . import test_program_membership_service
3636
from . import test_program_service
3737
from . import test_scope_enforcement
38+
from . import test_schema_builder
3839
from . import test_search_service

0 commit comments

Comments
 (0)