Skip to main content

vintasend-managed-templates

Database-backed notification templates for vintasend: versioning, a draft/active/inactive/archived lifecycle with an audit trail, tags, and filtering — all on top of a storage seam you (or a ready-made package) implement.

A regular vintasend template renderer reads templates from wherever its engine looks, which is usually files on disk. That means every copy change is a deploy. This package moves the templates into a data store so someone who is not a developer can edit them, keeps every edit as a new version, and lets you publish a version deliberately instead of the moment it is saved.

It is storage-agnostic on its own — it defines the interface, not the database. Pair it with a manager backend such as vintasend-django-templates-manager, or implement BaseTemplateManagerBackend yourself.

Install

poetry add vintasend-managed-templates
# or
pip install vintasend-managed-templates

Python 3.10–3.14. The only dependencies are vintasend itself and typing-extensions.

The pieces

Piece What it is
BaseTemplateManagerBackend The storage seam. An ABC covering template CRUD, versions, status history, tags, filtering, and pagination.
ManagedTemplateService The API you call. Wraps a backend and a renderer with version resolution, status-transition rules, filter validation, and tag normalization.
ManagedTemplateEmailRenderer / ManagedTemplateSMSRenderer A vintasend template renderer that wraps another renderer and feeds it a stored template instead of a template path.
composition.TemplateComposer Resolves template inheritance and inclusion against the store, before the engine runs.
tags.slugify_tag / next_available_slug The shared slug rules, so every backend derives the same slug from the same text.
lifecycle The shared lifecycle rules: which version a send renders (newest_active_version) and which versions may be deleted (is_template_version_deletable).
dataclasses, constants, filters, exceptions The wire types: ManagedTemplate, ManagedTemplateTag, the two status enums, the filter TypedDicts, and the error hierarchy.

