Skip to content

Commit 1674829

Browse files
CharStringsvenvandescheur
authored andcommitted
🐛 [#177] fix: OBField creation
- add test that was missing from #178 - fix OBField creation code and pull it into a function the order of get_args return value is not guaranteed: https://docs.python.org/3/library/typing.html#typing.get_args
1 parent 43005fd commit 1674829

File tree

4 files changed

+189
-98
lines changed

4 files changed

+189
-98
lines changed

backend/src/openbeheer/api/views.py

Lines changed: 9 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,15 @@
22

33
from abc import ABC
44
from collections.abc import Callable
5-
from functools import partial, reduce
6-
from operator import or_
5+
from functools import partial
76
from typing import (
87
TYPE_CHECKING,
98
Iterable,
109
Mapping,
1110
NoReturn,
1211
Protocol,
1312
Sequence,
14-
get_args,
1513
get_origin,
16-
get_type_hints,
1714
runtime_checkable,
1815
)
1916
from uuid import UUID
@@ -26,7 +23,6 @@
2623
from furl import furl
2724
from msgspec import (
2825
UNSET,
29-
Meta,
3026
Struct,
3127
ValidationError,
3228
convert,
@@ -53,9 +49,8 @@
5349
VersionSummary,
5450
ZGWError,
5551
ZGWResponse,
56-
as_ob_fieldtype,
57-
options,
5852
)
53+
from openbeheer.types._open_beheer import ob_fields_of_type
5954
from openbeheer.utils.decorators import handle_service_errors
6055

6156
if TYPE_CHECKING:
@@ -437,33 +432,8 @@ def parse_ob_fields(
437432
Options are inferred from the type annotation of
438433
`self.data_type`, but that may be set more general.
439434
"""
440-
441-
def to_ob_field(name: str, annotation: type) -> OBField:
442-
# closure over option_overrides
443-
not_applicable = object()
444-
args: tuple[type, Meta] = get_args(annotation)
445-
t, meta = args if args else (annotation, None)
446-
447-
ob_field = OBField(
448-
name=name,
449-
type=as_ob_fieldtype(t, meta),
450-
options=option_overrides.get(name, options(annotation)),
451-
)
452-
453-
for filter_name in [name, f"{name}__in"]:
454-
if (
455-
value := getattr(params, filter_name, not_applicable)
456-
) is not not_applicable:
457-
ob_field.filter_lookup = filter_name
458-
ob_field.filter_value = value
459-
else:
460-
ob_field.options = UNSET
461-
462-
return ob_field
463-
464-
attrs = get_type_hints(self.return_data_type, include_extras=True)
465435
return sorted(
466-
(to_ob_field(field, annotation) for field, annotation in attrs.items()),
436+
ob_fields_of_type(self.return_data_type, params, option_overrides),
467437
key=sort_key,
468438
)
469439

@@ -641,32 +611,13 @@ def _expand(self, client, object: T):
641611
return expand_one(client, self.expansions, object)
642612

643613
def get_fields(self) -> list[OBField]:
644-
field_types = reduce(
645-
or_,
646-
(
647-
get_type_hints(obj, include_extras=True)
648-
for obj in reversed(self.data_type.mro())
649-
),
650-
)
614+
ob_fields = ob_fields_of_type(self.data_type)
651615

652-
fields = []
653-
for field, annotation in field_types.items():
654-
if field == "_expand":
655-
continue
656-
657-
args: tuple[type, Meta] = get_args(annotation)
658-
t, meta = args if args else (annotation, None)
659-
660-
fields.append(
661-
OBField(
662-
name=field,
663-
type=as_ob_fieldtype(t, meta),
664-
options=options(t) or UNSET,
665-
filter_lookup=UNSET,
666-
editable=field not in self.expansions,
667-
)
668-
)
669-
return fields
616+
def adapt[F: OBField](field: F) -> F:
617+
field.editable = field.name not in self.expansions
618+
return field
619+
620+
return [adapt(f) for f in ob_fields if f.name != "Expand"]
670621

671622
@handle_service_errors
672623
def get(self, request: Request, slug: str, uuid: UUID, *args, **kwargs) -> Response:

backend/src/openbeheer/types/_open_beheer.py

Lines changed: 78 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,23 @@
66
import datetime
77
import enum
88
from functools import singledispatch
9+
from itertools import starmap
910
from types import NoneType, UnionType
10-
from typing import Self, Sequence, Type
11+
from typing import (
12+
Annotated,
13+
Iterable,
14+
Mapping,
15+
Self,
16+
Sequence,
17+
Type,
18+
get_args,
19+
get_type_hints,
20+
)
1121

1222
import msgspec
1323
from ape_pie import APIClient
1424
from furl import furl
15-
from msgspec import UNSET, Struct, UnsetType, structs
25+
from msgspec import UNSET, Meta, Struct, UnsetType, structs
1626

1727
from .ztc import (
1828
BesluitType,
@@ -97,47 +107,44 @@ class OBFieldType(enum.StrEnum):
97107
# jsx = enum.auto()
98108

99109

100-
def as_ob_fieldtype(t: type | UnionType, meta: msgspec.Meta | None) -> OBFieldType:
110+
def as_ob_fieldtype(
111+
t: type | UnionType | Annotated, meta: Meta | None = None
112+
) -> OBFieldType:
101113
"Return the `OBFieldType` for some annotation `t`"
102-
if isinstance(t, UnionType):
103-
return as_ob_fieldtype(
104-
next(ut for ut in t.__args__ if ut not in (NoneType, UnsetType)), meta
105-
)
106-
if t is bool:
107-
return OBFieldType.boolean
108-
if t in (int, float):
109-
return OBFieldType.number
110-
if t is str:
111-
# We return either "string" (input) or text" for the field type based on the
112-
# "max_length" meta attribute.
113-
#
114-
# Fields don't have this property set all the time, and it's absence can
115-
# possibly indicate no limit. Therefore, the default should be "text"
116-
# (textarea).
117-
#
118-
# Only for fields with a smaller "max_length" set we will use "string" (input).
119-
try:
120-
if meta and isinstance(meta.max_length, int) and meta.max_length <= 50:
121-
# Small fields should get an input
122-
return OBFieldType.string
123-
except (AttributeError, TypeError):
124-
pass
125-
126-
# Large fields should get a textarea
127-
return OBFieldType.text
128-
129-
if t is datetime.date:
130-
return OBFieldType.date
131-
return OBFieldType.string
132-
133-
134-
def options(t: type | UnionType) -> list[OBOption]:
114+
115+
args = get_args(t)
116+
meta = meta or next((arg for arg in args if isinstance(arg, Meta)), None)
117+
118+
match (t, meta):
119+
case type(), _ if t is bool:
120+
return OBFieldType.boolean
121+
case type(), _ if t in (int, float):
122+
return OBFieldType.number
123+
case _, Meta(max_length=int(n)) if str in args and n <= 50:
124+
# small strings get an input widget
125+
return OBFieldType.string
126+
case type(), _ if t is str:
127+
# large ones a textarea
128+
return OBFieldType.text
129+
case type(), _ if t is datetime.date:
130+
return OBFieldType.date
131+
case _ if args:
132+
# unpack Unions, Annotated etc.
133+
return as_ob_fieldtype(
134+
next(ut for ut in args if ut not in (NoneType, UnsetType)), meta
135+
)
136+
case _:
137+
# fallback to input widget
138+
return OBFieldType.string
139+
140+
141+
def options(t: type | UnionType | Annotated) -> list[OBOption]:
135142
"Find an enum in the type and turn it into options."
136143
match t:
137144
case enum.EnumType():
138145
return OBOption.from_enum(t)
139-
case UnionType():
140-
return [option for ut in t.__args__ for option in options(ut)]
146+
case _ if get_args(t):
147+
return sum(map(options, get_args(t)), [])
141148
case _:
142149
return []
143150

@@ -165,10 +172,37 @@ class OBField[T](Struct, rename="camel", omit_defaults=True):
165172

166173
def __post_init__(self):
167174
# camelize value of name
168-
self.name = "".join(
169-
part.title() if n else part for n, part in enumerate(self.name.split("_"))
175+
self.name = _camelize(self.name)
176+
177+
178+
def ob_fields_of_type(
179+
data_type: type,
180+
query_params: OBPagedQueryParams | None = None,
181+
option_overrides: Mapping[str, list[OBOption]] = {},
182+
) -> Iterable[OBField]:
183+
def to_ob_field(name: str, annotation: type) -> OBField:
184+
# closure over option_overrides
185+
not_applicable = object()
186+
187+
ob_field = OBField(
188+
name=name,
189+
type=as_ob_fieldtype(annotation),
190+
options=option_overrides.get(name, options(annotation)) or UNSET,
170191
)
171192

193+
if query_params:
194+
for filter_name in [name, f"{name}__in"]:
195+
if (
196+
value := getattr(query_params, filter_name, not_applicable)
197+
) is not not_applicable:
198+
ob_field.filter_lookup = filter_name
199+
ob_field.filter_value = value
200+
201+
return ob_field
202+
203+
attrs = get_type_hints(data_type, include_extras=True)
204+
return starmap(to_ob_field, attrs.items())
205+
172206

173207
class OBList[T](Struct):
174208
"""Used to draw list views on the frontend."""
@@ -329,3 +363,7 @@ class ZaakTypeWithUUID(UUIDMixin, ZaakType):
329363

330364
class ZaakObjectTypeWithUUID(UUIDMixin, ZaakObjectType):
331365
uuid: str | UnsetType = UNSET
366+
367+
368+
def _camelize(s: str) -> str:
369+
return "".join(part.title() if n else part for n, part in enumerate(s.split("_")))
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
from datetime import date
2+
from enum import Enum
3+
from typing import Annotated
4+
from unittest import TestCase
5+
6+
from hypothesis import assume, given, strategies as st # noqa: F401
7+
from msgspec import Meta, Struct
8+
from msgspec.json import decode, encode
9+
10+
from . import ztc
11+
from ._open_beheer import _camelize, ob_fields_of_type, options
12+
13+
ZTC_DATATYPES = [
14+
struct
15+
for name in dir(ztc)
16+
if (struct := getattr(ztc, name))
17+
and isinstance(struct, type)
18+
and issubclass(struct, Struct)
19+
]
20+
21+
ZTC_ENUMS = [
22+
struct
23+
for name in dir(ztc)
24+
if (struct := getattr(ztc, name))
25+
and isinstance(struct, type)
26+
and issubclass(struct, Enum)
27+
]
28+
29+
30+
@st.composite
31+
def ztc_struct_instances(draw):
32+
struct_type = draw(st.sampled_from(ZTC_DATATYPES))
33+
return draw(st.from_type(struct_type))
34+
35+
36+
class OBFieldsTest(TestCase):
37+
def test_simple_field_type_example(self):
38+
class MyStruct(Struct):
39+
boolean: bool
40+
integer: int
41+
ieee_754: float
42+
string: Annotated[str, Meta(max_length=49)]
43+
text: str
44+
date: date
45+
46+
(b, i, f, s, t, d) = ob_fields_of_type(MyStruct)
47+
assert b.name == "boolean"
48+
assert b.type == "boolean"
49+
assert i.name == "integer"
50+
assert i.type == "number"
51+
assert f.name == "ieee754" # camelcased
52+
assert f.type == "number"
53+
assert s.name == "string"
54+
assert s.type == "string"
55+
assert t.name == "text"
56+
assert t.type == "text"
57+
assert d.name == "date"
58+
assert d.type == "date"
59+
60+
@given(ztc_struct_instances())
61+
def test_all_fields_are_present(self, instance: Struct):
62+
def as_seen_by_frontend(struct) -> dict:
63+
"""Return struct as the dict that is seen by the frontend
64+
This also asserts that all generated OBFields are serializable
65+
"""
66+
return decode(encode(struct), type=dict)
67+
68+
ob_fields = ob_fields_of_type(type(instance))
69+
70+
ob_fields_as_dict = map(as_seen_by_frontend, ob_fields)
71+
field_names = {field["name"] for field in ob_fields_as_dict}
72+
73+
instance_dict = as_seen_by_frontend(instance)
74+
expected_struct_attributes = set(map(_camelize, instance_dict))
75+
76+
assert field_names == expected_struct_attributes
77+
78+
79+
class OBOptionsTest(TestCase):
80+
@given(enum=st.sampled_from(ZTC_ENUMS))
81+
def test_length(self, enum):
82+
"Every enum member should have an OBOption"
83+
ob_options = options(enum)
84+
assert len(ob_options) == len(enum)
85+
86+
@given(enum=st.sampled_from(ZTC_ENUMS))
87+
def test_values(self, enum: type[Enum]):
88+
"Every OBOption.value should be a valid Enum value"
89+
ob_options = options(enum)
90+
91+
for option in ob_options:
92+
assert option.value in enum
93+
94+
def test_annotated_enum(self):
95+
enum_annotation = Annotated[
96+
ztc.VertrouwelijkheidaanduidingEnum, Meta(description="blabla")
97+
]
98+
ob_options = options(enum_annotation)
99+
assert len(ob_options) == len(ztc.VertrouwelijkheidaanduidingEnum)

backend/src/openbeheer/zaaktype/tests/test_zaaktype_detail.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,9 @@ def test_retrieve_zaaktype(self):
341341
self.assertEqual(len(vertrouwelijkheidaanduiding_field["options"]), 8)
342342
self.assertEqual(fields_by_name["beginGeldigheid"]["type"], "date")
343343

344+
# has some editable fields
345+
assert any(f.get("editable") for f in fields_by_name.values())
346+
344347
def test_patch_zaaktype(self):
345348
zaaktype = self.helper.create_zaaktype()
346349

0 commit comments

Comments
 (0)