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
@@ -0,0 +1,112 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-structured-metaobjects
3
+ Version: 1.0.0
4
+ Summary: User-defined typed JSON objects for Django, powered by django-structured-json-field
5
+ Author-email: Lotrèk <gabriele.baldi.01@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/bnznamco/django-structured-metaobjects
8
+ Keywords: django,pydantic,django pydantic,json schema,meta models,dynamic models
9
+ Classifier: Environment :: Web Environment
10
+ Classifier: Framework :: Django
11
+ Classifier: Framework :: Django :: 4.2
12
+ Classifier: Framework :: Django :: 5.0
13
+ Classifier: Framework :: Django :: 5.1
14
+ Classifier: Framework :: Django :: 5.2
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Programming Language :: Python
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
23
+ Requires-Python: >=3.10
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Requires-Dist: Django>=4.2
27
+ Requires-Dist: django-structured-json-field>=1.5.1
28
+ Requires-Dist: djangorestframework<4.0.0,>=3.14.0
29
+ Requires-Dist: pydantic>=2.12
30
+ Dynamic: license-file
31
+
32
+ [![Test](https://github.com/bnznamco/django-structured-metaobjects/actions/workflows/ci.yml/badge.svg)](https://github.com/bnznamco/django-structured-metaobjects/actions/workflows/ci.yml)
33
+ [![PyPI](https://img.shields.io/pypi/v/django-structured-metaobjects.svg)](https://pypi.org/project/django-structured-metaobjects/)
34
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
35
+
36
+ # django-structured-metaobjects
37
+
38
+ User-defined, typed JSON objects for Django — built on top of
39
+ [`django-structured-json-field`](https://github.com/bnznamco/django-structured-field).
40
+
41
+ Editors define **meta types** in the admin (a list of typed field
42
+ definitions). Each `MetaInstance` then picks one of those types and the
43
+ admin form / REST API automatically adapts to the schema declared by the
44
+ chosen type.
45
+
46
+ ## Features
47
+
48
+ - Field kinds: primitives, `ref` (single FK), `queryset` (list of FKs),
49
+ `group` (nested object), `list` (repeating group), with optional
50
+ `translated=True` per-field.
51
+ - Runtime Pydantic compiler with per-MetaType caching, invalidated on save.
52
+ - Admin form rebuilds the `data` widget for the selected meta type.
53
+ - DRF `MetaTypeViewSet` / `MetaInstanceViewSet` with `schema/` action.
54
+ - Typed access on `instance.obj.<field>` returning the parsed Pydantic
55
+ model — FK / queryset fields lazily resolve to real Django objects
56
+ with structured-json-field's caching.
57
+
58
+ ## Install
59
+
60
+ ```bash
61
+ pip install django-structured-metaobjects
62
+ ```
63
+
64
+ Add to `INSTALLED_APPS`:
65
+
66
+ ```python
67
+ INSTALLED_APPS = [
68
+ # ...
69
+ "structured",
70
+ "structured_metaobjects",
71
+ ]
72
+ ```
73
+
74
+ Wire the REST endpoints into your router:
75
+
76
+ ```python
77
+ from rest_framework import routers
78
+ from structured_metaobjects.views import MetaTypeViewSet, MetaInstanceViewSet
79
+
80
+ router = routers.DefaultRouter()
81
+ router.register(r"meta-types", MetaTypeViewSet, "meta-types")
82
+ router.register(r"meta-instances", MetaInstanceViewSet, "meta-instances")
83
+ ```
84
+
85
+ ## Field kinds
86
+
87
+ | kind | Python type built by the compiler | Notes |
88
+ |------------|--------------------------------------------|----------------------------------------|
89
+ | `string` | `str` | |
90
+ | `text` | `str` | Multi-line. |
91
+ | `integer` | `int` | |
92
+ | `number` | `float` | |
93
+ | `boolean` | `bool` | |
94
+ | `date` | `datetime.date` | |
95
+ | `datetime` | `datetime.datetime` | |
96
+ | `ref` | `<TargetModel>` | Requires `target_model="app.Model"`. |
97
+ | `queryset` | `List[<TargetModel>]` | Requires `target_model="app.Model"`. |
98
+ | `group` | nested Pydantic model from `children` | |
99
+ | `list` | `List[<group model>]` from `children` | |
100
+
101
+ Setting `translated=True` wraps the field in `Dict[str, T]` so the value
102
+ is a per-language map.
103
+
104
+ ## Typed access
105
+
106
+ ```python
107
+ instance = MetaInstance.objects.get(pk=1)
108
+ obj = instance.obj # cached Pydantic instance
109
+ obj.title # typed string
110
+ obj.related_page # real Django model instance (lazy/cached)
111
+ obj.gallery # list of real Django model instances
112
+ ```
@@ -0,0 +1,40 @@
1
+ django_structured_metaobjects-1.0.0.dist-info/licenses/LICENSE,sha256=qzxvcwVOx03-Kys8GRn_AtC5ZXv1HzJzliG9LkhVgqE,1081
2
+ structured_metaobjects/__init__.py,sha256=5bRrmvCAWgEXUP3L0cpjMPpQOzmIGV5OMavydl0nDlw,366
3
+ structured_metaobjects/admin.py,sha256=w_YBYEQxg_YcYx_FSmd-8B_mi4JJQOaZPyg-GMh0gBc,508
4
+ structured_metaobjects/apps.py,sha256=Tgvm2-LHJN_ywyr96GWqAYmzdzrrFAc54EB5E0jyzws,254
5
+ structured_metaobjects/compiler.py,sha256=ABdDSNxhoKPirI1tOcKhNHtx07n7YuuX1kVpQbwcS4g,5261
6
+ structured_metaobjects/forms.py,sha256=GZYXeOzVj97IeGyuWVe3nUJRz-tqvivga6HVhO6J9ns,1304
7
+ structured_metaobjects/models.py,sha256=Q_KayvIWKKnUDmt5i1r6RVzCI2W5Rmv-MDTsd752eIc,3729
8
+ structured_metaobjects/schema_builder.py,sha256=vtQgzIYZyoFCHFd09znqUSZIhQ5TnwbaaXjd97OHLpQ,6108
9
+ structured_metaobjects/serializers.py,sha256=9_81rDABUKwYVhu5paPuP5Ve7SxS81x5h8-yv9I4SJM,1210
10
+ structured_metaobjects/urls.py,sha256=7psHWKIdcHCCKVHIVfzG3tytDiRobsexjYhMC4Je_uY,355
11
+ structured_metaobjects/views.py,sha256=G1OurMs9JIsX4yk50drciYz5s5xWRWvUvxphwqn-Y8A,1331
12
+ structured_metaobjects/migrations/0001_initial.py,sha256=fPypcHYYvi35SkHOYZlY-vDQ1ho5f9N1NUrY8nbOJFQ,2353
13
+ structured_metaobjects/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
+ structured_metaobjects/static/structured_metaobjects/admin/meta_instance.js,sha256=5dIlPtUvYMLaiyVhjoAxUl4ImNInLzZateWfF4oMTXg,780
15
+ tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
+ tests/test_admin.py,sha256=UTNq77PWv1jh31QuHwlWQzwnvn_hDPhrfnkrNUewQTA,857
17
+ tests/test_compiler.py,sha256=LN-oXTw5Jfc-1J2xt51xI90p0cze7gAMxIwkwMUtq8s,16830
18
+ tests/test_forms.py,sha256=5XYUxIaI5rV8FJxmbWw6a9Dn4iEB34vxrt3q5EmDb1I,1126
19
+ tests/test_models.py,sha256=2XhmmCF55yuadRfleHMLmo5BdIsWblteh5IUbe9nojg,7188
20
+ tests/test_schema_builder.py,sha256=pXV8-pvGf2sKdOP2kkEEedL4pMKavtBh_3DnTujRwPo,2626
21
+ tests/test_serializers.py,sha256=wALzWGlgLVzTzI4R32OplQXMwWSlowN9HeWheIHvPfc,2280
22
+ tests/test_views.py,sha256=rrUZJ_VjHbRg1mXRiWorKd_29rsd10T6TZcz2CPXBz4,3851
23
+ tests/app/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
24
+ tests/app/app/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
25
+ tests/app/app/asgi.py,sha256=Khb37ClvAOJtS2aYrRXQymK4aHNPeNuLQGXWAgBV88c,172
26
+ tests/app/app/settings.py,sha256=Wy6Nc6JEmfGrA6V0RPUGVriIPHRufVGVhiWENmCVLwc,2184
27
+ tests/app/app/urls.py,sha256=iZ_z3gQeHdHeSl1hdEy_h_jc_EGzPmGfEFSXzbPc_bY,433
28
+ tests/app/app/wsgi.py,sha256=XcI10xhFKshbQm3aGOCRuo4JF5ylfdskewI_qQAKU4I,172
29
+ tests/app/test_module/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
30
+ tests/app/test_module/admin.py,sha256=FNZL7j2juUh1WyR_WKS8ASy49p75jOyG8bEtiO5qiVU,155
31
+ tests/app/test_module/apps.py,sha256=uNVLDfJrenNGg8_uRHnKOH_72WxftHw_pHP1a9EBuIk,222
32
+ tests/app/test_module/models.py,sha256=WD7uLVfPYiLa6LhadXCwWth3fxRUyX_GOR4U81HDxSM,271
33
+ tests/app/test_module/serializers.py,sha256=Xms82D2NMp-2ovXxi-5oUil9I3X04RRIa_T5Z-GPGcQ,182
34
+ tests/app/test_module/views.py,sha256=Yeumwat3XeS96T4cmqX9yMAJ6hCII8uX4c9MOVyoN9I,218
35
+ tests/app/test_module/migrations/0001_initial.py,sha256=DWEkIhNn9Mhy1ay2q0LwaLd0DMYBXUaaH9Cmt94JByU,572
36
+ tests/app/test_module/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
37
+ django_structured_metaobjects-1.0.0.dist-info/METADATA,sha256=zfFpkgKRMkIhdwdbpWxvjFYUdxlLO_UsKeo79gfJTxo,4986
38
+ django_structured_metaobjects-1.0.0.dist-info/WHEEL,sha256=TdQ5LtNwLuxTCjgxN51AgdU5w-KkB9ttmLbzjTH02pg,109
39
+ django_structured_metaobjects-1.0.0.dist-info/top_level.txt,sha256=222iEaFc3Gp3fX_NYXkQTX01ixlL5ejqWhb2EamK49Y,29
40
+ django_structured_metaobjects-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,6 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py2-none-any
5
+ Tag: py3-none-any
6
+
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) Gabriele Baldi 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
@@ -0,0 +1,2 @@
1
+ structured_metaobjects
2
+ tests
@@ -0,0 +1,14 @@
1
+ from .schema_builder import MetaFieldKind, MetaTypeFieldDef
2
+ from .compiler import build_pydantic_model, clear_cache, get_json_schema
3
+
4
+ __version__ = "1.0.0"
5
+
6
+ default_app_config = "structured_metaobjects.apps.StructuredMetaobjectsConfig"
7
+
8
+ __all__ = [
9
+ "MetaFieldKind",
10
+ "MetaTypeFieldDef",
11
+ "build_pydantic_model",
12
+ "clear_cache",
13
+ "get_json_schema",
14
+ ]
@@ -0,0 +1,20 @@
1
+ from django.contrib import admin
2
+
3
+ from .forms import MetaInstanceForm
4
+ from .models import MetaInstance, MetaType
5
+
6
+
7
+ @admin.register(MetaType)
8
+ class MetaTypeAdmin(admin.ModelAdmin):
9
+ list_display = ("name",)
10
+
11
+
12
+ @admin.register(MetaInstance)
13
+ class MetaInstanceAdmin(admin.ModelAdmin):
14
+ form = MetaInstanceForm
15
+ list_display = ("__str__", "meta_type", "updated_at")
16
+ list_filter = ("meta_type",)
17
+ search_fields = ()
18
+
19
+ class Media:
20
+ js = ("structured_metaobjects/admin/meta_instance.js",)
@@ -0,0 +1,8 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class StructuredMetaobjectsConfig(AppConfig):
5
+ name = "structured_metaobjects"
6
+ label = "structured_metaobjects"
7
+ verbose_name = "Structured Meta Objects"
8
+ default_auto_field = "django.db.models.AutoField"
@@ -0,0 +1,161 @@
1
+ """
2
+ Runtime Pydantic model compiler for MetaType definitions.
3
+
4
+ A MetaType stores a list of MetaTypeFieldDef rows. At runtime we walk
5
+ that list and build a ``structured.pydantic.BaseModel`` subclass that
6
+ mirrors the declared shape. The resulting class can be:
7
+
8
+ - handed to ``structured.widget.fields.StructuredJSONFormField`` so the
9
+ admin renders the JSON-schema editor for it,
10
+ - used to validate / serialize ``MetaInstance.data``,
11
+ - introspected (``model_json_schema()``) by the REST API for frontends.
12
+
13
+ Compiled models are cached per ``(meta_type_id, compiled_at)`` so saves
14
+ on the MetaType invalidate the cache automatically.
15
+ """
16
+
17
+ from datetime import date, datetime
18
+ from typing import Dict, List, Optional, Tuple, Type
19
+
20
+ from django.apps import apps
21
+ from pydantic import Field, create_model
22
+ from structured.pydantic.fields import ForeignKey, QuerySet
23
+ from structured.pydantic.models import BaseModel
24
+
25
+ from .schema_builder import MetaFieldKind, MetaTypeFieldDef
26
+
27
+
28
+ _cache: Dict[Tuple[int, str], Type[BaseModel]] = {}
29
+
30
+
31
+ def clear_cache(meta_type_id: Optional[int] = None) -> None:
32
+ if meta_type_id is None:
33
+ _cache.clear()
34
+ return
35
+ for key in [k for k in _cache if k[0] == meta_type_id]:
36
+ _cache.pop(key, None)
37
+
38
+
39
+ _PRIMITIVE_TYPES = {
40
+ MetaFieldKind.string: str,
41
+ MetaFieldKind.html: str,
42
+ MetaFieldKind.number: float,
43
+ MetaFieldKind.boolean: bool,
44
+ MetaFieldKind.date: date,
45
+ MetaFieldKind.datetime: datetime,
46
+ MetaFieldKind.select: str,
47
+ }
48
+
49
+
50
+ def _resolve_ref_model(target_model):
51
+ if not target_model or "." not in target_model:
52
+ raise ValueError(
53
+ f"MetaType ref/queryset field requires target_model as 'app.Model', "
54
+ f"got {target_model!r}"
55
+ )
56
+ app_label, model_name = target_model.split(".", 1)
57
+ return apps.get_model(app_label, model_name)
58
+
59
+
60
+ def _field_type(field_def: MetaTypeFieldDef, model_name_hint: str):
61
+ kind = field_def.kind
62
+ if kind == MetaFieldKind.number and field_def.integer:
63
+ return int
64
+ if kind in _PRIMITIVE_TYPES:
65
+ return _PRIMITIVE_TYPES[kind]
66
+ if kind == MetaFieldKind.ref:
67
+ return ForeignKey[_resolve_ref_model(field_def.target_model)]
68
+ if kind == MetaFieldKind.queryset:
69
+ return QuerySet[_resolve_ref_model(field_def.target_model)]
70
+ if kind == MetaFieldKind.group:
71
+ return _build_group_model(
72
+ field_def.children, f"{model_name_hint}__{field_def.name}"
73
+ )
74
+ if kind == MetaFieldKind.list:
75
+ item_model = _build_group_model(
76
+ field_def.children, f"{model_name_hint}__{field_def.name}_item"
77
+ )
78
+ return List[item_model]
79
+ raise ValueError(f"Unknown MetaType field kind: {kind!r}")
80
+
81
+
82
+ def _json_schema_extra(fdef: MetaTypeFieldDef) -> dict:
83
+ """Build JSON Schema extra properties from the field definition."""
84
+ extra = {}
85
+ kind = fdef.kind
86
+
87
+ # Format hints
88
+ if kind == MetaFieldKind.string and fdef.multiline:
89
+ extra["format"] = "textarea"
90
+ if kind == MetaFieldKind.html:
91
+ extra["format"] = "html"
92
+
93
+ # Select choices
94
+ if kind == MetaFieldKind.select and fdef.choices:
95
+ extra["oneOf"] = [
96
+ {"const": c.value, "title": c.label} for c in fdef.choices
97
+ ]
98
+
99
+ # String constraints
100
+ if kind == MetaFieldKind.string:
101
+ if fdef.max_length is not None:
102
+ extra["maxLength"] = fdef.max_length
103
+ if fdef.min_length is not None:
104
+ extra["minLength"] = fdef.min_length
105
+
106
+ # Placeholder
107
+ if kind in (MetaFieldKind.string, MetaFieldKind.html) and fdef.placeholder:
108
+ extra["placeholder"] = fdef.placeholder
109
+
110
+ # Number constraints
111
+ if kind == MetaFieldKind.number:
112
+ if fdef.minimum is not None:
113
+ extra["minimum"] = fdef.minimum
114
+ if fdef.maximum is not None:
115
+ extra["maximum"] = fdef.maximum
116
+
117
+ return extra
118
+
119
+
120
+ def _build_group_model(
121
+ fields: List[MetaTypeFieldDef], model_name: str
122
+ ) -> Type[BaseModel]:
123
+ field_specs: dict = {}
124
+ for fdef in fields:
125
+ if isinstance(fdef, dict):
126
+ fdef = MetaTypeFieldDef.model_validate(fdef)
127
+ py_type = _field_type(fdef, model_name)
128
+ if fdef.translated:
129
+ py_type = Dict[str, py_type]
130
+ json_extra = _json_schema_extra(fdef)
131
+ if fdef.required:
132
+ default = Field(..., json_schema_extra=json_extra or None)
133
+ else:
134
+ py_type = Optional[py_type]
135
+ default = Field(default=None, json_schema_extra=json_extra or None)
136
+ field_specs[fdef.name] = (py_type, default)
137
+
138
+ return create_model( # type: ignore[call-overload]
139
+ model_name,
140
+ __base__=BaseModel,
141
+ **field_specs,
142
+ )
143
+
144
+
145
+ def build_pydantic_model(meta_type) -> Type[BaseModel]:
146
+ """
147
+ Build (or fetch from cache) the runtime Pydantic model for a MetaType.
148
+ """
149
+ cache_key = (meta_type.pk, str(meta_type.compiled_at or ""))
150
+ cached = _cache.get(cache_key)
151
+ if cached is not None:
152
+ return cached
153
+
154
+ fields = list(meta_type.schema or [])
155
+ model_cls = _build_group_model(fields, f"MetaType_{meta_type.pk}")
156
+ _cache[cache_key] = model_cls
157
+ return model_cls
158
+
159
+
160
+ def get_json_schema(meta_type) -> dict:
161
+ return build_pydantic_model(meta_type).model_json_schema()
@@ -0,0 +1,36 @@
1
+ from django import forms
2
+ from structured.widget.fields import StructuredJSONFormField
3
+
4
+ from .models import MetaInstance, MetaType
5
+
6
+
7
+ class MetaInstanceForm(forms.ModelForm):
8
+ """
9
+ Rebuild the ``data`` form field as a ``StructuredJSONFormField`` whose
10
+ schema is the runtime Pydantic model compiled from the chosen
11
+ ``MetaType``. The selected meta type is taken from the bound instance
12
+ or — when adding — from ``initial`` / submitted data.
13
+ """
14
+
15
+ class Meta:
16
+ model = MetaInstance
17
+ fields = "__all__"
18
+
19
+ def __init__(self, *args, **kwargs):
20
+ super().__init__(*args, **kwargs)
21
+ meta_type = None
22
+ if self.instance and self.instance.pk:
23
+ meta_type = self.instance.meta_type
24
+ else:
25
+ initial_mt = self.initial.get("meta_type") or self.data.get("meta_type")
26
+ if initial_mt:
27
+ meta_type = MetaType.objects.filter(pk=initial_mt).first()
28
+ if meta_type is not None:
29
+ schema_cls = meta_type.get_pydantic_model()
30
+ self.fields["data"] = StructuredJSONFormField(
31
+ schema=schema_cls, required=False
32
+ )
33
+ else:
34
+ self.fields["data"].help_text = (
35
+ "Select a meta type and save to start editing the data."
36
+ )
@@ -0,0 +1,73 @@
1
+ import django.db.models.deletion
2
+ import structured.fields
3
+ from django.db import migrations, models
4
+
5
+ import structured_metaobjects.schema_builder
6
+
7
+
8
+ class Migration(migrations.Migration):
9
+
10
+ initial = True
11
+
12
+ dependencies = []
13
+
14
+ operations = [
15
+ migrations.CreateModel(
16
+ name="MetaType",
17
+ fields=[
18
+ (
19
+ "id",
20
+ models.AutoField(
21
+ auto_created=True,
22
+ primary_key=True,
23
+ serialize=False,
24
+ verbose_name="ID",
25
+ ),
26
+ ),
27
+ ("name", models.CharField(max_length=200)),
28
+ (
29
+ "schema",
30
+ structured.fields.StructuredJSONField(
31
+ default=list,
32
+ schema=structured_metaobjects.schema_builder.MetaTypeFieldDef,
33
+ ),
34
+ ),
35
+ ("compiled_at", models.DateTimeField(auto_now=True)),
36
+ ],
37
+ options={
38
+ "verbose_name": "meta type",
39
+ "verbose_name_plural": "meta types",
40
+ "ordering": ("name",),
41
+ },
42
+ ),
43
+ migrations.CreateModel(
44
+ name="MetaInstance",
45
+ fields=[
46
+ (
47
+ "id",
48
+ models.AutoField(
49
+ auto_created=True,
50
+ primary_key=True,
51
+ serialize=False,
52
+ verbose_name="ID",
53
+ ),
54
+ ),
55
+ ("data", models.JSONField(blank=True, default=dict)),
56
+ ("created_at", models.DateTimeField(auto_now_add=True)),
57
+ ("updated_at", models.DateTimeField(auto_now=True)),
58
+ (
59
+ "meta_type",
60
+ models.ForeignKey(
61
+ on_delete=django.db.models.deletion.PROTECT,
62
+ related_name="instances",
63
+ to="structured_metaobjects.metatype",
64
+ ),
65
+ ),
66
+ ],
67
+ options={
68
+ "verbose_name": "meta instance",
69
+ "verbose_name_plural": "meta instances",
70
+ "ordering": ("-updated_at",),
71
+ },
72
+ ),
73
+ ]
File without changes
@@ -0,0 +1,103 @@
1
+ from django.core.exceptions import ValidationError
2
+ from django.db import models
3
+ from django.utils.translation import gettext_lazy as _
4
+ from structured.fields import StructuredJSONField
5
+
6
+ from . import compiler as meta_compiler
7
+ from .schema_builder import MetaTypeFieldDef
8
+
9
+
10
+ class MetaType(models.Model):
11
+ """
12
+ A user-defined "type" describing the shape of a ``MetaInstance``.
13
+
14
+ The ``schema`` field is a list of ``MetaTypeFieldDef`` rows declared
15
+ by an editor in the admin (string, ref, group, list, ...). When the
16
+ MetaType is saved, the runtime Pydantic compiler cache is invalidated
17
+ so any MetaInstance form/serializer using it picks up the new shape.
18
+ """
19
+
20
+ name = models.CharField(max_length=200)
21
+ schema = StructuredJSONField(default=list, schema=MetaTypeFieldDef)
22
+ compiled_at = models.DateTimeField(auto_now=True)
23
+
24
+ class Meta:
25
+ app_label = "structured_metaobjects"
26
+ verbose_name = _("meta type")
27
+ verbose_name_plural = _("meta types")
28
+ ordering = ("name",)
29
+
30
+ def __str__(self) -> str:
31
+ return self.name
32
+
33
+ def save(self, *args, **kwargs):
34
+ super().save(*args, **kwargs)
35
+ meta_compiler.clear_cache(self.pk)
36
+
37
+ def get_pydantic_model(self):
38
+ return meta_compiler.build_pydantic_model(self)
39
+
40
+ def get_json_schema(self) -> dict:
41
+ return meta_compiler.get_json_schema(self)
42
+
43
+
44
+ class MetaInstance(models.Model):
45
+ """
46
+ Concrete entry whose shape is dictated by its referenced ``MetaType``.
47
+
48
+ ``data`` is stored as a plain ``JSONField`` because ``StructuredJSONField``
49
+ needs its schema at class load time. We validate ``data`` against the
50
+ runtime Pydantic model built from ``meta_type`` in :meth:`clean` (and
51
+ at the serializer level for the API).
52
+ """
53
+
54
+ meta_type = models.ForeignKey(
55
+ MetaType, on_delete=models.PROTECT, related_name="instances"
56
+ )
57
+ data = models.JSONField(default=dict, blank=True)
58
+ created_at = models.DateTimeField(auto_now_add=True)
59
+ updated_at = models.DateTimeField(auto_now=True)
60
+
61
+ class Meta:
62
+ app_label = "structured_metaobjects"
63
+ verbose_name = _("meta instance")
64
+ verbose_name_plural = _("meta instances")
65
+ ordering = ("-updated_at",)
66
+
67
+ def __str__(self) -> str:
68
+ return f"{self.meta_type} #{self.pk}"
69
+
70
+ @property
71
+ def obj(self):
72
+ """
73
+ Return ``data`` parsed into the runtime Pydantic model derived from
74
+ ``meta_type``. Field access on the returned object benefits from
75
+ ``django-structured-json-field`` semantics — typed attributes plus
76
+ cached lazy DB lookups for ``ref``/``queryset`` fields — without
77
+ forcing a static schema on the underlying ``JSONField``.
78
+
79
+ The parsed object is cached on the instance and invalidated whenever
80
+ the raw ``data`` dict is reassigned.
81
+ """
82
+ if self.meta_type_id is None:
83
+ return None
84
+ cached = getattr(self, "_obj_cache", None)
85
+ if cached is not None and cached[0] is self.data:
86
+ return cached[1]
87
+ model_cls = self.meta_type.get_pydantic_model()
88
+ raw = self.data or {}
89
+ model_cls._cache_engine.build_cache(raw)
90
+ parsed = model_cls.model_validate(raw)
91
+ self._obj_cache = (self.data, parsed)
92
+ return parsed
93
+
94
+ def clean(self):
95
+ super().clean()
96
+ if self.meta_type_id is None:
97
+ return
98
+ model_cls = self.meta_type.get_pydantic_model()
99
+ try:
100
+ validated = model_cls.model_validate(self.data or {})
101
+ except Exception as exc: # pydantic ValidationError
102
+ raise ValidationError({"data": str(exc)})
103
+ self.data = validated.model_dump(mode="json")