Everything here is synchronous. There is no AsyncIO twin, because the seams it composes (BaseTemplateManagerBackend and vintasend's template renderer seam) are both synchronous.

Quick start

from vintasend_managed_templates.dataclasses import ManagedTemplateCreateInput
from vintasend_managed_templates.managed_template_renderer import ManagedTemplateEmailRenderer
from vintasend_managed_templates.managed_template_service import ManagedTemplateService

manager_backend = MyTemplateManagerBackend()          # any BaseTemplateManagerBackend
renderer = ManagedTemplateEmailRenderer(
    manager_backend,
    inner_renderer,                                    # any vintasend email renderer
)
service = ManagedTemplateService(manager_backend, renderer)

template = service.create_template(
    ManagedTemplateCreateInput(
        name="Welcome email",
        description="Sent right after signup",
        key="welcome",                                 # what notifications reference
        template_managed_backend="django",             # which manager backend stores it
        template_body="<p>Hi {{ name }}, welcome!</p>",
        template_subject="Welcome aboard",
        template_preheader=None,
        tenant=None,
        tags=["onboarding", "Black Friday"],
    )
)

service.activate("welcome", changed_by="hugo@example.com")

To send through it, hand the wrapping renderer to your adapter and set the notification's body_template to the template key instead of a path:

from vintasend.services.notification_service import NotificationService

notification_service = NotificationService(
    notification_adapters=[MyEmailAdapter(template_renderer=renderer, backend=notification_backend)],
    notification_backend=notification_backend,
)

notification_service.create_notification(
    user_id=user.id,
    notification_type="EMAIL",
    title="Welcome",
    body_template="welcome",   # a managed template key, not a file path
    context_name="welcome_context",
    context_kwargs={"user_id": user.id},
    send_after=None,
    subject_template="",
    preheader_template="",
)

Pass the renderer as a live instance rather than as a dotted import string: it takes a backend and an inner renderer as constructor arguments, which a string path cannot supply.

Nothing else about creating or sending notifications changes. A send renders the key's newest active version -- see Which version a send renders -- so a template has to be activated before it goes out.

Sending before a template is written: fallbacks

An application can register a default for each key, so it can send a notification before anyone has written its template in the store. While a key has nothing published, render hands the default to a renderer of your choosing. Once a version of the key is activated, sends use it.

from vintasend_managed_templates.managed_template_renderer import (
    FallbackTemplate,
    ManagedTemplateEmailRenderer,
    ManagedTemplateFallback,
)

renderer = ManagedTemplateEmailRenderer(
    manager_backend,
    inner_renderer,
    fallback=ManagedTemplateFallback(
        templates={
            "welcome": FallbackTemplate(
                subject_template="emails/welcome/subject.txt",   # file paths, for a file renderer
                body_template="emails/welcome/body.html",
                preheader_template=None,                         # optional
            ),
        },
        # Optional. Defaults to the inner renderer, which takes template *source* -- so register
        # source rather than paths when you leave this out.
        renderer=DjangoTemplatedEmailRenderer(),
    ),
)

renderer.get_fallback_template("welcome")   # the registered default, or None -- for a dashboard

The fallback renderer's render gets a copy of the notification (dataclasses.replace) with body_template, subject_template and preheader_template replaced by the registered values; a None becomes "". The SMS renderer takes the same option. The rules:

  • Only when the key itself has nothing published: no versions at all, or only drafts and retired ones (the lookup raised ManagedTemplateNotFoundError, which includes ManagedTemplateNoActiveVersionError). A stored template that fails to compose because it extends a missing base raises ManagedTemplateCompositionReferenceError instead. That template exists and is broken, and a default must not hide it.
  • Only for an unpinned notification. If a pinned version is missing, the template existed when the notification was created, so the render raises.
  • Only for a registered key. The templates are copied into a plain dict when the renderer is built, so a mapping with a default for missing keys (a defaultdict, say) never answers for a key nobody registered.
  • Only on render, the send path. ManagedTemplateService.render and render_template (what previews use) never fall back, and get_latest_template_version still answers None for a key with nothing published.

A fallback render leaves the send input's template_version as None, so the notification's used_template_version stays null. A render of a stored template always reports its version, so a sent notification with a null used_template_version went out with the fallback. That is how a host tells the two apart; NotificationSendInput has no field for the source. The renderer logs the key and the notification id when it falls back, at INFO on the vintasend_managed_templates.managed_template_renderer logger, and never the context.

What the inner renderer has to do

ManagedTemplateRenderer looks the template up, builds an EmailTemplateContent (or a TemplateContent for SMS) out of the stored strings, and calls the inner renderer's render_from_template_content. So the inner renderer receives template source in the body_template field, where it normally expects a name a loader can resolve.

Renderers that resolve names through a loader — JinjaTemplatedEmailRenderer, DjangoTemplatedEmailRenderer — need a loader that will accept source. For Jinja that is one line:

from jinja2 import Environment, FunctionLoader
from vintasend_jinja.services.notification_template_renderers.jinja_templated_email_renderer import (
    JinjaTemplatedEmailRenderer,
)

inner_renderer = JinjaTemplatedEmailRenderer(Environment(loader=FunctionLoader(lambda source: source)))

A renderer written to compile source directly needs no such setup.

Composition: bases, blocks and includes

A file-based renderer gets composition for free. Django's {% extends %} and Jinja's {% include %} hand a name to a loader, and a loader reads files — so the header, the footer and the wrapper every email shares live in one file that every other file points at.

Managed templates are not files. They reach the engine as source, so a loader has nothing to resolve and those tags have nothing to load. Without composition the shared chrome would have to be pasted into every row in the store, and changing the footer would mean editing all of them.

This package resolves its own set of tags before the engine sees anything. What the engine receives is one flat string with no managed_* tag left in it; its own syntax is untouched.

service.create_template(ManagedTemplateCreateInput(
    name="Base email", description="The wrapper every email uses", key="base-email",
    template_managed_backend="django",
    template_body=(
        "<html>\n"
        "  <body>\n"
        "    {% managed_block header %}<h1>Acme</h1>{% managed_endblock %}\n"
        "    {% managed_children %}\n"
        "    {% managed_include \"footer\" %}\n"
        "  </body>\n"
        "</html>"
    ),
    template_subject="[Acme] {% managed_children %}",
    template_preheader=None, tenant=None,
))

service.create_template(ManagedTemplateCreateInput(
    name="Welcome email", description="Sent right after signup", key="welcome",
    template_managed_backend="django",
    template_body=(
        "{% managed_extends \"base-email\" %}\n"
        "{% managed_block header %}<h1>Welcome!</h1>{% managed_endblock %}\n"
        "<p>Hi {{ name }}, welcome aboard.</p>"
    ),
    template_subject="{% managed_extends \"base-email\" %}Welcome aboard",
    template_preheader=None, tenant=None,
))

welcome now renders inside the base, with its own header and the shared footer, and its subject comes out as [Acme] Welcome aboard. {{ name }} is never looked at — the context is the engine's business.

The tags

Tag What it does
{% managed_extends "key" %} This template is a child of key. At most one per template, never inside a block. Pin the parent with version=2.
{% managed_children %} In a base: where the child's content goes. Rendered with no child, the hole is simply empty.
{% managed_block name %}…{% managed_endblock %} A named region a child may replace. Unreplaced, it renders what it was declared with. Blocks may nest.
{% managed_super %} Inside a child's block: the content it is overriding. Chains through as many levels of inheritance as there are.
{% managed_include "key" %} Splice another template in here. It is composed in full first, so an include may itself extend and include. Pins the same way: version=7.

Everything a child writes outside a block is its children content, and it lands in the base's {% managed_children %}. So a child can both fill the hole and override named regions — which is the one difference from Django, where content outside a block in a child template is discarded.

The managed_ prefix is reserved: an unknown {% managed_something %} is an error rather than text passed through, so a typo surfaces at edit time instead of shipping. Change the prefix by handing the renderer or the service its own composer:

from vintasend_managed_templates.composition import TemplateComposer

composer = TemplateComposer.from_backend(manager_backend, tag_prefix="tpl_")
renderer = ManagedTemplateEmailRenderer(manager_backend, inner_renderer, composer=composer)

One field at a time

A template carries three sources — body, subject and preheader — and each composes against the same field of the template it references. A child's body extends the base's body; its subject extends the base's subject. So a base can define a subject prefix and a body wrapper at once, and neither leaks into the other. A field the base leaves empty composes to nothing rather than to an error.

Whitespace

A structural tag (extends, block, endblock) alone on its line is taken out with the line, so a layout written across several lines does not compose into one padded with blank ones. The placeholder tags (children, include, super) are never line-trimmed: what replaces them lands exactly where the tag stood, indentation and all.

Abstract templates

A template is abstract when it declares a {% managed_children %} hole, or declares blocks without extending anything — a layout meant to be built on rather than sent. That is a fact about the source, so it follows the template as it is edited: a template becomes abstract the moment someone writes the hole into it and stops being abstract the moment they take it out.

The check recomputes from the source every time, which makes it the authority:

service.is_abstract(base)      # True
service.is_abstract(welcome)   # False

The flag is that same answer, denormalized onto the template so it can be queried:

base.is_abstract                                          # True -- stored, not recomputed
service.get_filtered_templates({"is_abstract": False})    # every sendable template

Filtering is the reason the flag exists. Without it, a picker that has to leave the bases out would read and parse every row in the store to draw one page. Nobody writes the flag — there is no field for it on either write input — because a stored copy that disagreed with the source would be a lie a filter goes on repeating. It is a backend's job to derive it on every write with composition.is_abstract; see Implementing a manager backend.

Reach for the check when the flag cannot be trusted: a template edited in memory since it was read, or one written before its backend maintained the column.

Composing an abstract template directly is allowed and gives you the layout with an empty hole. Neither the check nor the flag refuses anything — keeping bases out of a picker is the host's call.

Versions

A reference with no version resolves the same way any other read does: to whatever version that key currently is. Pin it when a template must keep composing against an exact parent — re-rendering an old notification resolves the child's version explicitly, but its unpinned bases still resolve to today's.

version=N is the only spelling, on both tags that take a reference:

{% managed_extends "base-email" version=2 %}
{% managed_include "footer" version=7 %}

Nothing inside the quoted key is interpreted, so a key is only ever a key — a template genuinely named base-email[v2] is referenced exactly as written, with no escaping and no special case.

Checking a template before it ships

Composition failures are this package's, not the engine's, so nothing downstream can report them. Catch them where someone can still fix them:

service.validate_composition(template)     # raises exactly what rendering would have
service.get_composed_template("welcome")   # what the engine will actually receive
service.get_template_references(template)  # the bases and fragments it names, unresolved
Exception Raised when
ManagedTemplateCompositionSyntaxError A tag is malformed, unknown, or unbalanced
ManagedTemplateCompositionReferenceError A base or fragment does not exist (also a ManagedTemplateNotFoundError)
ManagedTemplateCompositionCycleError The references loop
ManagedTemplateCompositionDepthError The chain runs past the composer's max_depth (25 by default)

All four subclass ManagedTemplateCompositionError.

Turning it off

Composition is on by default. A store that predates it and holds managed_-prefixed text meant to reach the engine verbatim can opt out:

renderer = ManagedTemplateEmailRenderer(manager_backend, inner_renderer, compose_templates=False)
service = ManagedTemplateService(manager_backend, renderer, compose_templates=False)

Reads are never composed either way: get_template hands back exactly what is stored, which is what an editing UI needs. get_composed_template is the explicit way to ask for the assembled form.

Templates and versions

Templates are versioned, never edited in place. update_template copies the latest version forward, applies the non-None fields of the input, and returns the new version — so a published version's body can never change under a notification that already referenced it.

from vintasend_managed_templates.dataclasses import ManagedTemplateUpdateInput

service.update_template("welcome", ManagedTemplateUpdateInput(
    name=None,                       # None leaves the field as the previous version had it
    description=None,
    template_body="<p>Hi {{ name }}, welcome aboard!</p>",
    template_subject=None,
    template_preheader=None,
    tags=None,                       # None carries tags forward; [] clears them
))

service.get_template("welcome")            # latest version, whatever its status
service.get_template("welcome", version=1) # a specific one
service.get_active_template("welcome")     # what an unpinned send renders
service.get_template_versions("welcome")   # every version, newest first

version=None means "the latest version of this key" for reads, status changes and tagging -- the editing view, which includes a draft someone is working on. Sends are the exception, below.

Which version a send renders

"Latest" means two things, and they are kept apart:

Resolves to Used by
The editing view the newest version, whatever its status get_template(key), status changes, tagging, the API's reads
The send path the newest ACTIVE version render on an unpinned notification, service.render with no version and no pin, get_latest_template_version

A draft is never sent: publishing is the deliberate act that puts a version in front of recipients. When several versions of a key are active at once, the highest-numbered active one renders. A key with no active version raises ManagedTemplateNoActiveVersionError, a subclass of ManagedTemplateNotFoundError, so a key holding only drafts counts as "not customized yet" and a registered fallback applies.

A notification pinned to a version renders that version whatever its status today. The pin exists so a notification renders what was reviewed when it was created, so deactivating or archiving the version later does not change what a pinned notification renders.

Deleting a version

Only a version that was never published can be deleted: one still in DRAFT whose status history records nothing but DRAFT. Anything else raises ManagedTemplateDeletionNotAllowedError. A published version may have rendered a notification that is pinned to it, and its status history records who published it. Retire it with archive instead.

service.delete_template("welcome", version=3)   # fine while v3 is an unpublished draft
service.delete_template("welcome", version=1)   # raises once v1 has been activated
service.delete_template("welcome")              # the latest version, under the same rule

The status history is never deleted, even when the version is. The service resolves the version, checks the rule, and then deletes that exact version, so every backend is held to it; the Django backend checks it itself too. If an operator genuinely needs to hard-delete a published version, pass allow_deleting_published_versions=True to both the service and the backend. lifecycle.is_template_version_deletable(template, history) answers the question without attempting the delete.

Statuses

A version moves through DRAFT → ACTIVE → INACTIVE → ARCHIVED, and every move is written to the backend's audit trail:

service.activate("welcome", changed_by="hugo@example.com")
service.deactivate("welcome")
service.archive("welcome", version=1)
service.get_status_history("welcome")      # newest change first
service.can_transition_to(template, ManagedTemplateStatus.ACTIVE)

The default transition table:

From May move to
DRAFT ACTIVE, ARCHIVED
ACTIVE INACTIVE, ARCHIVED
INACTIVE ACTIVE, ARCHIVED
ARCHIVED — terminal

Anything else raises ManagedTemplateStatusTransitionError. Setting a version to the status it already holds is a no-op: no history entry, no error. Override ALLOWED_STATUS_TRANSITIONS on a subclass for a different lifecycle, or pass validate_status_transitions=False to leave the ordering entirely to your application.

Two things the service deliberately does not decide for you:

  • A key may have several ACTIVE versions at once. Activating one does not deactivate the others. An unpinned send renders the highest-numbered active one, so activating an older version while a newer one is active does not change what is sent.
  • changed_by is passed through untouched, None included. Attribution is never required.

Tags

Tags are many-to-many with template versions and are identified by a slug derived from the text someone typed. Slugging lives in vintasend_managed_templates.tags rather than in a backend, so a Django store and a SQLAlchemy store agree on what Promoção slugs to. Every call that takes a slug also accepts the original text.

service.add_template_tags("welcome", ["Black Friday"])   # creates the tag if it is new
service.remove_template_tags("welcome", ["black-friday"])
service.set_template_tags("welcome", ["onboarding"])     # replaces; [] clears
service.get_templates_by_tags(["onboarding", "email"], match_all=False)
service.get_active_tags()                                # what a tag picker should show

Retagging edits the version in place instead of creating one. Tags are how a template is found, not part of what it renders, so relabelling for findability does not spawn a version and drop it back to DRAFT.

Archiving a tag (archive_tag / restore_tag) takes it out of the pickers but keeps every link: filtering by an archived tag still returns the templates carrying it. delete_tag is the irreversible one — it removes the label from the templates too.

Text with nothing sluggable in it (" ", "!!!") raises ManagedTemplateInvalidTagError at the call site, rather than becoming a tag no filter can ever name.

Filtering and pagination

Filters are plain dicts, typed by the TypedDicts in filters.py, and compose with and / or / not:

service.get_filtered_templates({
    "and": [
        {"status": {"lookup": "in", "value": [ManagedTemplateStatus.ACTIVE]}},
        {"name": {"lookup": "includes", "value": "welcome", "case_sensitive": False}},
        {"includes_any_of_tags": ["onboarding", "transactional"]},
        {"created_at_range": {"from": datetime(2026, 1, 1)}},
    ]
})

service.get_paginated_filtered_templates(filters, page=1, page_size=20)  # page is 1-indexed

Fields: name, description, key, version, template_managed_backend, status, created_at_range, updated_at_range, includes_all_tags, includes_any_of_tags, most_recent_active_version. String lookups are exact / starts_with / ends_with / includes; numeric ones are gt / gte / lt / lte.

One row per key: most_recent_active_version

The store holds a row per version, so an unfiltered read shows a template once for every version it has ever had. most_recent_active_version collapses that to one row per key — the highest-numbered ACTIVE or DRAFT version, which is what is live plus the draft on its way to replacing it. A key whose versions are all INACTIVE or ARCHIVED has no current version and drops out.

service.get_all_templates()                          # one row per key — the current version
service.get_all_templates(include_all_versions=True) # every version of every key
service.get_paginated_templates(page=1, page_size=20)              # same default
service.get_filtered_templates({"most_recent_active_version": True})  # the filter itself

The two listing methods apply it by default; pass include_all_versions=True for the raw read. get_filtered_templates and get_paginated_filtered_templates do not add it — a filter means what it says — so name the field yourself when a filtered listing should be one row per key too. False is the exact complement (every other row, retired keys included), the same set {"not": {"most_recent_active_version": True}} returns.

Unlike every other field, this one is answered against the whole key rather than against the row being tested, so a backend evaluates it with a subquery over the key's other versions.

validate_filter runs before every filtered read and raises ManagedTemplateInvalidFilterError for a typo'd field name, an and/or that is not a non-empty list, a logical group with sibling keys, a tag filter given as a bare string (which would otherwise be iterated character by character and silently match nothing), or a non-boolean most_recent_active_version (the string "false" is truthy, so it would ask for exactly what the caller meant to switch off). Lookup values stay the backend's authority.

The empty-collection rules follow Python's own all() / any(): an empty includes_all_tags constrains nothing, an empty includes_any_of_tags matches nothing.

What the backend can answer: capabilities

Not every store can answer every filter, and a backend says which it cannot:

service.get_backend_supported_filter_capabilities()
# {'logical.and': True, ..., 'fields.includesAllTags': False, ..., 'orderBy.createdAt': True}

Keys are camelCase dotted — fields.templateManagedBackend, orderBy.createdAt — even though the Python field names are snake_case, because a report goes on the wire and is spelled identically in the TypeScript sibling. A backend declares only what it cannot do, and the service merges its report over DEFAULT_TEMPLATE_BACKEND_FILTER_CAPABILITIES, so a filter added in a later release does not force every backend to re-declare it.

The service acts on the report. Before every filtered read, the parts the backend has declined are pruned away — a capability map nothing acts on is decoration, and every caller left to walk it themselves would reach a slightly different conclusion.

Dropping only ever widens: a pruned filter matches everything the original matched and possibly more, so you see extra rows rather than missing ones. Every corner follows from that one rule — an or is dropped whole (removing a branch of a disjunction narrows it), and a not whose inside pruned away is dropped (negating "everything" is "nothing"). A bare string counts as exact and case-sensitive, so a backend lacking either cannot answer it.

Ordering

get_paginated_templates and get_paginated_filtered_templates take an optional order_by:

service.get_paginated_templates(
    page=1, page_size=20, order_by={"field": "name", "direction": "asc"}
)

Orderable fields are key, name, version, status, created_at and updated_at — each a scalar a backend already stores per row, so a store can answer it from an index. Tags are absent because ordering by a many-to-many has no single value to compare, and most_recent_active_version because it is a filter rather than a field.

Ask before you send. Every orderBy.* capability defaults to False, so a backend that predates ordering — or simply cannot sort — reports nothing orderable:

service.get_supported_order_by_fields()   # e.g. ['key', 'name', 'created_at']

Filters are dropped; orders are refused. An order the backend cannot apply raises ManagedTemplateUnsupportedOrderingError rather than being silently ignored:

Unsupported filter Unsupported order
What happens pruned away, the call succeeds raises
If it were ignored more rows than you asked for the same rows, arbitrary sequence
Can you tell? yes — visible in the rows no

sort_templates(templates, order_by) is the shared comparator, for a backend that reads a complete set anyway. It is total and stable: ties break on (key, version), and neither the tiebreak nor the placement of absent values flips with the direction — either would let a page boundary move between two requests and drop or repeat a row. Strings compare by code point rather than by locale, so two machines serving two pages of one listing cannot disagree.

Rendering a specific version

Which version of a template a notification renders is decided in this order:

  1. an explicit version= argument to service.render(),
  2. the notification's own requested_template_version,
  3. the key's newest active version.
service.render(notification, context)                 # the notification's pin, or the newest active
service.render(notification, context, version=3)      # preview v3 regardless of the pin
service.render_template(notification, template, context)   # a template already in hand

The argument is for rendering a version the notification is not pinned to -- previewing an unpublished draft, or reproducing what an old notification looked like. Leave it off and you get what a real send would produce, except that a renderer's registered fallback is never used: a key with nothing published raises.

Pinning a notification to a version

ManagedTemplateRenderer reads requested_template_version off the notification, so a notification recorded against v3 renders v3 however many versions follow. That field is vintasend's, not this package's -- see Template Version Pinning -- and this package is what makes it mean anything:

notification_service.create_notification(
    ...,
    body_template="welcome",           # the template key
    requested_template_version=3,      # render v3, now and forever
)

# Or pin to the newest active version at this moment, without naming it:
notification_service.create_notification(..., pin_template_versions=True)

# The same, as the default for every call on the service:
notification_service = NotificationService(..., pin_template_versions=True)

With pin_template_versions=True, the service asks this package's renderer which version to pin through get_latest_template_version(), which answers the key's newest active version: the same version an unpinned send would render, so a draft is never pinned. A key with nothing published answers None and the notification is created unpinned, rather than failing the creation over a template that may well be published by the time it is sent.

Whichever version renders is reported back on the send input as template_version, so vintasend can store it as used_template_version. For an unpinned notification that is the only record of which version went out, since the template has moved on by the time anyone asks.

Implementing a manager backend

Subclass BaseTemplateManagerBackend and implement every abstract method. It splits into four groups:

  • Versions — create_template, get_template, update_template, delete_template
  • Statuses — create_template_status_update, get_template_status_history
  • Tags — get_or_create_tags, create_tag, get_tag, update_tag, set_tag_status, delete_tag, get_tags, get_template_tags, set_template_tags
  • Queries — get_all_templates, get_templates_by_status, get_filtered_templates, get_paginated_templates, get_paginated_filtered_templates

The two paginated methods take an optional order_by, and get_filter_capabilities is concrete rather than abstract, so neither breaks a backend written before they existed. See Ordering.

get_active_template -- the newest ACTIVE version, for the send path -- is concrete too. Leave it out and it is answered with get_filtered_templates({"key": key, "status": ACTIVE}). Override it when your store can answer more cheaply, picking the version with lifecycle.newest_active_version and raising lifecycle.no_active_version(key) for a key that exists but has no active version, so every backend applies the same rule.

delete_template refuses a published version and keeps the status history. Call lifecycle.assert_template_version_deletable before deleting, put any hard delete of a published version behind an option that is off by default, and keep each history entry readable through get_template_status_history after its version is gone. Since history is read by key and version, never reuse a deleted version's number: number a new version one above the highest the key has ever had, history included.

get_filter_capabilities is concrete rather than abstract and returns {}, so a backend that declares nothing keeps working and reads as fully capable of every filter. Ordering is the exception: those keys default to False, so a backend that can sort has to say so —

def get_filter_capabilities(self) -> dict[str, bool]:
    return {
        "logical.or": False,          # this store ANDs its query parameters
        "orderBy.key": True,
        "orderBy.createdAt": True,    # declare only what the store genuinely does
    }

Verify each ordering claim by running the sort rather than reading your store's documentation: a store keeping versions as strings sorts 10 before 2 and looks correct until a key reaches its tenth version. A backend that accepts order_by must apply it to the whole result set before paging — sorting a page after it has been chosen orders rows within the page while the rows selected for it came back in the store's own order, which is right on page 1 and wrong on every page after.

What a backend owns, beyond storage: assigning version numbers, deriving tag slugs with slugify_tag and keeping them unique with next_available_slug, deriving is_abstract with composition.is_abstract on every write that touches a source, and translating the filter dicts into its own query language.

is_abstract is the one easy to miss, because no method is named for it. It is a denormalization of the template's own source, kept so the is_abstract filter can be a column lookup instead of a full-store parse, and neither write input carries it. A backend that never sets it reports every template as concrete and that filter quietly stops working.

tests/fakes.py in this repo has InMemoryTemplateManagerBackend, a complete, dependency-free implementation of the seam — the shortest readable reference for what each method owes its caller. The suite drives it end to end rather than mocking it, so it is a real implementation, not a stub.

Exceptions

All of them subclass ManagedTemplateError:

Exception Raised when
ManagedTemplateNotFoundError The key, or that version of it, does not exist
ManagedTemplateNoActiveVersionError A send resolved a key that exists but has no ACTIVE version. Subclasses ManagedTemplateNotFoundError
ManagedTemplateDeletionNotAllowedError Deleting a version that has been published -- archive it instead
ManagedTemplateInvalidFilterError A filter is malformed or names an unknown field
ManagedTemplateStatusTransitionError The status move is not allowed from the current status
ManagedTemplateChangeUserNotFoundError An update names a changed_by user that does not exist
ManagedTemplateTagNotFoundError No tag has that slug
ManagedTemplateTagAlreadyExistsError create_tag collides with an existing slug
ManagedTemplateInvalidTagError A tag's text has nothing that can be slugified
ManagedTemplateCompositionError A template could not be assembled -- see Composition for its four subclasses

Development

poetry install
poetry run pytest          # coverage is on by default and fails the run below 90%
poetry run ruff check
poetry run mypy
poetry run tox             # the full 3.10–3.14 matrix

The suite runs fully offline against the in-memory backend — no database, no services.

Metadata

Release files for vintasend-managed-templates 3.4.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for vintasend-managed-templates 3.4.0
File Size Uploaded
vintasend_managed_templates-3.4.0.tar.gz 70.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for vintasend-managed-templates 3.4.0
File Interpreter ABI Platform
vintasend_managed_templates-3.4.0-py3-none-any.whl Python 3 none any Details

Total release size: 134.9 kB

Release files / vintasend_managed_templates-3.4.0.tar.gz

Download URL vintasend_managed_templates-3.4.0.tar.gz
Size 70.2 kB
Tags Source
SHA-256 checksum
How to use checksums
17a15903ac2563b879f1acbf7e81c1a032cce25578b2909bd970790dd272cf66
BLAKE2b-256 checksum
How to use checksums
587b63981e48af87fcc391bfb312c33eddc96dead6840a4e99d560ac5bbe8d62
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

Release files / vintasend_managed_templates-3.4.0-py3-none-any.whl

Download URL vintasend_managed_templates-3.4.0-py3-none-any.whl
Size 64.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
5bc31dc68467f4e7e41a61b73225aba757435065f0e1cde7f2e01779e2e0808d
BLAKE2b-256 checksum
How to use checksums
4047359d0891d09604a5143ac9c649ec863a28ff8d2ae2f92be966fa4c16baff
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

Release history Release notifications | RSS feed

3.5.0

2 release files

This release

3.4.0 This release

2 release files

3.3.0

2 release files

3.2.0

2 release files

3.1.1

2 release files

3.1.0

2 release files

3.0.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page