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_models.py ADDED
@@ -0,0 +1,213 @@
1
+ import pytest
2
+ from django.core.exceptions import ValidationError
3
+ from django.test.utils import override_settings
4
+
5
+ from structured_metaobjects.compiler import _cache, clear_cache
6
+ from structured_metaobjects.models import MetaInstance, MetaType
7
+ from tests.app.test_module.models import Page
8
+
9
+
10
+ @pytest.mark.django_db
11
+ class TestMetaType:
12
+ def test_create(self):
13
+ mt = MetaType.objects.create(
14
+ name="Article",
15
+ schema=[{"name": "title", "kind": "string", "required": True}],
16
+ )
17
+ assert mt.pk is not None
18
+ assert str(mt) == "Article"
19
+
20
+ def test_save_clears_cache(self):
21
+ mt = MetaType.objects.create(
22
+ name="Cache Inv",
23
+ schema=[{"name": "x", "kind": "string"}],
24
+ )
25
+ mt.get_pydantic_model()
26
+ assert any(k[0] == mt.pk for k in _cache)
27
+ mt.name = "Updated"
28
+ mt.save()
29
+ assert not any(k[0] == mt.pk for k in _cache)
30
+
31
+ def test_get_pydantic_model(self):
32
+ mt = MetaType.objects.create(
33
+ name="PM",
34
+ schema=[{"name": "body", "kind": "string", "multiline": True}],
35
+ )
36
+ clear_cache()
37
+ model_cls = mt.get_pydantic_model()
38
+ assert hasattr(model_cls, "model_fields")
39
+
40
+ def test_get_json_schema(self):
41
+ mt = MetaType.objects.create(
42
+ name="JSON Schema",
43
+ schema=[{"name": "name", "kind": "string", "required": True}],
44
+ )
45
+ schema = mt.get_json_schema()
46
+ assert "properties" in schema
47
+
48
+
49
+ @pytest.mark.django_db
50
+ class TestMetaInstance:
51
+ def _make_type(self):
52
+ return MetaType.objects.create(
53
+ name="Inst Type",
54
+ schema=[
55
+ {"name": "title", "kind": "string", "required": True},
56
+ {"name": "count", "kind": "number", "integer": True},
57
+ ],
58
+ )
59
+
60
+ def test_create(self):
61
+ mt = self._make_type()
62
+ mi = MetaInstance.objects.create(
63
+ meta_type=mt,
64
+ data={"title": "Hello", "count": 5},
65
+ )
66
+ assert mi.pk is not None
67
+
68
+ def test_str(self):
69
+ mt = self._make_type()
70
+ mi = MetaInstance.objects.create(
71
+ meta_type=mt, data={"title": "X"},
72
+ )
73
+ assert str(mi) == f"Inst Type #{mi.pk}"
74
+
75
+ def test_obj_property(self):
76
+ mt = self._make_type()
77
+ mi = MetaInstance.objects.create(
78
+ meta_type=mt,
79
+ data={"title": "World", "count": 10},
80
+ )
81
+ obj = mi.obj
82
+ assert obj.title == "World"
83
+ assert obj.count == 10
84
+
85
+ def test_obj_caching(self):
86
+ mt = self._make_type()
87
+ mi = MetaInstance.objects.create(
88
+ meta_type=mt,
89
+ data={"title": "Cached"},
90
+ )
91
+ obj1 = mi.obj
92
+ obj2 = mi.obj
93
+ assert obj1 is obj2
94
+
95
+ def test_obj_none_without_meta_type(self):
96
+ mi = MetaInstance()
97
+ assert mi.obj is None
98
+
99
+ def test_clean_valid_data(self):
100
+ mt = self._make_type()
101
+ mi = MetaInstance(meta_type=mt, data={"title": "Valid"})
102
+ mi.clean()
103
+ assert mi.data["title"] == "Valid"
104
+
105
+ def test_clean_invalid_data_raises(self):
106
+ mt = self._make_type()
107
+ mi = MetaInstance(meta_type=mt, data={"title": 123})
108
+ with pytest.raises(ValidationError):
109
+ mi.clean()
110
+
111
+ def test_clean_missing_required_raises(self):
112
+ mt = self._make_type()
113
+ mi = MetaInstance(meta_type=mt, data={})
114
+ with pytest.raises(ValidationError):
115
+ mi.clean()
116
+
117
+ def test_clean_normalizes_data(self):
118
+ mt = self._make_type()
119
+ mi = MetaInstance(meta_type=mt, data={"title": "Test", "count": "5"})
120
+ mi.clean()
121
+ assert mi.data["count"] == 5
122
+
123
+
124
+ @pytest.mark.django_db
125
+ class TestMetaInstanceCacheEngine:
126
+ """Verify that accessing ref/queryset fields on MetaInstance.obj uses
127
+ the structured-field CacheEngine, batch-fetching related objects
128
+ instead of issuing per-field queries."""
129
+
130
+ @pytest.fixture()
131
+ def pages(self):
132
+ return [
133
+ Page.objects.create(title="Page A", slug="page-a"),
134
+ Page.objects.create(title="Page B", slug="page-b"),
135
+ Page.objects.create(title="Page C", slug="page-c"),
136
+ ]
137
+
138
+ def test_fk_field_single_query(self, django_assert_num_queries, pages):
139
+ mt = MetaType.objects.create(
140
+ name="FK Test",
141
+ schema=[
142
+ {"name": "page", "kind": "ref", "target_model": "test_module.Page"},
143
+ ],
144
+ )
145
+ mi = MetaInstance.objects.create(
146
+ meta_type=mt,
147
+ data={"page": pages[0].pk},
148
+ )
149
+ # Building obj should batch-fetch the FK in a single query
150
+ # (1 query for the cache engine to fetch all referenced Page PKs)
151
+ with django_assert_num_queries(1):
152
+ obj = mi.obj
153
+ assert obj.page.pk == pages[0].pk
154
+ assert obj.page.title == "Page A"
155
+
156
+ def test_queryset_field_single_query(self, django_assert_num_queries, pages):
157
+ mt = MetaType.objects.create(
158
+ name="QS Test",
159
+ schema=[
160
+ {"name": "pages", "kind": "queryset", "target_model": "test_module.Page"},
161
+ ],
162
+ )
163
+ pks = [p.pk for p in pages]
164
+ mi = MetaInstance.objects.create(
165
+ meta_type=mt,
166
+ data={"pages": pks},
167
+ )
168
+ # Building obj should batch-fetch all QS PKs in a single query
169
+ with django_assert_num_queries(1):
170
+ obj = mi.obj
171
+ result_pks = [p.pk for p in obj.pages]
172
+ assert set(result_pks) == set(pks)
173
+
174
+ def test_mixed_fk_and_qs_fields(self, django_assert_num_queries, pages):
175
+ mt = MetaType.objects.create(
176
+ name="Mixed Test",
177
+ schema=[
178
+ {"name": "main_page", "kind": "ref", "target_model": "test_module.Page"},
179
+ {"name": "related_pages", "kind": "queryset", "target_model": "test_module.Page"},
180
+ ],
181
+ )
182
+ mi = MetaInstance.objects.create(
183
+ meta_type=mt,
184
+ data={
185
+ "main_page": pages[0].pk,
186
+ "related_pages": [p.pk for p in pages[1:]],
187
+ },
188
+ )
189
+ # Both FK and QS point to the same model so the cache engine
190
+ # should resolve everything in a single batched query
191
+ with django_assert_num_queries(1):
192
+ obj = mi.obj
193
+ assert obj.main_page.pk == pages[0].pk
194
+ qs_pks = [p.pk for p in obj.related_pages]
195
+ assert set(qs_pks) == {pages[1].pk, pages[2].pk}
196
+
197
+ def test_obj_cache_no_extra_queries(self, django_assert_num_queries, pages):
198
+ mt = MetaType.objects.create(
199
+ name="Cache Hit",
200
+ schema=[
201
+ {"name": "page", "kind": "ref", "target_model": "test_module.Page"},
202
+ ],
203
+ )
204
+ mi = MetaInstance.objects.create(
205
+ meta_type=mt,
206
+ data={"page": pages[0].pk},
207
+ )
208
+ # First access builds the cache
209
+ _ = mi.obj
210
+ # Second access should hit the instance cache — zero queries
211
+ with django_assert_num_queries(0):
212
+ obj = mi.obj
213
+ assert obj.page.pk == pages[0].pk
@@ -0,0 +1,81 @@
1
+ import pytest
2
+ from structured_metaobjects.schema_builder import MetaFieldKind, MetaTypeFieldDef
3
+
4
+
5
+ class TestMetaFieldKind:
6
+ def test_all_kinds_present(self):
7
+ expected = {
8
+ "string", "html", "number", "boolean",
9
+ "date", "datetime", "select", "ref", "queryset", "group", "list",
10
+ }
11
+ assert {k.value for k in MetaFieldKind} == expected
12
+
13
+ def test_kind_is_str_enum(self):
14
+ assert isinstance(MetaFieldKind.string, str)
15
+ assert MetaFieldKind.string == "string"
16
+
17
+
18
+ class TestMetaTypeFieldDef:
19
+ def test_minimal_field(self):
20
+ fd = MetaTypeFieldDef(name="title")
21
+ assert fd.name == "title"
22
+ assert fd.kind == MetaFieldKind.string
23
+ assert fd.required is False
24
+ assert fd.translated is False
25
+ assert fd.target_model is None
26
+ assert fd.children == []
27
+
28
+ def test_ref_field(self):
29
+ fd = MetaTypeFieldDef(
30
+ name="page",
31
+ kind=MetaFieldKind.ref,
32
+ target_model="test_module.Page",
33
+ )
34
+ assert fd.kind == MetaFieldKind.ref
35
+ assert fd.target_model == "test_module.Page"
36
+
37
+ def test_group_with_children(self):
38
+ child = MetaTypeFieldDef(name="street", kind=MetaFieldKind.string)
39
+ fd = MetaTypeFieldDef(
40
+ name="address",
41
+ kind=MetaFieldKind.group,
42
+ children=[child],
43
+ )
44
+ assert len(fd.children) == 1
45
+ assert fd.children[0].name == "street"
46
+
47
+ def test_json_schema_has_conditional_logic(self):
48
+ schema = MetaTypeFieldDef.model_json_schema()
49
+ assert "if" in schema or "allOf" in schema or "$defs" in schema
50
+
51
+ def test_round_trip_dict(self):
52
+ data = {
53
+ "name": "bio",
54
+ "label": "Biography",
55
+ "kind": "string",
56
+ "multiline": True,
57
+ "required": True,
58
+ "translated": True,
59
+ }
60
+ fd = MetaTypeFieldDef.model_validate(data)
61
+ dumped = fd.model_dump(mode="json")
62
+ assert dumped["name"] == "bio"
63
+ assert dumped["kind"] == "string"
64
+ assert dumped["translated"] is True
65
+
66
+ def test_recursive_children(self):
67
+ data = {
68
+ "name": "section",
69
+ "kind": "group",
70
+ "children": [
71
+ {
72
+ "name": "subsection",
73
+ "kind": "group",
74
+ "children": [
75
+ {"name": "title", "kind": "string"},
76
+ ],
77
+ }
78
+ ],
79
+ }
80
+ fd = MetaTypeFieldDef.model_validate(data)
81
+ assert fd.children[0].children[0].name == "title"
@@ -0,0 +1,79 @@
1
+ import pytest
2
+
3
+ from structured_metaobjects.models import MetaInstance, MetaType
4
+ from structured_metaobjects.serializers import (
5
+ MetaInstanceSerializer,
6
+ MetaTypeSerializer,
7
+ )
8
+
9
+
10
+ @pytest.mark.django_db
11
+ class TestMetaTypeSerializer:
12
+ def test_serialize(self):
13
+ mt = MetaType.objects.create(
14
+ name="Ser",
15
+ schema=[{"name": "title", "kind": "string"}],
16
+ )
17
+ data = MetaTypeSerializer(mt).data
18
+ assert data["name"] == "Ser"
19
+ assert isinstance(data["schema"], list)
20
+
21
+ def test_deserialize(self):
22
+ payload = {
23
+ "name": "New Type",
24
+ "schema": [{"name": "body", "kind": "string", "multiline": True}],
25
+ }
26
+ s = MetaTypeSerializer(data=payload)
27
+ assert s.is_valid(), s.errors
28
+ mt = s.save()
29
+ assert mt.pk is not None
30
+
31
+
32
+ @pytest.mark.django_db
33
+ class TestMetaInstanceSerializer:
34
+ def _make_type(self):
35
+ return MetaType.objects.create(
36
+ name="MI Ser",
37
+ schema=[
38
+ {"name": "title", "kind": "string", "required": True},
39
+ ],
40
+ )
41
+
42
+ def test_valid_create(self):
43
+ mt = self._make_type()
44
+ payload = {
45
+ "meta_type": mt.pk,
46
+ "data": {"title": "Hello"},
47
+ }
48
+ s = MetaInstanceSerializer(data=payload)
49
+ assert s.is_valid(), s.errors
50
+ mi = s.save()
51
+ assert mi.data["title"] == "Hello"
52
+
53
+ def test_invalid_data_rejected(self):
54
+ mt = self._make_type()
55
+ payload = {
56
+ "meta_type": mt.pk,
57
+ "data": {},
58
+ }
59
+ s = MetaInstanceSerializer(data=payload)
60
+ assert not s.is_valid()
61
+ assert "data" in s.errors
62
+
63
+ def test_missing_meta_type_rejected(self):
64
+ payload = {
65
+ "data": {"title": "No type"},
66
+ }
67
+ s = MetaInstanceSerializer(data=payload)
68
+ assert not s.is_valid()
69
+
70
+ def test_update_existing(self):
71
+ mt = self._make_type()
72
+ mi = MetaInstance.objects.create(
73
+ meta_type=mt,
74
+ data={"title": "Old"},
75
+ )
76
+ s = MetaInstanceSerializer(mi, data={"data": {"title": "New"}}, partial=True)
77
+ assert s.is_valid(), s.errors
78
+ updated = s.save()
79
+ assert updated.data["title"] == "New"
tests/test_views.py ADDED
@@ -0,0 +1,120 @@
1
+ import pytest
2
+ from rest_framework.test import APIClient
3
+
4
+ from structured_metaobjects.models import MetaInstance, MetaType
5
+
6
+
7
+ @pytest.fixture
8
+ def api_client():
9
+ return APIClient()
10
+
11
+
12
+ @pytest.mark.django_db
13
+ class TestMetaTypeViewSet:
14
+ def _url(self, pk=None):
15
+ if pk:
16
+ return f"/api/meta-types/{pk}/"
17
+ return "/api/meta-types/"
18
+
19
+ def test_list(self, api_client):
20
+ MetaType.objects.create(name="V1", schema=[])
21
+ resp = api_client.get(self._url())
22
+ assert resp.status_code == 200
23
+ assert len(resp.data) >= 1
24
+
25
+ def test_create(self, api_client):
26
+ resp = api_client.post(
27
+ self._url(),
28
+ {"name": "V2", "schema": [{"name": "x", "kind": "string"}]},
29
+ format="json",
30
+ )
31
+ assert resp.status_code == 201
32
+ assert resp.data["name"] == "V2"
33
+
34
+ def test_retrieve(self, api_client):
35
+ mt = MetaType.objects.create(name="V3", schema=[])
36
+ resp = api_client.get(self._url(mt.pk))
37
+ assert resp.status_code == 200
38
+ assert resp.data["name"] == "V3"
39
+
40
+ def test_update(self, api_client):
41
+ mt = MetaType.objects.create(name="V4", schema=[])
42
+ resp = api_client.patch(
43
+ self._url(mt.pk),
44
+ {"name": "V4 Updated"},
45
+ format="json",
46
+ )
47
+ assert resp.status_code == 200
48
+ assert resp.data["name"] == "V4 Updated"
49
+
50
+ def test_delete(self, api_client):
51
+ mt = MetaType.objects.create(name="V5", schema=[])
52
+ resp = api_client.delete(self._url(mt.pk))
53
+ assert resp.status_code == 204
54
+
55
+ def test_schema_action(self, api_client):
56
+ mt = MetaType.objects.create(
57
+ name="V6",
58
+ schema=[{"name": "title", "kind": "string", "required": True}],
59
+ )
60
+ resp = api_client.get(f"/api/meta-types/{mt.pk}/schema/")
61
+ assert resp.status_code == 200
62
+ assert "properties" in resp.data
63
+
64
+
65
+ @pytest.mark.django_db
66
+ class TestMetaInstanceViewSet:
67
+ def _url(self, pk=None):
68
+ if pk:
69
+ return f"/api/meta-instances/{pk}/"
70
+ return "/api/meta-instances/"
71
+
72
+ def _make_type(self):
73
+ return MetaType.objects.create(
74
+ name="VI",
75
+ schema=[{"name": "title", "kind": "string", "required": True}],
76
+ )
77
+
78
+ def test_list(self, api_client):
79
+ mt = self._make_type()
80
+ MetaInstance.objects.create(meta_type=mt, data={"title": "A"})
81
+ resp = api_client.get(self._url())
82
+ assert resp.status_code == 200
83
+
84
+ def test_create(self, api_client):
85
+ mt = self._make_type()
86
+ resp = api_client.post(
87
+ self._url(),
88
+ {"meta_type": mt.pk, "data": {"title": "New"}},
89
+ format="json",
90
+ )
91
+ assert resp.status_code == 201
92
+
93
+ def test_create_invalid_data(self, api_client):
94
+ mt = self._make_type()
95
+ resp = api_client.post(
96
+ self._url(),
97
+ {"meta_type": mt.pk, "data": {}},
98
+ format="json",
99
+ )
100
+ assert resp.status_code == 400
101
+
102
+ def test_retrieve(self, api_client):
103
+ mt = self._make_type()
104
+ mi = MetaInstance.objects.create(meta_type=mt, data={"title": "R"})
105
+ resp = api_client.get(self._url(mi.pk))
106
+ assert resp.status_code == 200
107
+
108
+ def test_schema_action(self, api_client):
109
+ mt = self._make_type()
110
+ resp = api_client.get(f"/api/meta-instances/schema/?meta_type={mt.pk}")
111
+ assert resp.status_code == 200
112
+ assert "properties" in resp.data
113
+
114
+ def test_schema_action_missing_param(self, api_client):
115
+ resp = api_client.get("/api/meta-instances/schema/")
116
+ assert resp.status_code == 400
117
+
118
+ def test_schema_action_not_found(self, api_client):
119
+ resp = api_client.get("/api/meta-instances/schema/?meta_type=99999")
120
+ assert resp.status_code == 404