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.
- django_structured_metaobjects-1.0.0.dist-info/METADATA +112 -0
- django_structured_metaobjects-1.0.0.dist-info/RECORD +40 -0
- django_structured_metaobjects-1.0.0.dist-info/WHEEL +6 -0
- django_structured_metaobjects-1.0.0.dist-info/licenses/LICENSE +21 -0
- django_structured_metaobjects-1.0.0.dist-info/top_level.txt +2 -0
- structured_metaobjects/__init__.py +14 -0
- structured_metaobjects/admin.py +20 -0
- structured_metaobjects/apps.py +8 -0
- structured_metaobjects/compiler.py +161 -0
- structured_metaobjects/forms.py +36 -0
- structured_metaobjects/migrations/0001_initial.py +73 -0
- structured_metaobjects/migrations/__init__.py +0 -0
- structured_metaobjects/models.py +103 -0
- structured_metaobjects/schema_builder.py +166 -0
- structured_metaobjects/serializers.py +37 -0
- structured_metaobjects/static/structured_metaobjects/admin/meta_instance.js +23 -0
- structured_metaobjects/urls.py +12 -0
- structured_metaobjects/views.py +35 -0
- tests/__init__.py +0 -0
- tests/app/__init__.py +0 -0
- tests/app/app/__init__.py +0 -0
- tests/app/app/asgi.py +5 -0
- tests/app/app/settings.py +77 -0
- tests/app/app/urls.py +14 -0
- tests/app/app/wsgi.py +5 -0
- tests/app/test_module/__init__.py +0 -0
- tests/app/test_module/admin.py +8 -0
- tests/app/test_module/apps.py +8 -0
- tests/app/test_module/migrations/0001_initial.py +22 -0
- tests/app/test_module/migrations/__init__.py +0 -0
- tests/app/test_module/models.py +12 -0
- tests/app/test_module/serializers.py +9 -0
- tests/app/test_module/views.py +9 -0
- tests/test_admin.py +25 -0
- tests/test_compiler.py +462 -0
- tests/test_forms.py +32 -0
- tests/test_models.py +213 -0
- tests/test_schema_builder.py +81 -0
- tests/test_serializers.py +79 -0
- 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,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
|
File without changes
|
|
@@ -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
|
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
|