django-dynamic-template 0.1.0__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.
@@ -0,0 +1,110 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-dynamic-template
3
+ Version: 0.1.0
4
+ Summary: Django app for content-type–scoped dynamic templates with NamedID slugs
5
+ Author-email: Octolo <dev@octolo.tech>
6
+ License-Expression: MIT
7
+ Classifier: Development Status :: 4 - Beta
8
+ Classifier: Framework :: Django
9
+ Classifier: Programming Language :: Python :: 3
10
+ Requires-Python: >=3.10
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: Django>=3.2
14
+ Requires-Dist: django-namedid>=0.1.0
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest>=8.3; extra == "dev"
17
+ Requires-Dist: pytest-django>=4.5; extra == "dev"
18
+ Requires-Dist: django-virtualqueryset>=0.1.1; extra == "dev"
19
+ Requires-Dist: django-boosted>=1.0.0; extra == "dev"
20
+ Requires-Dist: django-richtextfield>=1.6; extra == "dev"
21
+ Requires-Dist: ruff>=0.5; extra == "dev"
22
+ Requires-Dist: mypy>=1.0; extra == "dev"
23
+ Requires-Dist: django-stubs>=5.0.0; extra == "dev"
24
+ Dynamic: license-file
25
+
26
+ # django-dynamic-template
27
+
28
+ Django app for **content-type–scoped dynamic templates**: store DTL fragments in the database, address them with a **`named_id`** slug ([django-namedid](https://github.com/octolo/django-namedid)), inject optional **related querysets** and a structured **`object`** dict into each fragment.
29
+
30
+ ## Requirements
31
+
32
+ - Python ≥ 3.10
33
+ - Django ≥ 3.2
34
+ - **django-namedid** ≥ 0.1.0 (installed automatically via `pyproject.toml`)
35
+
36
+ ## Installation
37
+
38
+ From the repository root (editable install recommended for local work):
39
+
40
+ ```bash
41
+ pip install -e .
42
+ ```
43
+
44
+ Add the app and dependencies you need:
45
+
46
+ ```python
47
+ INSTALLED_APPS = [
48
+ # ...
49
+ "dynamic_template",
50
+ ]
51
+ ```
52
+
53
+ Optional: **django-boosted** (admin preview), **django-richtextfield** (TinyMCE-style widget) — see `pyproject.toml` `[project.optional-dependencies]` **dev**.
54
+
55
+ ## Concepts
56
+
57
+ | Piece | Role |
58
+ |--------|------|
59
+ | **`DynamicTemplate`** | `label`, `content_type`, `named_id`, `template` (richtext), plus `model_fields`, `annotate_fields`, `fields` shaping **`object`** in the fragment |
60
+ | **`DynamicRelationContext`** | Related queryset (manager + filters), same three JSON fields per **row**; rows are **tuples of dicts** in the fragment |
61
+ | **`{% dyntpl %}`** | Loads a template by `named_id`, merges context, evaluates relation contexts, then replaces **`object`** with a plain **dict** |
62
+
63
+ - **`model_fields`**: ORM column names (empty → all concrete fields on the model).
64
+ - **`annotate_fields`**: list of annotation **aliases** already on the queryset / instance — this app does **not** call `.annotate()`; your manager or view must provide them.
65
+ - **`fields`**: extra Python names resolved with **`getattr`** (`@property`, class attributes, methods).
66
+
67
+ Filters on relations: **`filter_spec`** (resolved from **`object`** + template context) and **`filter_literal`** (static ORM kwargs). See **`docs/purpose.md`** for full detail.
68
+
69
+ ## Template tag
70
+
71
+ ```django
72
+ {% load dyntpl %}
73
+
74
+ {% dyntpl "my-block-named-id" %}
75
+ {% dyntpl tpl_id obj=article %}
76
+ {% dyntpl tpl_id ctype=Product obj=product %}
77
+ ```
78
+
79
+ - **`obj=`**: must match the template’s `content_type`.
80
+ - **`ctype=`**: disambiguates when the same `named_id` exists for several models.
81
+ - Other keyword arguments are merged into the **inner** fragment context.
82
+
83
+ In the fragment, use **`{{ object.name }}`**, **`{% for row in article %}{{ row.title }}{% endfor %}`**, **`{{ rows|length }}`** (not `.count` on querysets).
84
+
85
+ ## Settings (optional)
86
+
87
+ ```python
88
+ # Import path to a widget class for the template field in admin
89
+ DYNAMIC_TEMPLATE_RICHTEXT_WIDGET = "djrichtextfield.widgets.RichTextWidget"
90
+ ```
91
+
92
+ ## Development
93
+
94
+ ```bash
95
+ pytest
96
+ ```
97
+
98
+ `pyproject.toml` sets `DJANGO_SETTINGS_MODULE=tests.settings` and `pythonpath=["src"]`.
99
+
100
+ Apply migrations for the bundled test project:
101
+
102
+ ```bash
103
+ PYTHONPATH=src DJANGO_SETTINGS_MODULE=tests.settings python manage.py migrate
104
+ ```
105
+
106
+ More detail: **`docs/`** (`purpose.md`, `structure.md`, `development.md`).
107
+
108
+ ## License
109
+
110
+ MIT
@@ -0,0 +1,22 @@
1
+ django_dynamic_template-0.1.0.dist-info/licenses/LICENSE,sha256=oZt4vlNwtZQ6WPaqWnQu-KtlTCThT67jqVt02pAVXKE,1064
2
+ dynamic_template/__init__.py,sha256=vbqOVeJ9JahvW_4-Cbfqjdz9i6BJFm5T1JgREB3Y-74,80
3
+ dynamic_template/apps.py,sha256=xuCHWlxnr-RmvTTtodppKz-BoCHDrQIEmZ84UtAZZMc,163
4
+ dynamic_template/fields.py,sha256=cvyJHoH-v0pS5hjbC9jEVJpL-BLv0MBu6T3wBdmCWw4,769
5
+ dynamic_template/io.py,sha256=trZEuL5xVmmG7L8wjNP3B52kvozoAzu2ovMYDlVgYdc,7323
6
+ dynamic_template/rendering.py,sha256=QGEcRyQbiPH4sXFILDq2lxkYB0tGe3lx6QxCYV5l-hY,1657
7
+ dynamic_template/resolve.py,sha256=vmrqx9OcU_T2vgXWfP69R32IPwLbkvrh7h9f3iwAw0s,1375
8
+ dynamic_template/admin/__init__.py,sha256=XkS1WOY3R95XaNCUAGqfX7FEKJItITTP9vmdzWG8RPk,168
9
+ dynamic_template/admin/relation_context.py,sha256=bYMOHePRKLm5EC_NG1avZnnAj6Rof4Vxw-rz9z1goas,201
10
+ dynamic_template/admin/template.py,sha256=0U2Us5kVD1wfXH2Fs4wNRTMYwvFG-yy6RyrRRDdmUn0,7445
11
+ dynamic_template/migrations/0001_initial.py,sha256=lS8LmzsZO03Duj2hqCKEsLkANegOTR9f2euqMYON5so,9690
12
+ dynamic_template/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
+ dynamic_template/models/__init__.py,sha256=kStCI74bke8it51CjXaPtRBOWLXFHMxvkCb9jTtIndw,517
14
+ dynamic_template/models/_helpers.py,sha256=niZ7f0XEUbjPLagtHvBQbosTPGiLUYDUdzkCi1MwX4k,923
15
+ dynamic_template/models/relation_context.py,sha256=b24EuWbKv1NlrArcxohqbcGhu_K0dY_pTPHQEhYbaF8,8081
16
+ dynamic_template/models/template.py,sha256=NmpTmshoZYUrWl0ue7HZdtgGosUi661wMqFApDplNKc,5282
17
+ dynamic_template/templatetags/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
+ dynamic_template/templatetags/dyntpl.py,sha256=6CIXChBTTEFEqZzxa8whrukIpWyP3ElUrEPCMYJuNOU,6899
19
+ django_dynamic_template-0.1.0.dist-info/METADATA,sha256=hxBpHa4rib6nMFNdFu5Lc_gwtRkfTBmER_ERynJZiko,3927
20
+ django_dynamic_template-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
21
+ django_dynamic_template-0.1.0.dist-info/top_level.txt,sha256=5l2JWqtr23SfBck5FF4fmQABbBWKvb7s_srWA1zfq3w,17
22
+ django_dynamic_template-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Octolo
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 all
13
+ 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 THE
21
+ SOFTWARE.
22
+
@@ -0,0 +1 @@
1
+ dynamic_template
@@ -0,0 +1 @@
1
+ """Django app: dynamic DB-backed templates keyed by NamedID and ContentType."""
@@ -0,0 +1,5 @@
1
+ """Django admin for dynamic_template (import submodules for @admin.register side effects)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from . import template # noqa: F401
@@ -0,0 +1,8 @@
1
+ from django.contrib import admin
2
+
3
+ from dynamic_template.models import DynamicRelationContext
4
+
5
+
6
+ class DynamicRelationContextInline(admin.TabularInline):
7
+ model = DynamicRelationContext
8
+ extra = 0
@@ -0,0 +1,216 @@
1
+ import json
2
+
3
+ from django import forms
4
+ from django.contrib import admin, messages
5
+ from django.core.exceptions import PermissionDenied
6
+ from django.urls import reverse
7
+ from django.utils.translation import gettext_lazy as _
8
+ from django_boosted import AdminBoostModel, admin_boost_view
9
+ from django_boosted.decorators import AdminBoostViewConfig
10
+
11
+ from .relation_context import DynamicRelationContextInline
12
+ from dynamic_template.io import import_payload, json_attachment_response, serialize_export
13
+ from dynamic_template.models import DynamicTemplate
14
+ from dynamic_template.rendering import render_dynamic_template_fragment
15
+
16
+
17
+ def _preview_form_for_template(obj: DynamicTemplate) -> type[forms.Form]:
18
+ Model = obj.content_type.model_class()
19
+ model_label = Model._meta.label if Model else "?"
20
+
21
+ class DynamicTemplatePreviewForm(forms.Form):
22
+ object_id = forms.CharField(
23
+ label=_("Object primary key"),
24
+ required=False,
25
+ help_text=_(
26
+ "Primary key of a %(model)s instance for relation filters and for `object` in the fragment "
27
+ "(after relation contexts, `object` is a dict from model_fields / annotate_fields / fields). "
28
+ "Leave empty if the fragment does not use `object`."
29
+ )
30
+ % {"model": model_label},
31
+ )
32
+
33
+ return DynamicTemplatePreviewForm
34
+
35
+
36
+ class DynamicTemplateImportForm(forms.Form):
37
+ file = forms.FileField(label=_("JSON file"), required=False)
38
+ payload = forms.CharField(
39
+ label=_("Or paste JSON"),
40
+ widget=forms.Textarea(attrs={"rows": 24, "cols": 100, "class": "vLargeTextField"}),
41
+ required=False,
42
+ )
43
+ replace = forms.BooleanField(
44
+ label=_("Replace existing templates (same content type + label)"),
45
+ required=False,
46
+ initial=True,
47
+ )
48
+
49
+ def clean(self):
50
+ cleaned = super().clean()
51
+ file = cleaned.get("file")
52
+ text = (cleaned.get("payload") or "").strip()
53
+ raw: str | None = None
54
+ if file:
55
+ data = file.read()
56
+ raw = data.decode("utf-8") if isinstance(data, bytes) else str(data)
57
+ elif text:
58
+ raw = text
59
+ if not raw:
60
+ raise forms.ValidationError(_("Provide a JSON file or paste JSON."))
61
+ try:
62
+ parsed = json.loads(raw)
63
+ except json.JSONDecodeError as exc:
64
+ raise forms.ValidationError(str(exc)) from exc
65
+ if not isinstance(parsed, dict):
66
+ raise forms.ValidationError(_("Root JSON value must be an object."))
67
+ cleaned["parsed"] = parsed
68
+ return cleaned
69
+
70
+
71
+ @admin.register(DynamicTemplate)
72
+ class DynamicTemplateAdmin(AdminBoostModel):
73
+ list_display = ("label", "named_id", "content_type", "relation_context_count_display")
74
+ list_filter = ("content_type",)
75
+ search_fields = ("label", "named_id", "template")
76
+ readonly_fields = ("named_id",)
77
+ fieldsets = (
78
+ (
79
+ None,
80
+ {
81
+ "fields": (
82
+ "label",
83
+ "named_id",
84
+ "content_type",
85
+ "model_fields",
86
+ "annotate_fields",
87
+ "fields",
88
+ "raw_object",
89
+ "context_object",
90
+ "template",
91
+ ),
92
+ },
93
+ ),
94
+ )
95
+ inlines = (DynamicRelationContextInline,)
96
+
97
+ def get_queryset(self, request):
98
+ return super().get_queryset(request).with_relation_context_count()
99
+
100
+ @admin.display(
101
+ ordering="relation_context_count",
102
+ description=_("Relation contexts"),
103
+ )
104
+ def relation_context_count_display(self, obj):
105
+ return getattr(obj, "relation_context_count", 0)
106
+
107
+ @admin_boost_view(
108
+ "adminform",
109
+ _("Import JSON"),
110
+ config=AdminBoostViewConfig(
111
+ path_fragment="import-json",
112
+ requires_object=False,
113
+ permission="change",
114
+ ),
115
+ )
116
+ def admin_import_json(self, request, form=None):
117
+ if not self.has_change_permission(request):
118
+ raise PermissionDenied
119
+ if form is None:
120
+ return {"form": DynamicTemplateImportForm()}
121
+ result = import_payload(
122
+ form.cleaned_data["parsed"],
123
+ replace=form.cleaned_data.get("replace", True),
124
+ )
125
+ messages.success(
126
+ request,
127
+ _("Import finished: %(c)d created, %(u)d updated.")
128
+ % {"c": result["created"], "u": result["updated"]},
129
+ )
130
+ for err in result["errors"][:25]:
131
+ messages.warning(request, err)
132
+ url = reverse(
133
+ f"admin:{self.model._meta.app_label}_{self.model._meta.model_name}_changelist",
134
+ current_app=self.admin_site.name,
135
+ )
136
+ return {"redirect_url": url}
137
+
138
+ @admin_boost_view(
139
+ "json",
140
+ _("Export JSON (all)"),
141
+ config=AdminBoostViewConfig(
142
+ path_fragment="export-json-bulk",
143
+ requires_object=False,
144
+ permission="view",
145
+ ),
146
+ )
147
+ def admin_export_json_bulk(self, request):
148
+ qs = self.get_queryset(request).prefetch_related("relation_contexts")
149
+ return json_attachment_response("dynamic-templates.json", serialize_export(qs))
150
+
151
+ @admin_boost_view(
152
+ "json",
153
+ _("Export JSON"),
154
+ config=AdminBoostViewConfig(
155
+ path_fragment="export-json",
156
+ requires_object=True,
157
+ permission="view",
158
+ ),
159
+ )
160
+ def admin_export_json_single(self, request, obj):
161
+ payload = serialize_export(
162
+ DynamicTemplate.objects.filter(pk=obj.pk).prefetch_related("relation_contexts"),
163
+ )
164
+ safe = "".join(c if c.isalnum() or c in "-_" else "-" for c in obj.named_id)[:80]
165
+ return json_attachment_response(
166
+ f"dynamic-template-{obj.pk}-{safe}.json",
167
+ payload,
168
+ )
169
+
170
+ @admin_boost_view(
171
+ "adminform",
172
+ _("Preview fragment"),
173
+ config=AdminBoostViewConfig(
174
+ template_name="dynamic_template/admin/preview_render.html",
175
+ path_fragment="preview-render",
176
+ permission="change",
177
+ ),
178
+ )
179
+ def admin_preview_render(self, request, obj, form=None):
180
+ PreviewForm = _preview_form_for_template(obj)
181
+ Model = obj.content_type.model_class()
182
+
183
+ if form is None:
184
+ return {"form": PreviewForm()}
185
+
186
+ bound = None
187
+ oid = (form.cleaned_data.get("object_id") or "").strip()
188
+ if oid:
189
+ if Model is None:
190
+ form.add_error("object_id", _("Unknown content type."))
191
+ else:
192
+ try:
193
+ bound = Model.objects.get(pk=oid)
194
+ except (Model.DoesNotExist, ValueError, TypeError):
195
+ form.add_error(
196
+ "object_id",
197
+ _("No %(model)s found with this primary key.")
198
+ % {"model": Model._meta.verbose_name},
199
+ )
200
+
201
+ if form.errors:
202
+ return {"form": form}
203
+
204
+ try:
205
+ html = render_dynamic_template_fragment(
206
+ obj,
207
+ request=request,
208
+ bound=bound,
209
+ base_context={},
210
+ extra={},
211
+ )
212
+ except ValueError as exc:
213
+ form.add_error(None, str(exc))
214
+ return {"form": form}
215
+
216
+ return {"form": form, "rendered_preview": html}
@@ -0,0 +1,6 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class DynamicTemplateConfig(AppConfig):
5
+ name = "dynamic_template"
6
+ default_auto_field = "django.db.models.BigAutoField"
@@ -0,0 +1,24 @@
1
+ """Model fields; rich-text widget is chosen via ``DYNAMIC_TEMPLATE_RICHTEXT_WIDGET`` (like django-pymissive)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from django.db import models
8
+
9
+
10
+ class RichTextField(models.TextField):
11
+ """``TextField`` whose ModelForm/admin widget is configurable in settings."""
12
+
13
+ def formfield(self, **kwargs: Any) -> Any:
14
+ from django.conf import settings
15
+ from django.utils.module_loading import import_string
16
+
17
+ widget_path = getattr(
18
+ settings,
19
+ "DYNAMIC_TEMPLATE_RICHTEXT_WIDGET",
20
+ "django.forms.Textarea",
21
+ )
22
+ widget_class = import_string(widget_path)
23
+ kwargs.setdefault("widget", widget_class)
24
+ return super().formfield(**kwargs)
dynamic_template/io.py ADDED
@@ -0,0 +1,187 @@
1
+ """JSON export / import for :class:`~dynamic_template.models.DynamicTemplate` (+ relation contexts)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+ from urllib.parse import quote
8
+
9
+ from django.apps import apps
10
+ from django.http import HttpResponse
11
+ from django.contrib.contenttypes.models import ContentType
12
+ from django.db import transaction
13
+
14
+ from dynamic_template.models import DynamicRelationContext, DynamicTemplate
15
+
16
+ EXPORT_FORMAT_VERSION = 1
17
+
18
+
19
+ def _split_ct(label: str) -> tuple[str, str]:
20
+ label = str(label).strip()
21
+ parts = label.split(".", 1)
22
+ if len(parts) != 2:
23
+ raise ValueError(f"Invalid content_type label {label!r} (expected app_label.model).")
24
+ return parts[0], parts[1].lower()
25
+
26
+
27
+ def resolve_content_type(label: str) -> ContentType:
28
+ app_label, model_name = _split_ct(label)
29
+ Model = apps.get_model(app_label, model_name)
30
+ if Model is None:
31
+ raise ValueError(f"Unknown model {label!r}.")
32
+ return ContentType.objects.get_for_model(Model, for_concrete_model=False)
33
+
34
+
35
+ def serialize_relation(rc: DynamicRelationContext) -> dict[str, Any]:
36
+ return {
37
+ "content_type": f"{rc.content_type.app_label}.{rc.content_type.model}",
38
+ "manager_method": rc.manager_method,
39
+ "filter_spec": rc.filter_spec or {},
40
+ "filter_literal": rc.filter_literal or {},
41
+ "model_fields": list(rc.model_fields) if rc.model_fields is not None else [],
42
+ "annotate_fields": list(rc.annotate_fields) if rc.annotate_fields is not None else [],
43
+ "fields": list(rc.fields) if rc.fields is not None else [],
44
+ "name": rc.name,
45
+ }
46
+
47
+
48
+ def serialize_template(dt: DynamicTemplate) -> dict[str, Any]:
49
+ data: dict[str, Any] = {
50
+ "label": dt.label,
51
+ "named_id": dt.named_id,
52
+ "content_type": f"{dt.content_type.app_label}.{dt.content_type.model}",
53
+ "template": dt.template,
54
+ "model_fields": list(dt.model_fields) if dt.model_fields is not None else [],
55
+ "annotate_fields": list(dt.annotate_fields) if dt.annotate_fields is not None else [],
56
+ "fields": list(dt.fields) if dt.fields is not None else [],
57
+ "raw_object": dt.raw_object,
58
+ "context_object": dt.context_object or "",
59
+ "relation_contexts": [serialize_relation(r) for r in dt.relation_contexts.all()],
60
+ }
61
+ return data
62
+
63
+
64
+ def serialize_export(qs) -> dict[str, Any]:
65
+ templates = []
66
+ for dt in qs.order_by("pk").select_related("content_type").prefetch_related(
67
+ "relation_contexts", "relation_contexts__content_type"
68
+ ):
69
+ templates.append(serialize_template(dt))
70
+ return {
71
+ "format_version": EXPORT_FORMAT_VERSION,
72
+ "dynamic_templates": templates,
73
+ }
74
+
75
+
76
+ def content_disposition_attachment(filename: str) -> str:
77
+ """``Content-Disposition`` with ASCII ``filename`` and RFC 5987 ``filename*`` when needed."""
78
+ try:
79
+ filename.encode("ascii")
80
+ except UnicodeEncodeError:
81
+ pass
82
+ else:
83
+ return f'attachment; filename="{filename}"'
84
+ ascii_fallback = filename.encode("ascii", "replace").decode("ascii").replace("?", "_")
85
+ quoted = quote(filename, safe="")
86
+ return f'attachment; filename="{ascii_fallback}"; filename*=UTF-8\'\'{quoted}'
87
+
88
+
89
+ def json_attachment_response(filename: str, payload: dict[str, Any]) -> HttpResponse:
90
+ body = (
91
+ json.dumps(payload, ensure_ascii=False, indent=2, allow_nan=False) + "\n"
92
+ ).encode("utf-8")
93
+ response = HttpResponse(body, content_type="application/json; charset=utf-8")
94
+ response["Content-Disposition"] = content_disposition_attachment(filename)
95
+ return response
96
+
97
+
98
+ def import_payload(data: dict[str, Any], *, replace: bool = True) -> dict[str, Any]:
99
+ version = data.get("format_version", 1)
100
+ if version != EXPORT_FORMAT_VERSION:
101
+ raise ValueError(f"Unsupported format_version {version!r} (expected {EXPORT_FORMAT_VERSION}).")
102
+ items = data.get("dynamic_templates") or data.get("templates")
103
+ if not isinstance(items, list):
104
+ raise ValueError("Expected a JSON object with key 'dynamic_templates' (array).")
105
+ created = 0
106
+ updated = 0
107
+ errors: list[str] = []
108
+ for idx, item in enumerate(items):
109
+ if not isinstance(item, dict):
110
+ errors.append(f"#{idx}: not an object")
111
+ continue
112
+ try:
113
+ with transaction.atomic():
114
+ c, u = _import_one_template(item, replace=replace)
115
+ created += c
116
+ updated += u
117
+ except (ValueError, TypeError, KeyError, ContentType.DoesNotExist) as exc:
118
+ errors.append(f"#{idx} ({item.get('label', '?')}): {exc}")
119
+ return {"created": created, "updated": updated, "errors": errors}
120
+
121
+
122
+ def _import_one_template(item: dict[str, Any], *, replace: bool) -> tuple[int, int]:
123
+ label = str(item.get("label", "")).strip()
124
+ if not label:
125
+ raise ValueError("label is required")
126
+ ct = resolve_content_type(item["content_type"])
127
+ template_body = item.get("template", "") or ""
128
+ model_fields = item.get("model_fields") or []
129
+ annotate_fields = item.get("annotate_fields") or []
130
+ fields = item.get("fields") or []
131
+
132
+ if not isinstance(model_fields, list):
133
+ raise ValueError("model_fields must be a list")
134
+ if not isinstance(annotate_fields, list):
135
+ raise ValueError("annotate_fields must be a list")
136
+ if not isinstance(fields, list):
137
+ raise ValueError("fields must be a list")
138
+
139
+ raw_object = bool(item.get("raw_object", False))
140
+ context_object = str(item.get("context_object", "") or "").strip()
141
+
142
+ dt = DynamicTemplate.objects.filter(content_type=ct, label=label).first()
143
+ if dt:
144
+ if not replace:
145
+ return (0, 0)
146
+ dt.template = template_body
147
+ dt.model_fields = model_fields
148
+ dt.annotate_fields = annotate_fields
149
+ dt.fields = fields
150
+ dt.raw_object = raw_object
151
+ dt.context_object = context_object
152
+ dt.save()
153
+ DynamicRelationContext.objects.filter(dynamic_template=dt).delete()
154
+ updated = 1
155
+ created = 0
156
+ else:
157
+ dt = DynamicTemplate.objects.create(
158
+ label=label,
159
+ content_type=ct,
160
+ template=template_body,
161
+ model_fields=model_fields,
162
+ annotate_fields=annotate_fields,
163
+ fields=fields,
164
+ raw_object=raw_object,
165
+ context_object=context_object,
166
+ )
167
+ created, updated = 1, 0
168
+
169
+ rels = item.get("relation_contexts") or []
170
+ if not isinstance(rels, list):
171
+ raise ValueError("relation_contexts must be a list")
172
+ for rel in rels:
173
+ if not isinstance(rel, dict):
174
+ raise ValueError("Each relation_context must be an object")
175
+ r_ct = resolve_content_type(rel["content_type"])
176
+ DynamicRelationContext.objects.create(
177
+ dynamic_template=dt,
178
+ content_type=r_ct,
179
+ manager_method=str(rel.get("manager_method") or "objects.all"),
180
+ filter_spec=rel.get("filter_spec") or {},
181
+ filter_literal=rel.get("filter_literal") or {},
182
+ model_fields=rel.get("model_fields") or [],
183
+ annotate_fields=rel.get("annotate_fields") or [],
184
+ fields=rel.get("fields") or [],
185
+ name=str(rel.get("name", "")).strip(),
186
+ )
187
+ return (created, updated)