Skip to content

Commit 35e0b84

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 e858186 commit 35e0b84

File tree

8 files changed

+767
-258
lines changed

8 files changed

+767
-258
lines changed

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
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
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+
if callable(selection):
193+
try:
194+
selection = selection(self.env[field.model_name])
195+
except Exception:
196+
_logger.warning("Could not evaluate callable selection for %s", field.name, exc_info=True)
197+
return []
198+
if not selection:
199+
return []
200+
result = []
201+
for item in selection:
202+
if isinstance(item, (list, tuple)) and len(item) >= 2:
203+
result.append({"value": item[0], "label": item[1]})
204+
else:
205+
_logger.debug("Skipping unparseable selection item for %s: %r", field.name, item)
206+
return result
207+
208+
def _extract_vocabulary_info_from_domain(
209+
self,
210+
domain_str: str,
211+
comodel_name: str,
212+
) -> dict[str, Any] | None:
213+
"""Parse a domain string to extract vocabulary namespace and load codes.
214+
215+
Args:
216+
domain_str: String representation of an Odoo domain (e.g.
217+
``[('namespace_uri', '=', 'urn:iso:std:iso:5218')]``)
218+
comodel_name: The comodel (expected to be ``spp.vocabulary.code``)
219+
220+
Returns:
221+
Dict with ``namespaceUri`` and ``codes``, or None if unparseable.
222+
"""
223+
if not domain_str:
224+
return None
225+
226+
try:
227+
domain = ast.literal_eval(domain_str)
228+
except (ValueError, SyntaxError):
229+
# Domain contains Python name references (e.g., registrant_id)
230+
return None
231+
232+
if not isinstance(domain, list):
233+
return None
234+
235+
namespace_uri = None
236+
for leaf in domain:
237+
if not isinstance(leaf, (list, tuple)) or len(leaf) != 3:
238+
continue
239+
field_path, operator, value = leaf
240+
if operator != "=":
241+
continue
242+
if field_path in ("namespace_uri", "vocabulary_id.namespace_uri"):
243+
namespace_uri = value
244+
break
245+
246+
if not namespace_uri:
247+
return None
248+
249+
codes = self.env[comodel_name].search(
250+
[("namespace_uri", "=", namespace_uri)],
251+
order="sequence, code",
252+
)
253+
return {
254+
"namespaceUri": namespace_uri,
255+
"codes": [{"value": code.code, "label": code.display or code.code} for code in codes],
256+
}
257+
258+
# ------------------------------------------------------------------
259+
# Dispatch table (Odoo field type string → handler method)
260+
# ------------------------------------------------------------------
261+
262+
_TYPE_HANDLERS: dict[str, Any] = {
263+
"char": _handle_char,
264+
"text": _handle_text,
265+
"html": _handle_text,
266+
"integer": _handle_integer,
267+
"float": _handle_float,
268+
"monetary": _handle_float,
269+
"boolean": _handle_boolean,
270+
"date": _handle_date,
271+
"datetime": _handle_datetime,
272+
"selection": _handle_selection,
273+
}

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)