django-structured-metaobjects 1.0.0__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. django_structured_metaobjects-1.0.0.dist-info/METADATA +112 -0
  2. django_structured_metaobjects-1.0.0.dist-info/RECORD +40 -0
  3. django_structured_metaobjects-1.0.0.dist-info/WHEEL +6 -0
  4. django_structured_metaobjects-1.0.0.dist-info/licenses/LICENSE +21 -0
  5. django_structured_metaobjects-1.0.0.dist-info/top_level.txt +2 -0
  6. structured_metaobjects/__init__.py +14 -0
  7. structured_metaobjects/admin.py +20 -0
  8. structured_metaobjects/apps.py +8 -0
  9. structured_metaobjects/compiler.py +161 -0
  10. structured_metaobjects/forms.py +36 -0
  11. structured_metaobjects/migrations/0001_initial.py +73 -0
  12. structured_metaobjects/migrations/__init__.py +0 -0
  13. structured_metaobjects/models.py +103 -0
  14. structured_metaobjects/schema_builder.py +166 -0
  15. structured_metaobjects/serializers.py +37 -0
  16. structured_metaobjects/static/structured_metaobjects/admin/meta_instance.js +23 -0
  17. structured_metaobjects/urls.py +12 -0
  18. structured_metaobjects/views.py +35 -0
  19. tests/__init__.py +0 -0
  20. tests/app/__init__.py +0 -0
  21. tests/app/app/__init__.py +0 -0
  22. tests/app/app/asgi.py +5 -0
  23. tests/app/app/settings.py +77 -0
  24. tests/app/app/urls.py +14 -0
  25. tests/app/app/wsgi.py +5 -0
  26. tests/app/test_module/__init__.py +0 -0
  27. tests/app/test_module/admin.py +8 -0
  28. tests/app/test_module/apps.py +8 -0
  29. tests/app/test_module/migrations/0001_initial.py +22 -0
  30. tests/app/test_module/migrations/__init__.py +0 -0
  31. tests/app/test_module/models.py +12 -0
  32. tests/app/test_module/serializers.py +9 -0
  33. tests/app/test_module/views.py +9 -0
  34. tests/test_admin.py +25 -0
  35. tests/test_compiler.py +462 -0
  36. tests/test_forms.py +32 -0
  37. tests/test_models.py +213 -0
  38. tests/test_schema_builder.py +81 -0
  39. tests/test_serializers.py +79 -0
  40. tests/test_views.py +120 -0
