vintasend-django-templates-manager
The Django storage backend for
vintasend-managed-templates: a
DjangoTemplateManager implementing the storage seam, the models behind it, and an admin for the
people who edit the copy.
vintasend-managed-templates defines what a managed template is — versions, a
draft/active/inactive/archived lifecycle with an audit trail, tags, filtering, and composition. It
deliberately does not say where any of it lives. This package puts it in your Django database.
Install
poetry add vintasend-django-templates-manager
# or
pip install vintasend-django-templates-manager
Python 3.12–3.14, Django 4.2–6.0.
Add the app and migrate:
# settings.py
INSTALLED_APPS = [
...,
"vintasend_django_templates_manager",
]
python manage.py migrate
Wiring it up
DjangoTemplateManager is the backend; hand it to the renderer and the service from
vintasend-managed-templates:
from vintasend_managed_templates.managed_template_renderer import ManagedTemplateEmailRenderer
from vintasend_managed_templates.managed_template_service import ManagedTemplateService
from vintasend_django_templates_manager.django_templates_manager import DjangoTemplateManager
manager_backend = DjangoTemplateManager()
renderer = ManagedTemplateEmailRenderer(manager_backend, inner_renderer)
service = ManagedTemplateService(manager_backend, renderer)
inner_renderer is any vintasend email renderer. It receives template source rather than a
template name, so a renderer that resolves names through a loader —
DjangoTemplatedEmailRenderer included — needs a loader that accepts source. See
What the inner renderer has to do.
Everything else — creating templates, publishing versions, tagging, filtering — is
ManagedTemplateService's API, unchanged.
Which version a send renders
get_active_template is implemented natively: one indexed query for the key's highest-numbered
active version. That is what an unpinned send renders and what pin_template_versions pins to,
so a draft is never sent. A key that exists but has no active version raises
ManagedTemplateNoActiveVersionError. get_template(key) with no version still answers the newest
version whatever its status, for editors and APIs. See
Which version a send renders.
Deleting a version
delete_template deletes only a version that was never published: still draft, with nothing
but draft in its status history. Anything else raises ManagedTemplateDeletionNotAllowedError,
including delete_template(key) with no version when the latest version is published. Archive a
published version instead.
The rule is checked with the row locked, inside the same transaction as the delete. To allow hard deletes of published versions, which an operator should rarely need, switch it off on both the backend and the service:
manager_backend = DjangoTemplateManager(allow_deleting_published_versions=True)
service = ManagedTemplateService(manager_backend, renderer, allow_deleting_published_versions=True)
Status history is never deleted, whichever way a version goes: through delete_template, the
admin, or ManagedTemplate.objects...delete(). Each record stores its own template_key and
version, and its template link is set to NULL when the version is deleted, so
get_template_status_history keeps returning it. For the same reason a deleted version's number
is never reused: the next version is numbered one above the highest the key has ever had,
history included, so it cannot inherit someone else's trail.
The admin applies the same rule. A published version has no delete button and its delete page
answers 403, and a "Delete selected" batch holding one is refused whole. Set
allow_deleting_published_versions = True on a ManagedTemplateAdmin subclass to lift it.
What is stored
| Model | What it holds |
|---|---|
ManagedTemplate |
One version of a template. (key, version) is unique, and a key has as many rows as it has versions. Nothing about a row changes once it exists except its status, its tags, and the derived is_abstract flag. |
ManagedTemplateStatusRecord |
The audit trail: who moved a version to which status, and when. Each record keeps its version's template_key and version on its own row, so it outlives the version (template becomes NULL). |
ManagedTemplateTag |
A label shared across templates, identified by the slug vintasend_managed_templates.tags.slugify_tag derives from its text. |
Versions are the reason a published template can never change under a notification that already
referenced it: update_template inserts the next version and leaves its predecessor exactly as it
was, content, status and history alike.
Filtering and ordering
Every filter the library's vocabulary defines is translated into a Django Q and answered by the
database, so this backend declines nothing. What get_filter_capabilities reports is the other
direction — what it can do that the library does not assume:
service.get_backend_supported_filter_capabilities()
# {..., 'orderBy.key': True, 'orderBy.name': True, 'orderBy.version': True,
# 'orderBy.status': True, 'orderBy.createdAt': True, 'orderBy.updatedAt': True}
The six orderBy.* keys are declared explicitly because they default to False in the library:
ordering is newer vocabulary than the filters, so a backend that can sort has to say so rather
than be assumed to. All six are real indexed columns on ManagedTemplate, so each is answered by
the database:
service.get_paginated_templates(
page=1, page_size=20, order_by={"field": "version", "direction": "desc"}
)
Two details worth knowing:
- The order is composed into the SQL, not applied to the page. A page ordered after it was chosen sorts rows within the page while the rows selected for it came back in the store's own order — right on page 1, wrong on every page after it.
versionis aPositiveIntegerField, so v10 sorts after v2. A store keeping versions as strings gets that wrong silently, which is why every orderable field is pinned by a test that runs the sort rather than reads the column definition.
An unordered read still orders by -created, -id: a key has a row per version, so an unordered
offset page is free to return one row twice and skip another.
The admin
All three models are registered.
- Templates —
keyandversionlock once the row exists, since they are its identity. Every status change made here is written to the audit trail, which is inlined read-only on the page. Only a never-published version can be deleted here — see Deleting a version. - Tags — the slug is derived from the text on save rather than typed in, and collides safely
(
black-friday-2). Archive and restore are bulk actions; archiving retires a tag from the pickers without severing it from the templates carrying it. - Status history — browsable, never editable. Listed and searched by each record's own key and version, so the history of a deleted version still shows up.
Upgrading: the status-history migrations
0002–0004 move status history off on_delete=CASCADE:
0002adds nullabletemplate_keyandversioncolumns toManagedTemplateStatusRecord.0003is the data step. It copies each record's key and version from the version it belongs to, with oneUPDATEper column. It only touches records that are still missing them, so running it again changes nothing.0004makes both columns required, makestemplatenullable withon_delete=SET_NULL, and adds an index on(template_key, version).
They are split so the data copy never shares a transaction with a schema change on the same table, which PostgreSQL can refuse.
Deploy order. Code from before this release writes status records without template_key or
version, which 0004 makes required, and this release's code needs the columns 0002 adds.
Migrating and switching code in one step (stop, migrate, start) needs nothing more. For a rolling
deploy, where old and new instances serve side by side:
python manage.py migrate vintasend_django_templates_manager 0002- roll out the new code everywhere, and wait until no old instance is left;
python manage.py migrate—0003fills any record an old instance wrote meanwhile, and0004makes the columns required.
Rolling back with python manage.py migrate vintasend_django_templates_manager 0001 restores
the old schema. One case stops it: if any version has been deleted since the upgrade, its history
records have no version to point at, and the old schema cannot hold them. Rather than delete that
history, the rollback stops and reports how many such records exist. To roll back anyway, export
the records with template=None (their template_key and version say what they were about),
delete them deliberately, and run the rollback again.
Composition
A template stored here can build on another one: extend a base, fill its hole, override its blocks, splice in a shared fragment. All of it is resolved before the template engine runs, so what Django's engine receives is one flat string with its own syntax untouched.
base-email <html><body>
{% managed_block header %}<h1>Acme</h1>{% managed_endblock %}
{% managed_children %}
{% managed_include "footer" %}
</body></html>
welcome {% managed_extends "base-email" %}
{% managed_block header %}<h1>Welcome!</h1>{% managed_endblock %}
<p>Hi {{ name }}, welcome aboard.</p>
The tag language, the abstract-template rule and the version semantics are
vintasend-managed-templates': see
Composition.
Composition itself is read off the template source — there is no separate model for it, no join,
and nothing to keep in step except the one flag below.
What this package adds is the editing side:
- The admin refuses what will not compose. Saving a template runs every one of its three
sources through the composer against the database. A base that does not exist, a block left
open, a chain of bases that loops back to the row being edited — each becomes an error on the
field that carries it, rather than a notification that fails to send days later. Set
validate_composition = Falseon aManagedTemplateAdminFormsubclass to save a template whose base has not been written yet. - The change form previews the result. A read-only composed body under the content fields shows what the engine will actually receive, resolved against whatever the referenced templates are now.
- The changelist marks the bases, and filters on them, through the stored flag below.
is_abstract
Whether a template is a base to build on rather than one to send is a fact about its source. It is also the thing a "pick a template" screen most needs to filter on, so it is denormalized onto a column:
ManagedTemplate.objects.sendable() # what a picker should offer
ManagedTemplate.objects.abstract() # the bases
service.get_filtered_templates({"is_abstract": False}) # the same, through the library
ManagedTemplate.save() derives it, so every write path keeps it honest — the admin,
DjangoTemplateManager, a data migration, a shell session. It is editable=False: nobody types it
in, and a template whose composition tags are malformed saves as concrete rather than blowing up a
write (the admin form has already refused that template anyway).
To recompute rather than trust the column — for a row edited in memory, or one written before the column existed:
from vintasend_django_templates_manager.composition import template_is_abstract
template_is_abstract(template) # raises on a malformed tag
template_is_abstract(template, strict=False) # reads an unparseable template as concrete
Composition resolves references through this backend, which means an unpinned {% managed_extends "base-email" %} picks up the latest version of that key, draft included — the editing-view
rule get_template(key) follows, not the send path's newest-active rule. So an active template can
compose against a base's unpublished draft. Pin it with version=2 when a template has to keep
composing against an exact base.
Pinning the notification rather than the base is vintasend's job, through
requested_template_version — see
Template Version Pinning.
This app stores those templates; it does not store the notifications.
Development
poetry install
poetry run pytest
poetry run mypy
poetry run tox # the Python × Django matrix
poetry run pre-commit run --all-files # ruff lint + format
Metadata
Release files for vintasend-django-templates-manager 3.5.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| vintasend_django_templates_manager-3.5.0.tar.gz | 36.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| vintasend_django_templates_manager-3.5.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 73.6 kB
Release files / vintasend_django_templates_manager-3.5.0.tar.gz
| Download URL | vintasend_django_templates_manager-3.5.0.tar.gz |
|---|---|
| Size | 36.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
f8cc81b2a2a3c08290cb9873ae488aa2cbda7e7bf70520ea042f2f30d98a9ba4
|
|
BLAKE2b-256 checksum How to use checksums |
867bc3c256b175ba0e86fc9f46ad0eaeddd8ca98a6af14dcf9e5f7a2b728dd83
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 9, 2026.
Transparency logRelease files / vintasend_django_templates_manager-3.5.0-py3-none-any.whl
| Download URL | vintasend_django_templates_manager-3.5.0-py3-none-any.whl |
|---|---|
| Size | 37.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
b3c39e41bf670c6ef9aebe8ed3f76a46a814d3f7d68da058d42ef5dd97f6b555
|
|
BLAKE2b-256 checksum How to use checksums |
83eeaff69e5f5569c737ec306d1dce6e295eb4ab000bf57a095de1beef7f6080
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 9, 2026.
Transparency log