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,166 @@
1
+ from enum import Enum
2
+ from typing import Annotated, List, Optional
3
+
4
+ from pydantic import ConfigDict, Field, model_validator
5
+ from structured.pydantic.conditionals import When, conditional_schema
6
+ from structured.pydantic.models import BaseModel
7
+
8
+
9
+ def _target_model_enum(schema: dict) -> None:
10
+ """
11
+ Inject an enum of all installed Django models into the JSON schema.
12
+
13
+ For ``Optional[str]`` Pydantic emits ``anyOf: [{type: string}, {type: null}]``.
14
+ A sibling ``enum`` at that level is ignored by most JSON Schema editors,
15
+ so we place the enum inside the string branch instead.
16
+ """
17
+ from django.apps import apps
18
+
19
+ enum = sorted(f"{m._meta.app_label}.{m.__name__}" for m in apps.get_models())
20
+ for branch in schema.get("anyOf", []):
21
+ if branch.get("type") == "string":
22
+ branch["enum"] = enum
23
+ return
24
+ schema["enum"] = enum
25
+
26
+
27
+ class SelectChoice(BaseModel):
28
+ """A single option for a ``select`` field."""
29
+ value: str = Field(min_length=1)
30
+ label: str = ""
31
+
32
+ @model_validator(mode="after")
33
+ def _fill_label(self):
34
+ if not self.label:
35
+ self.label = self.value
36
+ return self
37
+
38
+
39
+ class MetaFieldKind(str, Enum):
40
+ string = "string"
41
+ html = "html"
42
+ number = "number"
43
+ boolean = "boolean"
44
+ date = "date"
45
+ datetime = "datetime"
46
+ select = "select"
47
+ ref = "ref"
48
+ queryset = "queryset"
49
+ group = "group"
50
+ list = "list"
51
+
52
+
53
+ class MetaTypeFieldDef(BaseModel):
54
+ """
55
+ Describes a single field declared by an editor on a ``MetaType``.
56
+
57
+ The full ``MetaType.schema`` is a list of these (possibly nested via
58
+ ``children``).
59
+
60
+ Fields are conditionally shown/required based on ``kind``:
61
+
62
+ - ``target_model`` — ``ref``, ``queryset``
63
+ - ``children`` — ``group``, ``list``
64
+ - ``choices`` — ``select``
65
+ - ``multiline``, ``placeholder``, ``min_length``, ``max_length`` — ``string``
66
+ - ``placeholder`` (also) — ``html``
67
+ - ``integer``, ``minimum``, ``maximum`` — ``number``
68
+ """
69
+
70
+ # ── common ───────────────────────────────────────────────────────────
71
+ name: str = Field(min_length=1)
72
+ label: str = ""
73
+ kind: MetaFieldKind = MetaFieldKind.string
74
+ required: bool = False
75
+ translated: bool = False
76
+
77
+ # ── ref / queryset ───────────────────────────────────────────────────
78
+ target_model: Annotated[
79
+ Optional[str],
80
+ Field(default=None, json_schema_extra=_target_model_enum),
81
+ ] = None
82
+
83
+ # ── group / list ─────────────────────────────────────────────────────
84
+ children: List["MetaTypeFieldDef"] = Field(default_factory=list)
85
+
86
+ # ── select ───────────────────────────────────────────────────────────
87
+ choices: List[SelectChoice] = Field(default_factory=list)
88
+
89
+ # ── string ───────────────────────────────────────────────────────────
90
+ multiline: bool = False
91
+ min_length: Optional[int] = None
92
+ max_length: Optional[int] = None
93
+
94
+ # ── string / html ────────────────────────────────────────────────────
95
+ placeholder: str = ""
96
+
97
+ # ── number ───────────────────────────────────────────────────────────
98
+ integer: bool = False
99
+ minimum: Optional[float] = None
100
+ maximum: Optional[float] = None
101
+
102
+ model_config = ConfigDict(
103
+ json_schema_extra=conditional_schema(
104
+ When(
105
+ "kind",
106
+ in_=["ref", "queryset"],
107
+ controls=["target_model"],
108
+ then={"required": ["target_model"]},
109
+ ),
110
+ When(
111
+ "kind",
112
+ in_=["group", "list"],
113
+ controls=["children"],
114
+ then={"required": ["children"]},
115
+ ),
116
+ When(
117
+ "kind",
118
+ in_=["select"],
119
+ controls=["choices"],
120
+ then={"required": ["choices"]},
121
+ ),
122
+ When(
123
+ "kind",
124
+ in_=["string"],
125
+ controls=["multiline", "min_length", "max_length"],
126
+ ),
127
+ When(
128
+ "kind",
129
+ in_=["string", "html"],
130
+ controls=["placeholder"],
131
+ ),
132
+ When(
133
+ "kind",
134
+ in_=["number"],
135
+ controls=["integer", "minimum", "maximum"],
136
+ ),
137
+ )
138
+ )
139
+
140
+ @model_validator(mode="after")
141
+ def _validate_fields(self):
142
+ if not self.label:
143
+ self.label = self.name.replace("_", " ").replace("-", " ").title()
144
+ return self
145
+
146
+ @model_validator(mode="after")
147
+ def _validate_kind_dependencies(self):
148
+ if self.kind in (MetaFieldKind.ref, MetaFieldKind.queryset):
149
+ if not self.target_model:
150
+ raise ValueError(
151
+ f"target_model is required when kind is '{self.kind.value}'"
152
+ )
153
+ if self.kind in (MetaFieldKind.group, MetaFieldKind.list):
154
+ if not self.children:
155
+ raise ValueError(
156
+ f"children is required when kind is '{self.kind.value}'"
157
+ )
158
+ if self.kind == MetaFieldKind.select:
159
+ if not self.choices:
160
+ raise ValueError(
161
+ "choices is required when kind is 'select'"
162
+ )
163
+ return self
164
+
165
+
166
+ MetaTypeFieldDef.model_rebuild()
@@ -0,0 +1,37 @@
1
+ from rest_framework import serializers
2
+ from rest_framework.exceptions import ValidationError as DRFValidationError
3
+
4
+ from .models import MetaInstance, MetaType
5
+
6
+
7
+ class MetaTypeSerializer(serializers.ModelSerializer):
8
+ class Meta:
9
+ model = MetaType
10
+ fields = "__all__"
11
+
12
+
13
+ class MetaInstanceSerializer(serializers.ModelSerializer):
14
+ data = serializers.JSONField()
15
+
16
+ class Meta:
17
+ model = MetaInstance
18
+ fields = "__all__"
19
+
20
+ def _resolve_meta_type(self, attrs):
21
+ meta_type = attrs.get("meta_type")
22
+ if meta_type is None and self.instance is not None:
23
+ meta_type = self.instance.meta_type
24
+ return meta_type
25
+
26
+ def validate(self, attrs):
27
+ attrs = super().validate(attrs)
28
+ meta_type = self._resolve_meta_type(attrs)
29
+ if meta_type is None:
30
+ raise DRFValidationError({"meta_type": "This field is required."})
31
+ model_cls = meta_type.get_pydantic_model()
32
+ try:
33
+ validated = model_cls.model_validate(attrs.get("data") or {})
34
+ except Exception as exc:
35
+ raise DRFValidationError({"data": str(exc)})
36
+ attrs["data"] = validated.model_dump(mode="json")
37
+ return attrs
@@ -0,0 +1,23 @@
1
+ // When the meta_type select changes on the MetaInstance add/change form,
2
+ // reload the page with ?meta_type=<id> so the server-side form rebuilds
3
+ // the "data" widget with the structured-json schema for the chosen type.
4
+ (function () {
5
+ function init() {
6
+ var select = document.getElementById("id_meta_type");
7
+ if (!select) return;
8
+ select.addEventListener("change", function () {
9
+ var url = new URL(window.location.href);
10
+ if (select.value) {
11
+ url.searchParams.set("meta_type", select.value);
12
+ } else {
13
+ url.searchParams.delete("meta_type");
14
+ }
15
+ window.location.href = url.toString();
16
+ });
17
+ }
18
+ if (document.readyState === "loading") {
19
+ document.addEventListener("DOMContentLoaded", init);
20
+ } else {
21
+ init();
22
+ }
23
+ })();
@@ -0,0 +1,12 @@
1
+ from django.urls import include, path
2
+ from rest_framework import routers
3
+
4
+ from .views import MetaInstanceViewSet, MetaTypeViewSet
5
+
6
+ router = routers.DefaultRouter()
7
+ router.register(r"meta-types", MetaTypeViewSet, "meta-types")
8
+ router.register(r"meta-instances", MetaInstanceViewSet, "meta-instances")
9
+
10
+ urlpatterns = [
11
+ path("", include(router.urls)),
12
+ ]
@@ -0,0 +1,35 @@
1
+ from rest_framework import viewsets
2
+ from rest_framework.decorators import action
3
+ from rest_framework.exceptions import NotFound, ValidationError as DRFValidationError
4
+ from rest_framework.response import Response
5
+
6
+ from .models import MetaInstance, MetaType
7
+ from .serializers import MetaInstanceSerializer, MetaTypeSerializer
8
+
9
+
10
+ class MetaTypeViewSet(viewsets.ModelViewSet):
11
+ queryset = MetaType.objects.all()
12
+ serializer_class = MetaTypeSerializer
13
+ search_fields = ("name",)
14
+
15
+ @action(detail=True, methods=["get"], url_path="schema")
16
+ def schema(self, request, pk=None):
17
+ meta_type = self.get_object()
18
+ return Response(meta_type.get_json_schema())
19
+
20
+
21
+ class MetaInstanceViewSet(viewsets.ModelViewSet):
22
+ queryset = MetaInstance.objects.select_related("meta_type")
23
+ serializer_class = MetaInstanceSerializer
24
+ search_fields = ()
25
+
26
+ @action(detail=False, methods=["get"], url_path="schema")
27
+ def schema(self, request):
28
+ meta_type_id = request.GET.get("meta_type")
29
+ if not meta_type_id:
30
+ raise DRFValidationError({"meta_type": "Query parameter required."})
31
+ try:
32
+ meta_type = MetaType.objects.get(pk=meta_type_id)
33
+ except MetaType.DoesNotExist:
34
+ raise NotFound("MetaType not found")
35
+ return Response(meta_type.get_json_schema())
tests/__init__.py ADDED
File without changes
tests/app/__init__.py ADDED
File without changes
File without changes
tests/app/app/asgi.py ADDED
@@ -0,0 +1,5 @@
1
+ import os
2
+ from django.core.asgi import get_asgi_application
3
+
4
+ os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tests.app.app.settings')
5
+ application = get_asgi_application()
@@ -0,0 +1,77 @@
1
+ from pathlib import Path
2
+
3
+ BASE_DIR = Path(__file__).resolve().parent.parent
4
+
5
+ SECRET_KEY = 'django-insecure-test-key-for-structured-metaobjects-only'
6
+
7
+ DEBUG = True
8
+
9
+ ALLOWED_HOSTS = []
10
+
11
+ INSTALLED_APPS = [
12
+ 'structured',
13
+ 'structured_metaobjects',
14
+ 'tests.app.test_module',
15
+ 'django.contrib.admin',
16
+ 'django.contrib.auth',
17
+ 'django.contrib.contenttypes',
18
+ 'django.contrib.sessions',
19
+ 'django.contrib.messages',
20
+ 'django.contrib.staticfiles',
21
+ 'rest_framework',
22
+ ]
23
+
24
+ MIDDLEWARE = [
25
+ 'django.middleware.security.SecurityMiddleware',
26
+ 'django.contrib.sessions.middleware.SessionMiddleware',
27
+ 'django.middleware.common.CommonMiddleware',
28
+ 'django.middleware.csrf.CsrfViewMiddleware',
29
+ 'django.contrib.auth.middleware.AuthenticationMiddleware',
30
+ 'django.contrib.messages.middleware.MessageMiddleware',
31
+ 'django.middleware.clickjacking.XFrameOptionsMiddleware',
32
+ ]
33
+
34
+ ROOT_URLCONF = 'tests.app.app.urls'
35
+
36
+ TEMPLATES = [
37
+ {
38
+ 'BACKEND': 'django.template.backends.django.DjangoTemplates',
39
+ 'DIRS': [],
40
+ 'APP_DIRS': True,
41
+ 'OPTIONS': {
42
+ 'context_processors': [
43
+ 'django.template.context_processors.debug',
44
+ 'django.template.context_processors.request',
45
+ 'django.contrib.auth.context_processors.auth',
46
+ 'django.contrib.messages.context_processors.messages',
47
+ ],
48
+ },
49
+ },
50
+ ]
51
+
52
+ WSGI_APPLICATION = 'tests.app.app.wsgi.application'
53
+
54
+ DATABASES = {
55
+ 'default': {
56
+ 'ENGINE': 'django.db.backends.sqlite3',
57
+ 'NAME': BASE_DIR / 'db.sqlite3',
58
+ }
59
+ }
60
+
61
+ AUTH_PASSWORD_VALIDATORS = [
62
+ {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
63
+ {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},
64
+ {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
65
+ {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
66
+ ]
67
+
68
+ LANGUAGE_CODE = 'en-us'
69
+ TIME_ZONE = 'UTC'
70
+ USE_I18N = True
71
+ USE_TZ = True
72
+
73
+ STATIC_URL = 'static/'
74
+ MEDIA_ROOT = Path(BASE_DIR / 'media')
75
+ MEDIA_URL = '/media/'
76
+
77
+ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
tests/app/app/urls.py ADDED
@@ -0,0 +1,14 @@
1
+ from django.contrib import admin
2
+ from django.urls import path, include
3
+ from rest_framework.routers import DefaultRouter
4
+ from tests.app.test_module.views import PageViewSet
5
+
6
+ router = DefaultRouter()
7
+ router.register(r'pages', PageViewSet)
8
+
9
+ urlpatterns = [
10
+ path("", include("structured.urls")),
11
+ path('admin/', admin.site.urls),
12
+ path('api/', include('structured_metaobjects.urls')),
13
+ path('api/', include(router.urls)),
14
+ ]
tests/app/app/wsgi.py ADDED
@@ -0,0 +1,5 @@
1
+ import os
2
+ from django.core.wsgi import get_wsgi_application
3
+
4
+ os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tests.app.app.settings')
5
+ application = get_wsgi_application()
File without changes
@@ -0,0 +1,8 @@
1
+ from django.contrib import admin
2
+
3
+ from .models import Page
4
+
5
+
6
+ @admin.register(Page)
7
+ class PageAdmin(admin.ModelAdmin):
8
+ list_display = ("title", "slug")
@@ -0,0 +1,8 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class TestModuleConfig(AppConfig):
5
+ name = "tests.app.test_module"
6
+ label = "test_module"
7
+ verbose_name = "Test Module"
8
+ default_auto_field = "django.db.models.BigAutoField"
@@ -0,0 +1,22 @@
1
+ # Generated by Django 6.0.4 on 2026-04-10 07:52
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ initial = True
9
+
10
+ dependencies = [
11
+ ]
12
+
13
+ operations = [
14
+ migrations.CreateModel(
15
+ name='Page',
16
+ fields=[
17
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
18
+ ('title', models.CharField(max_length=200)),
19
+ ('slug', models.SlugField(blank=True, default='', max_length=200)),
20
+ ],
21
+ ),
22
+ ]
File without changes
@@ -0,0 +1,12 @@
1
+ from django.db import models
2
+
3
+
4
+ class Page(models.Model):
5
+ title = models.CharField(max_length=200)
6
+ slug = models.SlugField(max_length=200, blank=True, default="")
7
+
8
+ class Meta:
9
+ app_label = "test_module"
10
+
11
+ def __str__(self):
12
+ return self.title
@@ -0,0 +1,9 @@
1
+ from rest_framework import serializers
2
+
3
+ from .models import Page
4
+
5
+
6
+ class PageSerializer(serializers.ModelSerializer):
7
+ class Meta:
8
+ model = Page
9
+ fields = "__all__"
@@ -0,0 +1,9 @@
1
+ from rest_framework import viewsets
2
+
3
+ from .models import Page
4
+ from .serializers import PageSerializer
5
+
6
+
7
+ class PageViewSet(viewsets.ModelViewSet):
8
+ queryset = Page.objects.all()
9
+ serializer_class = PageSerializer
tests/test_admin.py ADDED
@@ -0,0 +1,25 @@
1
+ import pytest
2
+ from django.contrib.admin.sites import AdminSite
3
+
4
+ from structured_metaobjects.admin import MetaInstanceAdmin, MetaTypeAdmin
5
+ from structured_metaobjects.models import MetaInstance, MetaType
6
+
7
+
8
+ @pytest.mark.django_db
9
+ class TestAdminRegistration:
10
+ def test_meta_type_admin_registered(self):
11
+ site = AdminSite()
12
+ admin = MetaTypeAdmin(MetaType, site)
13
+ assert admin.list_display == ("name",)
14
+
15
+ def test_meta_instance_admin_registered(self):
16
+ site = AdminSite()
17
+ admin = MetaInstanceAdmin(MetaInstance, site)
18
+ assert "meta_type" in admin.list_filter
19
+
20
+ def test_meta_instance_admin_uses_custom_form(self):
21
+ from structured_metaobjects.forms import MetaInstanceForm
22
+
23
+ site = AdminSite()
24
+ admin = MetaInstanceAdmin(MetaInstance, site)
25
+ assert admin.form is MetaInstanceForm