tests/test_compiler.py ADDED
@@ -0,0 +1,462 @@
1
+ from datetime import date, datetime
2
+
3
+ import pytest
4
+ from pydantic import BaseModel as PydanticBaseModel
5
+
6
+ from structured_metaobjects.compiler import (
7
+ _cache,
8
+ _field_type,
9
+ _json_schema_extra,
10
+ build_pydantic_model,
11
+ clear_cache,
12
+ get_json_schema,
13
+ )
14
+ from structured_metaobjects.models import MetaType
15
+ from structured_metaobjects.schema_builder import MetaFieldKind, MetaTypeFieldDef
16
+
17
+
18
+ @pytest.mark.django_db
19
+ class TestBuildPydanticModel:
20
+ def _make_meta_type(self, name, schema):
21
+ return MetaType.objects.create(name=name, schema=schema)
22
+
23
+ def test_build_simple_string_field(self):
24
+ mt = self._make_meta_type("simple", [
25
+ {"name": "title", "kind": "string", "required": True},
26
+ ])
27
+ model_cls = build_pydantic_model(mt)
28
+ assert issubclass(model_cls, PydanticBaseModel)
29
+ instance = model_cls(title="Hello")
30
+ assert instance.title == "Hello"
31
+
32
+ def test_build_multiple_field_types(self):
33
+ mt = self._make_meta_type("multi", [
34
+ {"name": "name", "kind": "string", "required": True},
35
+ {"name": "age", "kind": "number", "integer": True},
36
+ {"name": "score", "kind": "number"},
37
+ {"name": "active", "kind": "boolean"},
38
+ ])
39
+ model_cls = build_pydantic_model(mt)
40
+ instance = model_cls(name="Alice", age=30, score=9.5, active=True)
41
+ assert instance.name == "Alice"
42
+ assert instance.age == 30
43
+
44
+ def test_build_optional_field_defaults_to_none(self):
45
+ mt = self._make_meta_type("opt", [
46
+ {"name": "bio", "kind": "string", "multiline": True, "required": False},
47
+ ])
48
+ model_cls = build_pydantic_model(mt)
49
+ instance = model_cls()
50
+ assert instance.bio is None
51
+
52
+ def test_build_group_field(self):
53
+ mt = self._make_meta_type("grp", [
54
+ {
55
+ "name": "address",
56
+ "kind": "group",
57
+ "children": [
58
+ {"name": "street", "kind": "string", "required": True},
59
+ {"name": "city", "kind": "string", "required": True},
60
+ ],
61
+ }
62
+ ])
63
+ model_cls = build_pydantic_model(mt)
64
+ instance = model_cls(address={"street": "123 Main", "city": "NYC"})
65
+ assert instance.address.street == "123 Main"
66
+
67
+ def test_build_list_field(self):
68
+ mt = self._make_meta_type("lst", [
69
+ {
70
+ "name": "items",
71
+ "kind": "list",
72
+ "children": [
73
+ {"name": "label", "kind": "string", "required": True},
74
+ ],
75
+ }
76
+ ])
77
+ model_cls = build_pydantic_model(mt)
78
+ instance = model_cls(items=[{"label": "A"}, {"label": "B"}])
79
+ assert len(instance.items) == 2
80
+ assert instance.items[0].label == "A"
81
+
82
+ def test_build_translated_field(self):
83
+ mt = self._make_meta_type("trans", [
84
+ {"name": "title", "kind": "string", "required": True, "translated": True},
85
+ ])
86
+ model_cls = build_pydantic_model(mt)
87
+ instance = model_cls(title={"en": "Hello", "it": "Ciao"})
88
+ assert instance.title["en"] == "Hello"
89
+
90
+ def test_build_ref_field(self):
91
+ mt = self._make_meta_type("ref", [
92
+ {"name": "page", "kind": "ref", "target_model": "test_module.Page"},
93
+ ])
94
+ model_cls = build_pydantic_model(mt)
95
+ assert model_cls is not None
96
+
97
+ def test_build_queryset_field(self):
98
+ mt = self._make_meta_type("qs", [
99
+ {"name": "pages", "kind": "queryset", "target_model": "test_module.Page"},
100
+ ])
101
+ model_cls = build_pydantic_model(mt)
102
+ assert model_cls is not None
103
+
104
+ def test_invalid_ref_target_raises(self):
105
+ mt = self._make_meta_type("badref", [
106
+ {"name": "thing", "kind": "ref", "target_model": "invalid"},
107
+ ])
108
+ with pytest.raises(ValueError, match="target_model"):
109
+ build_pydantic_model(mt)
110
+
111
+ def test_build_html_field(self):
112
+ mt = self._make_meta_type("html", [
113
+ {"name": "content", "kind": "html", "required": True},
114
+ ])
115
+ model_cls = build_pydantic_model(mt)
116
+ instance = model_cls(content="<p>Hello</p>")
117
+ assert instance.content == "<p>Hello</p>"
118
+
119
+ def test_build_date_field(self):
120
+ mt = self._make_meta_type("date", [
121
+ {"name": "birthday", "kind": "date", "required": True},
122
+ ])
123
+ model_cls = build_pydantic_model(mt)
124
+ instance = model_cls(birthday=date(2000, 1, 15))
125
+ assert instance.birthday == date(2000, 1, 15)
126
+
127
+ def test_build_date_field_from_string(self):
128
+ mt = self._make_meta_type("date_str", [
129
+ {"name": "published", "kind": "date", "required": True},
130
+ ])
131
+ model_cls = build_pydantic_model(mt)
132
+ instance = model_cls(published="2023-05-20")
133
+ assert instance.published == date(2023, 5, 20)
134
+
135
+ def test_build_datetime_field(self):
136
+ mt = self._make_meta_type("datetime", [
137
+ {"name": "created_at", "kind": "datetime", "required": True},
138
+ ])
139
+ model_cls = build_pydantic_model(mt)
140
+ dt = datetime(2023, 6, 15, 10, 30, 0)
141
+ instance = model_cls(created_at=dt)
142
+ assert instance.created_at == dt
143
+
144
+ def test_build_datetime_field_from_string(self):
145
+ mt = self._make_meta_type("datetime_str", [
146
+ {"name": "updated_at", "kind": "datetime", "required": True},
147
+ ])
148
+ model_cls = build_pydantic_model(mt)
149
+ instance = model_cls(updated_at="2023-06-15T10:30:00")
150
+ assert instance.updated_at == datetime(2023, 6, 15, 10, 30, 0)
151
+
152
+ def test_build_select_field(self):
153
+ mt = self._make_meta_type("select", [
154
+ {
155
+ "name": "status",
156
+ "kind": "select",
157
+ "required": True,
158
+ "choices": [
159
+ {"value": "draft", "label": "Draft"},
160
+ {"value": "published", "label": "Published"},
161
+ ],
162
+ },
163
+ ])
164
+ model_cls = build_pydantic_model(mt)
165
+ instance = model_cls(status="published")
166
+ assert instance.status == "published"
167
+
168
+ def test_build_select_field_optional(self):
169
+ mt = self._make_meta_type("select_opt", [
170
+ {
171
+ "name": "category",
172
+ "kind": "select",
173
+ "required": False,
174
+ "choices": [
175
+ {"value": "news"},
176
+ {"value": "blog"},
177
+ ],
178
+ },
179
+ ])
180
+ model_cls = build_pydantic_model(mt)
181
+ instance = model_cls()
182
+ assert instance.category is None
183
+ instance2 = model_cls(category="news")
184
+ assert instance2.category == "news"
185
+
186
+
187
+ @pytest.mark.django_db
188
+ class TestCache:
189
+ def test_cache_hit(self):
190
+ mt = MetaType.objects.create(name="cached", schema=[
191
+ {"name": "x", "kind": "string"},
192
+ ])
193
+ clear_cache()
194
+ model1 = build_pydantic_model(mt)
195
+ model2 = build_pydantic_model(mt)
196
+ assert model1 is model2
197
+
198
+ def test_clear_cache_by_id(self):
199
+ mt = MetaType.objects.create(name="cc", schema=[
200
+ {"name": "x", "kind": "string"},
201
+ ])
202
+ build_pydantic_model(mt)
203
+ assert any(k[0] == mt.pk for k in _cache)
204
+ clear_cache(mt.pk)
205
+ assert not any(k[0] == mt.pk for k in _cache)
206
+
207
+ def test_clear_cache_all(self):
208
+ mt = MetaType.objects.create(name="ca", schema=[
209
+ {"name": "x", "kind": "string"},
210
+ ])
211
+ build_pydantic_model(mt)
212
+ clear_cache()
213
+ assert len(_cache) == 0
214
+
215
+
216
+ class TestFieldType:
217
+ """Unit tests for _field_type helper."""
218
+
219
+ def test_string_returns_str(self):
220
+ fdef = MetaTypeFieldDef(name="x", kind=MetaFieldKind.string)
221
+ assert _field_type(fdef, "Test") is str
222
+
223
+ def test_html_returns_str(self):
224
+ fdef = MetaTypeFieldDef(name="x", kind=MetaFieldKind.html)
225
+ assert _field_type(fdef, "Test") is str
226
+
227
+ def test_number_returns_float(self):
228
+ fdef = MetaTypeFieldDef(name="x", kind=MetaFieldKind.number)
229
+ assert _field_type(fdef, "Test") is float
230
+
231
+ def test_number_with_integer_returns_int(self):
232
+ fdef = MetaTypeFieldDef(name="x", kind=MetaFieldKind.number, integer=True)
233
+ assert _field_type(fdef, "Test") is int
234
+
235
+ def test_boolean_returns_bool(self):
236
+ fdef = MetaTypeFieldDef(name="x", kind=MetaFieldKind.boolean)
237
+ assert _field_type(fdef, "Test") is bool
238
+
239
+ def test_date_returns_date(self):
240
+ fdef = MetaTypeFieldDef(name="x", kind=MetaFieldKind.date)
241
+ assert _field_type(fdef, "Test") is date
242
+
243
+ def test_datetime_returns_datetime(self):
244
+ fdef = MetaTypeFieldDef(name="x", kind=MetaFieldKind.datetime)
245
+ assert _field_type(fdef, "Test") is datetime
246
+
247
+ def test_select_returns_str(self):
248
+ fdef = MetaTypeFieldDef(
249
+ name="x",
250
+ kind=MetaFieldKind.select,
251
+ choices=[{"value": "a"}, {"value": "b"}],
252
+ )
253
+ assert _field_type(fdef, "Test") is str
254
+
255
+
256
+ class TestJsonSchemaExtra:
257
+ """Unit tests for _json_schema_extra helper."""
258
+
259
+ def test_string_multiline_format(self):
260
+ fdef = MetaTypeFieldDef(name="x", kind=MetaFieldKind.string, multiline=True)
261
+ extra = _json_schema_extra(fdef)
262
+ assert extra["format"] == "textarea"
263
+
264
+ def test_html_format(self):
265
+ fdef = MetaTypeFieldDef(name="x", kind=MetaFieldKind.html)
266
+ extra = _json_schema_extra(fdef)
267
+ assert extra["format"] == "html"
268
+
269
+ def test_select_choices(self):
270
+ fdef = MetaTypeFieldDef(
271
+ name="x",
272
+ kind=MetaFieldKind.select,
273
+ choices=[
274
+ {"value": "draft", "label": "Draft"},
275
+ {"value": "published", "label": "Published"},
276
+ ],
277
+ )
278
+ extra = _json_schema_extra(fdef)
279
+ assert "oneOf" in extra
280
+ assert len(extra["oneOf"]) == 2
281
+ assert extra["oneOf"][0] == {"const": "draft", "title": "Draft"}
282
+ assert extra["oneOf"][1] == {"const": "published", "title": "Published"}
283
+
284
+ def test_string_length_constraints(self):
285
+ fdef = MetaTypeFieldDef(
286
+ name="x", kind=MetaFieldKind.string, min_length=5, max_length=100
287
+ )
288
+ extra = _json_schema_extra(fdef)
289
+ assert extra["minLength"] == 5
290
+ assert extra["maxLength"] == 100
291
+
292
+ def test_string_placeholder(self):
293
+ fdef = MetaTypeFieldDef(
294
+ name="x", kind=MetaFieldKind.string, placeholder="Enter text"
295
+ )
296
+ extra = _json_schema_extra(fdef)
297
+ assert extra["placeholder"] == "Enter text"
298
+
299
+ def test_html_placeholder(self):
300
+ fdef = MetaTypeFieldDef(
301
+ name="x", kind=MetaFieldKind.html, placeholder="Enter HTML"
302
+ )
303
+ extra = _json_schema_extra(fdef)
304
+ assert extra["placeholder"] == "Enter HTML"
305
+
306
+ def test_number_constraints(self):
307
+ fdef = MetaTypeFieldDef(
308
+ name="x", kind=MetaFieldKind.number, minimum=0, maximum=100
309
+ )
310
+ extra = _json_schema_extra(fdef)
311
+ assert extra["minimum"] == 0
312
+ assert extra["maximum"] == 100
313
+
314
+ def test_no_extra_for_plain_boolean(self):
315
+ fdef = MetaTypeFieldDef(name="x", kind=MetaFieldKind.boolean)
316
+ extra = _json_schema_extra(fdef)
317
+ assert extra == {}
318
+
319
+ def test_no_extra_for_plain_date(self):
320
+ fdef = MetaTypeFieldDef(name="x", kind=MetaFieldKind.date)
321
+ extra = _json_schema_extra(fdef)
322
+ assert extra == {}
323
+
324
+ def test_no_extra_for_plain_datetime(self):
325
+ fdef = MetaTypeFieldDef(name="x", kind=MetaFieldKind.datetime)
326
+ extra = _json_schema_extra(fdef)
327
+ assert extra == {}
328
+
329
+
330
+ @pytest.mark.django_db
331
+ class TestGetJsonSchema:
332
+ def test_returns_dict(self):
333
+ mt = MetaType.objects.create(name="js", schema=[
334
+ {"name": "title", "kind": "string", "required": True},
335
+ ])
336
+ schema = get_json_schema(mt)
337
+ assert isinstance(schema, dict)
338
+ assert "properties" in schema
339
+ assert "title" in schema["properties"]
340
+
341
+ def test_schema_includes_date_format(self):
342
+ mt = MetaType.objects.create(name="js_date", schema=[
343
+ {"name": "published", "kind": "date", "required": True},
344
+ ])
345
+ schema = get_json_schema(mt)
346
+ assert "published" in schema["properties"]
347
+ assert schema["properties"]["published"]["type"] == "string"
348
+ assert schema["properties"]["published"]["format"] == "date"
349
+
350
+ def test_schema_includes_datetime_format(self):
351
+ mt = MetaType.objects.create(name="js_dt", schema=[
352
+ {"name": "created", "kind": "datetime", "required": True},
353
+ ])
354
+ schema = get_json_schema(mt)
355
+ assert "created" in schema["properties"]
356
+ assert schema["properties"]["created"]["type"] == "string"
357
+ assert schema["properties"]["created"]["format"] == "date-time"
358
+
359
+ def test_schema_includes_html_format(self):
360
+ mt = MetaType.objects.create(name="js_html", schema=[
361
+ {"name": "body", "kind": "html", "required": True},
362
+ ])
363
+ schema = get_json_schema(mt)
364
+ assert "body" in schema["properties"]
365
+ assert schema["properties"]["body"].get("format") == "html"
366
+
367
+ def test_schema_includes_select_choices(self):
368
+ mt = MetaType.objects.create(name="js_sel", schema=[
369
+ {
370
+ "name": "status",
371
+ "kind": "select",
372
+ "required": True,
373
+ "choices": [
374
+ {"value": "draft", "label": "Draft"},
375
+ {"value": "live", "label": "Live"},
376
+ ],
377
+ },
378
+ ])
379
+ schema = get_json_schema(mt)
380
+ assert "status" in schema["properties"]
381
+ prop = schema["properties"]["status"]
382
+ assert "oneOf" in prop
383
+ assert {"const": "draft", "title": "Draft"} in prop["oneOf"]
384
+ assert {"const": "live", "title": "Live"} in prop["oneOf"]
385
+
386
+ def test_schema_includes_string_constraints(self):
387
+ mt = MetaType.objects.create(name="js_str", schema=[
388
+ {
389
+ "name": "title",
390
+ "kind": "string",
391
+ "required": True,
392
+ "min_length": 1,
393
+ "max_length": 255,
394
+ },
395
+ ])
396
+ schema = get_json_schema(mt)
397
+ prop = schema["properties"]["title"]
398
+ assert prop.get("minLength") == 1
399
+ assert prop.get("maxLength") == 255
400
+
401
+ def test_schema_includes_number_constraints(self):
402
+ mt = MetaType.objects.create(name="js_num", schema=[
403
+ {
404
+ "name": "rating",
405
+ "kind": "number",
406
+ "required": True,
407
+ "minimum": 0,
408
+ "maximum": 5,
409
+ },
410
+ ])
411
+ schema = get_json_schema(mt)
412
+ prop = schema["properties"]["rating"]
413
+ assert prop.get("minimum") == 0
414
+ assert prop.get("maximum") == 5
415
+
416
+ def test_schema_nested_group_with_date(self):
417
+ mt = MetaType.objects.create(name="js_ng", schema=[
418
+ {
419
+ "name": "event",
420
+ "kind": "group",
421
+ "children": [
422
+ {"name": "title", "kind": "string", "required": True},
423
+ {"name": "start_date", "kind": "date", "required": True},
424
+ {"name": "start_time", "kind": "datetime"},
425
+ ],
426
+ },
427
+ ])
428
+ schema = get_json_schema(mt)
429
+ assert "event" in schema["properties"]
430
+
431
+ def test_schema_list_with_select(self):
432
+ mt = MetaType.objects.create(name="js_ls", schema=[
433
+ {
434
+ "name": "tags",
435
+ "kind": "list",
436
+ "children": [
437
+ {
438
+ "name": "category",
439
+ "kind": "select",
440
+ "required": True,
441
+ "choices": [
442
+ {"value": "tech"},
443
+ {"value": "news"},
444
+ ],
445
+ },
446
+ {"name": "label", "kind": "string", "required": True},
447
+ ],
448
+ },
449
+ ])
450
+ schema = get_json_schema(mt)
451
+ assert "tags" in schema["properties"]
452
+ # List fields produce an array schema - may be wrapped in anyOf for optional
453
+ tags_prop = schema["properties"]["tags"]
454
+ # Check for anyOf (optional list) or direct items
455
+ if "anyOf" in tags_prop:
456
+ array_branch = next(
457
+ (b for b in tags_prop["anyOf"] if b.get("type") == "array"), None
458
+ )
459
+ assert array_branch is not None
460
+ assert "items" in array_branch
461
+ else:
462
+ assert "items" in tags_prop or "$ref" in tags_prop
tests/test_forms.py ADDED
@@ -0,0 +1,32 @@
1
+ import pytest
2
+ from structured.widget.fields import StructuredJSONFormField
3
+
4
+ from structured_metaobjects.forms import MetaInstanceForm
5
+ from structured_metaobjects.models import MetaInstance, MetaType
6
+
7
+
8
+ @pytest.mark.django_db
9
+ class TestMetaInstanceForm:
10
+ def _make_type(self):
11
+ return MetaType.objects.create(
12
+ name="Form Type",
13
+ schema=[{"name": "title", "kind": "string", "required": True}],
14
+ )
15
+
16
+ def test_form_with_existing_instance(self):
17
+ mt = self._make_type()
18
+ mi = MetaInstance.objects.create(
19
+ meta_type=mt,
20
+ data={"title": "Existing"},
21
+ )
22
+ form = MetaInstanceForm(instance=mi)
23
+ assert isinstance(form.fields["data"], StructuredJSONFormField)
24
+
25
+ def test_form_without_meta_type_shows_help_text(self):
26
+ form = MetaInstanceForm()
27
+ assert "Select a meta type" in form.fields["data"].help_text
28
+
29
+ def test_form_with_initial_meta_type(self):
30
+ mt = self._make_type()
31
+ form = MetaInstanceForm(initial={"meta_type": mt.pk})
32
+ assert isinstance(form.fields["data"], StructuredJSONFormField)