django-dynamic-template 0.1.0__tar.gz
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_dynamic_template-0.1.0/LICENSE +22 -0
- django_dynamic_template-0.1.0/PKG-INFO +110 -0
- django_dynamic_template-0.1.0/README.md +85 -0
- django_dynamic_template-0.1.0/pyproject.toml +55 -0
- django_dynamic_template-0.1.0/setup.cfg +4 -0
- django_dynamic_template-0.1.0/src/django_dynamic_template.egg-info/PKG-INFO +110 -0
- django_dynamic_template-0.1.0/src/django_dynamic_template.egg-info/SOURCES.txt +28 -0
- django_dynamic_template-0.1.0/src/django_dynamic_template.egg-info/dependency_links.txt +1 -0
- django_dynamic_template-0.1.0/src/django_dynamic_template.egg-info/requires.txt +12 -0
- django_dynamic_template-0.1.0/src/django_dynamic_template.egg-info/top_level.txt +1 -0
- django_dynamic_template-0.1.0/src/dynamic_template/__init__.py +1 -0
- django_dynamic_template-0.1.0/src/dynamic_template/admin/__init__.py +5 -0
- django_dynamic_template-0.1.0/src/dynamic_template/admin/relation_context.py +8 -0
- django_dynamic_template-0.1.0/src/dynamic_template/admin/template.py +216 -0
- django_dynamic_template-0.1.0/src/dynamic_template/apps.py +6 -0
- django_dynamic_template-0.1.0/src/dynamic_template/fields.py +24 -0
- django_dynamic_template-0.1.0/src/dynamic_template/io.py +187 -0
- django_dynamic_template-0.1.0/src/dynamic_template/migrations/0001_initial.py +229 -0
- django_dynamic_template-0.1.0/src/dynamic_template/migrations/__init__.py +0 -0
- django_dynamic_template-0.1.0/src/dynamic_template/models/__init__.py +21 -0
- django_dynamic_template-0.1.0/src/dynamic_template/models/_helpers.py +26 -0
- django_dynamic_template-0.1.0/src/dynamic_template/models/relation_context.py +208 -0
- django_dynamic_template-0.1.0/src/dynamic_template/models/template.py +146 -0
- django_dynamic_template-0.1.0/src/dynamic_template/rendering.py +49 -0
- django_dynamic_template-0.1.0/src/dynamic_template/resolve.py +47 -0
- django_dynamic_template-0.1.0/src/dynamic_template/templatetags/__init__.py +0 -0
- django_dynamic_template-0.1.0/src/dynamic_template/templatetags/dyntpl.py +195 -0
- django_dynamic_template-0.1.0/tests/test_dynamic_relation_context.py +301 -0
- django_dynamic_template-0.1.0/tests/test_dyntpl.py +171 -0
- django_dynamic_template-0.1.0/tests/test_io.py +360 -0
|
@@ -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,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,85 @@
|
|
|
1
|
+
# django-dynamic-template
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- Python ≥ 3.10
|
|
8
|
+
- Django ≥ 3.2
|
|
9
|
+
- **django-namedid** ≥ 0.1.0 (installed automatically via `pyproject.toml`)
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
From the repository root (editable install recommended for local work):
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install -e .
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Add the app and dependencies you need:
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
INSTALLED_APPS = [
|
|
23
|
+
# ...
|
|
24
|
+
"dynamic_template",
|
|
25
|
+
]
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Optional: **django-boosted** (admin preview), **django-richtextfield** (TinyMCE-style widget) — see `pyproject.toml` `[project.optional-dependencies]` **dev**.
|
|
29
|
+
|
|
30
|
+
## Concepts
|
|
31
|
+
|
|
32
|
+
| Piece | Role |
|
|
33
|
+
|--------|------|
|
|
34
|
+
| **`DynamicTemplate`** | `label`, `content_type`, `named_id`, `template` (richtext), plus `model_fields`, `annotate_fields`, `fields` shaping **`object`** in the fragment |
|
|
35
|
+
| **`DynamicRelationContext`** | Related queryset (manager + filters), same three JSON fields per **row**; rows are **tuples of dicts** in the fragment |
|
|
36
|
+
| **`{% dyntpl %}`** | Loads a template by `named_id`, merges context, evaluates relation contexts, then replaces **`object`** with a plain **dict** |
|
|
37
|
+
|
|
38
|
+
- **`model_fields`**: ORM column names (empty → all concrete fields on the model).
|
|
39
|
+
- **`annotate_fields`**: list of annotation **aliases** already on the queryset / instance — this app does **not** call `.annotate()`; your manager or view must provide them.
|
|
40
|
+
- **`fields`**: extra Python names resolved with **`getattr`** (`@property`, class attributes, methods).
|
|
41
|
+
|
|
42
|
+
Filters on relations: **`filter_spec`** (resolved from **`object`** + template context) and **`filter_literal`** (static ORM kwargs). See **`docs/purpose.md`** for full detail.
|
|
43
|
+
|
|
44
|
+
## Template tag
|
|
45
|
+
|
|
46
|
+
```django
|
|
47
|
+
{% load dyntpl %}
|
|
48
|
+
|
|
49
|
+
{% dyntpl "my-block-named-id" %}
|
|
50
|
+
{% dyntpl tpl_id obj=article %}
|
|
51
|
+
{% dyntpl tpl_id ctype=Product obj=product %}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
- **`obj=`**: must match the template’s `content_type`.
|
|
55
|
+
- **`ctype=`**: disambiguates when the same `named_id` exists for several models.
|
|
56
|
+
- Other keyword arguments are merged into the **inner** fragment context.
|
|
57
|
+
|
|
58
|
+
In the fragment, use **`{{ object.name }}`**, **`{% for row in article %}{{ row.title }}{% endfor %}`**, **`{{ rows|length }}`** (not `.count` on querysets).
|
|
59
|
+
|
|
60
|
+
## Settings (optional)
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
# Import path to a widget class for the template field in admin
|
|
64
|
+
DYNAMIC_TEMPLATE_RICHTEXT_WIDGET = "djrichtextfield.widgets.RichTextWidget"
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Development
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
pytest
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`pyproject.toml` sets `DJANGO_SETTINGS_MODULE=tests.settings` and `pythonpath=["src"]`.
|
|
74
|
+
|
|
75
|
+
Apply migrations for the bundled test project:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
PYTHONPATH=src DJANGO_SETTINGS_MODULE=tests.settings python manage.py migrate
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
More detail: **`docs/`** (`purpose.md`, `structure.md`, `development.md`).
|
|
82
|
+
|
|
83
|
+
## License
|
|
84
|
+
|
|
85
|
+
MIT
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "django-dynamic-template"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Django app for content-type–scoped dynamic templates with NamedID slugs"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
authors = [{ name = "Octolo", email = "dev@octolo.tech" }]
|
|
11
|
+
license = "MIT"
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
dependencies = [
|
|
14
|
+
"Django>=3.2",
|
|
15
|
+
"django-namedid>=0.1.0",
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 4 - Beta",
|
|
19
|
+
"Framework :: Django",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
dev = [
|
|
25
|
+
"pytest>=8.3",
|
|
26
|
+
"pytest-django>=4.5",
|
|
27
|
+
"django-virtualqueryset>=0.1.1",
|
|
28
|
+
"django-boosted>=1.0.0",
|
|
29
|
+
"django-richtextfield>=1.6",
|
|
30
|
+
"ruff>=0.5",
|
|
31
|
+
"mypy>=1.0",
|
|
32
|
+
"django-stubs>=5.0.0",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.packages.find]
|
|
36
|
+
where = ["src"]
|
|
37
|
+
include = ["dynamic_template*"]
|
|
38
|
+
|
|
39
|
+
[tool.pytest.ini_options]
|
|
40
|
+
DJANGO_SETTINGS_MODULE = "tests.settings"
|
|
41
|
+
testpaths = ["tests"]
|
|
42
|
+
pythonpath = ["src"]
|
|
43
|
+
|
|
44
|
+
[tool.mypy]
|
|
45
|
+
python_version = "3.10"
|
|
46
|
+
warn_return_any = true
|
|
47
|
+
plugins = ["mypy_django_plugin.main"]
|
|
48
|
+
mypy_path = "src"
|
|
49
|
+
|
|
50
|
+
[tool.django-stubs]
|
|
51
|
+
django_settings_module = "tests.settings"
|
|
52
|
+
|
|
53
|
+
[tool.ruff]
|
|
54
|
+
line-length = 100
|
|
55
|
+
target-version = "py310"
|
|
@@ -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,28 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/django_dynamic_template.egg-info/PKG-INFO
|
|
5
|
+
src/django_dynamic_template.egg-info/SOURCES.txt
|
|
6
|
+
src/django_dynamic_template.egg-info/dependency_links.txt
|
|
7
|
+
src/django_dynamic_template.egg-info/requires.txt
|
|
8
|
+
src/django_dynamic_template.egg-info/top_level.txt
|
|
9
|
+
src/dynamic_template/__init__.py
|
|
10
|
+
src/dynamic_template/apps.py
|
|
11
|
+
src/dynamic_template/fields.py
|
|
12
|
+
src/dynamic_template/io.py
|
|
13
|
+
src/dynamic_template/rendering.py
|
|
14
|
+
src/dynamic_template/resolve.py
|
|
15
|
+
src/dynamic_template/admin/__init__.py
|
|
16
|
+
src/dynamic_template/admin/relation_context.py
|
|
17
|
+
src/dynamic_template/admin/template.py
|
|
18
|
+
src/dynamic_template/migrations/0001_initial.py
|
|
19
|
+
src/dynamic_template/migrations/__init__.py
|
|
20
|
+
src/dynamic_template/models/__init__.py
|
|
21
|
+
src/dynamic_template/models/_helpers.py
|
|
22
|
+
src/dynamic_template/models/relation_context.py
|
|
23
|
+
src/dynamic_template/models/template.py
|
|
24
|
+
src/dynamic_template/templatetags/__init__.py
|
|
25
|
+
src/dynamic_template/templatetags/dyntpl.py
|
|
26
|
+
tests/test_dynamic_relation_context.py
|
|
27
|
+
tests/test_dyntpl.py
|
|
28
|
+
tests/test_io.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
dynamic_template
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Django app: dynamic DB-backed templates keyed by NamedID and ContentType."""
|
|
@@ -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}
|