vintasend-managed-templates 3.0.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.
- vintasend_managed_templates-3.0.0/PKG-INFO +556 -0
- vintasend_managed_templates-3.0.0/README.md +537 -0
- vintasend_managed_templates-3.0.0/pyproject.toml +143 -0
- vintasend_managed_templates-3.0.0/vintasend_managed_templates/__init__.py +0 -0
- vintasend_managed_templates-3.0.0/vintasend_managed_templates/base_template_manager_backend.py +343 -0
- vintasend_managed_templates-3.0.0/vintasend_managed_templates/composition.py +833 -0
- vintasend_managed_templates-3.0.0/vintasend_managed_templates/constants.py +30 -0
- vintasend_managed_templates-3.0.0/vintasend_managed_templates/dataclasses.py +90 -0
- vintasend_managed_templates-3.0.0/vintasend_managed_templates/exceptions.py +88 -0
- vintasend_managed_templates-3.0.0/vintasend_managed_templates/filters.py +263 -0
- vintasend_managed_templates-3.0.0/vintasend_managed_templates/managed_template_renderer.py +157 -0
- vintasend_managed_templates-3.0.0/vintasend_managed_templates/managed_template_service.py +1024 -0
- vintasend_managed_templates-3.0.0/vintasend_managed_templates/py.typed +0 -0
- vintasend_managed_templates-3.0.0/vintasend_managed_templates/tags.py +102 -0
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vintasend-managed-templates
|
|
3
|
+
Version: 3.0.0
|
|
4
|
+
Summary: Starting point for a new vintasend-* implementation package: one TODO stub per seam, ready to clone.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Author: Hugo Bessa
|
|
7
|
+
Author-email: hugo@vinta.com.br
|
|
8
|
+
Requires-Python: >=3.10,<3.15
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Requires-Dist: typing-extensions (>=4.10.0)
|
|
16
|
+
Requires-Dist: vintasend (==3.0.0)
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# vintasend-managed-templates
|
|
20
|
+
|
|
21
|
+
Database-backed notification templates for
|
|
22
|
+
[vintasend](https://github.com/vintasoftware/vintasend): versioning, a
|
|
23
|
+
draft/active/inactive/archived lifecycle with an audit trail, tags, and filtering — all on top of
|
|
24
|
+
a storage seam you (or a ready-made package) implement.
|
|
25
|
+
|
|
26
|
+
A regular vintasend template renderer reads templates from wherever its engine looks, which is
|
|
27
|
+
usually files on disk. That means every copy change is a deploy. This package moves the templates
|
|
28
|
+
into a data store so someone who is not a developer can edit them, keeps every edit as a new
|
|
29
|
+
version, and lets you publish a version deliberately instead of the moment it is saved.
|
|
30
|
+
|
|
31
|
+
It is storage-agnostic on its own — it defines the interface, not the database. Pair it with a
|
|
32
|
+
manager backend such as
|
|
33
|
+
[vintasend-django-templates-manager](https://github.com/vintasoftware/vintasend-django-templates-manager/),
|
|
34
|
+
or implement `BaseTemplateManagerBackend` yourself.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
poetry add vintasend-managed-templates
|
|
40
|
+
# or
|
|
41
|
+
pip install vintasend-managed-templates
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Python 3.10–3.14. The only dependencies are `vintasend` itself and `typing-extensions`.
|
|
45
|
+
|
|
46
|
+
## The pieces
|
|
47
|
+
|
|
48
|
+
| Piece | What it is |
|
|
49
|
+
|---|---|
|
|
50
|
+
| `BaseTemplateManagerBackend` | The storage seam. An ABC covering template CRUD, versions, status history, tags, filtering, and pagination. |
|
|
51
|
+
| `ManagedTemplateService` | The API you call. Wraps a backend and a renderer with version resolution, status-transition rules, filter validation, and tag normalization. |
|
|
52
|
+
| `ManagedTemplateEmailRenderer` / `ManagedTemplateSMSRenderer` | A vintasend template renderer that wraps *another* renderer and feeds it a stored template instead of a template path. |
|
|
53
|
+
| `composition.TemplateComposer` | Resolves template inheritance and inclusion against the store, before the engine runs. |
|
|
54
|
+
| `tags.slugify_tag` / `next_available_slug` | The shared slug rules, so every backend derives the same slug from the same text. |
|
|
55
|
+
| `dataclasses`, `constants`, `filters`, `exceptions` | The wire types: `ManagedTemplate`, `ManagedTemplateTag`, the two status enums, the filter TypedDicts, and the error hierarchy. |
|
|
56
|
+
|
|
57
|
+
Everything here is synchronous. There is no AsyncIO twin, because the seams it composes
|
|
58
|
+
(`BaseTemplateManagerBackend` and vintasend's template renderer seam) are both synchronous.
|
|
59
|
+
|
|
60
|
+
## Quick start
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from vintasend_managed_templates.dataclasses import ManagedTemplateCreateInput
|
|
64
|
+
from vintasend_managed_templates.managed_template_renderer import ManagedTemplateEmailRenderer
|
|
65
|
+
from vintasend_managed_templates.managed_template_service import ManagedTemplateService
|
|
66
|
+
|
|
67
|
+
manager_backend = MyTemplateManagerBackend() # any BaseTemplateManagerBackend
|
|
68
|
+
renderer = ManagedTemplateEmailRenderer(
|
|
69
|
+
manager_backend,
|
|
70
|
+
inner_renderer, # any vintasend email renderer
|
|
71
|
+
)
|
|
72
|
+
service = ManagedTemplateService(manager_backend, renderer)
|
|
73
|
+
|
|
74
|
+
template = service.create_template(
|
|
75
|
+
ManagedTemplateCreateInput(
|
|
76
|
+
name="Welcome email",
|
|
77
|
+
description="Sent right after signup",
|
|
78
|
+
key="welcome", # what notifications reference
|
|
79
|
+
template_managed_backend="django", # which manager backend stores it
|
|
80
|
+
template_body="<p>Hi {{ name }}, welcome!</p>",
|
|
81
|
+
template_subject="Welcome aboard",
|
|
82
|
+
template_preheader=None,
|
|
83
|
+
tenant=None,
|
|
84
|
+
tags=["onboarding", "Black Friday"],
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
service.activate("welcome", changed_by="hugo@example.com")
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
To send through it, hand the wrapping renderer to your adapter and set the notification's
|
|
92
|
+
`body_template` to the template **key** instead of a path:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from vintasend.services.notification_service import NotificationService
|
|
96
|
+
|
|
97
|
+
notification_service = NotificationService(
|
|
98
|
+
notification_adapters=[MyEmailAdapter(template_renderer=renderer, backend=notification_backend)],
|
|
99
|
+
notification_backend=notification_backend,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
notification_service.create_notification(
|
|
103
|
+
user_id=user.id,
|
|
104
|
+
notification_type="EMAIL",
|
|
105
|
+
title="Welcome",
|
|
106
|
+
body_template="welcome", # a managed template key, not a file path
|
|
107
|
+
context_name="welcome_context",
|
|
108
|
+
context_kwargs={"user_id": user.id},
|
|
109
|
+
send_after=None,
|
|
110
|
+
subject_template="",
|
|
111
|
+
preheader_template="",
|
|
112
|
+
)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Pass the renderer as a live instance rather than as a dotted import string: it takes a backend and
|
|
116
|
+
an inner renderer as constructor arguments, which a string path cannot supply.
|
|
117
|
+
|
|
118
|
+
Nothing else about creating or sending notifications changes.
|
|
119
|
+
|
|
120
|
+
### What the inner renderer has to do
|
|
121
|
+
|
|
122
|
+
`ManagedTemplateRenderer` looks the template up, builds an `EmailTemplateContent` (or a
|
|
123
|
+
`TemplateContent` for SMS) out of the stored strings, and calls the inner renderer's
|
|
124
|
+
`render_from_template_content`. So the inner renderer receives **template source in the
|
|
125
|
+
`body_template` field**, where it normally expects a name a loader can resolve.
|
|
126
|
+
|
|
127
|
+
Renderers that resolve names through a loader — `JinjaTemplatedEmailRenderer`,
|
|
128
|
+
`DjangoTemplatedEmailRenderer` — need a loader that will accept source. For Jinja that is one
|
|
129
|
+
line:
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
from jinja2 import Environment, FunctionLoader
|
|
133
|
+
from vintasend_jinja.services.notification_template_renderers.jinja_templated_email_renderer import (
|
|
134
|
+
JinjaTemplatedEmailRenderer,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
inner_renderer = JinjaTemplatedEmailRenderer(Environment(loader=FunctionLoader(lambda source: source)))
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
A renderer written to compile source directly needs no such setup.
|
|
141
|
+
|
|
142
|
+
## Composition: bases, blocks and includes
|
|
143
|
+
|
|
144
|
+
A file-based renderer gets composition for free. Django's `{% extends %}` and Jinja's
|
|
145
|
+
`{% include %}` hand a *name* to a loader, and a loader reads files — so the header, the footer and
|
|
146
|
+
the wrapper every email shares live in one file that every other file points at.
|
|
147
|
+
|
|
148
|
+
Managed templates are not files. They reach the engine as source, so a loader has nothing to
|
|
149
|
+
resolve and those tags have nothing to load. Without composition the shared chrome would have to be
|
|
150
|
+
pasted into every row in the store, and changing the footer would mean editing all of them.
|
|
151
|
+
|
|
152
|
+
This package resolves its own set of tags **before** the engine sees anything. What the engine
|
|
153
|
+
receives is one flat string with no `managed_*` tag left in it; its own syntax is untouched.
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
service.create_template(ManagedTemplateCreateInput(
|
|
157
|
+
name="Base email", description="The wrapper every email uses", key="base-email",
|
|
158
|
+
template_managed_backend="django",
|
|
159
|
+
template_body=(
|
|
160
|
+
"<html>\n"
|
|
161
|
+
" <body>\n"
|
|
162
|
+
" {% managed_block header %}<h1>Acme</h1>{% managed_endblock %}\n"
|
|
163
|
+
" {% managed_children %}\n"
|
|
164
|
+
" {% managed_include \"footer\" %}\n"
|
|
165
|
+
" </body>\n"
|
|
166
|
+
"</html>"
|
|
167
|
+
),
|
|
168
|
+
template_subject="[Acme] {% managed_children %}",
|
|
169
|
+
template_preheader=None, tenant=None,
|
|
170
|
+
))
|
|
171
|
+
|
|
172
|
+
service.create_template(ManagedTemplateCreateInput(
|
|
173
|
+
name="Welcome email", description="Sent right after signup", key="welcome",
|
|
174
|
+
template_managed_backend="django",
|
|
175
|
+
template_body=(
|
|
176
|
+
"{% managed_extends \"base-email\" %}\n"
|
|
177
|
+
"{% managed_block header %}<h1>Welcome!</h1>{% managed_endblock %}\n"
|
|
178
|
+
"<p>Hi {{ name }}, welcome aboard.</p>"
|
|
179
|
+
),
|
|
180
|
+
template_subject="{% managed_extends \"base-email\" %}Welcome aboard",
|
|
181
|
+
template_preheader=None, tenant=None,
|
|
182
|
+
))
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
`welcome` now renders inside the base, with its own header and the shared footer, and its subject
|
|
186
|
+
comes out as `[Acme] Welcome aboard`. `{{ name }}` is never looked at — the context is the engine's
|
|
187
|
+
business.
|
|
188
|
+
|
|
189
|
+
### The tags
|
|
190
|
+
|
|
191
|
+
| Tag | What it does |
|
|
192
|
+
|---|---|
|
|
193
|
+
| `{% 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`. |
|
|
194
|
+
| `{% managed_children %}` | In a base: where the child's content goes. Rendered with no child, the hole is simply empty. |
|
|
195
|
+
| `{% managed_block name %}…{% managed_endblock %}` | A named region a child may replace. Unreplaced, it renders what it was declared with. Blocks may nest. |
|
|
196
|
+
| `{% managed_super %}` | Inside a child's block: the content it is overriding. Chains through as many levels of inheritance as there are. |
|
|
197
|
+
| `{% 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`. |
|
|
198
|
+
|
|
199
|
+
Everything a child writes **outside** a block is its children content, and it lands in the base's
|
|
200
|
+
`{% managed_children %}`. So a child can both fill the hole and override named regions — which is
|
|
201
|
+
the one difference from Django, where content outside a block in a child template is discarded.
|
|
202
|
+
|
|
203
|
+
The `managed_` prefix is reserved: an unknown `{% managed_something %}` is an error rather than
|
|
204
|
+
text passed through, so a typo surfaces at edit time instead of shipping. Change the prefix by
|
|
205
|
+
handing the renderer or the service its own composer:
|
|
206
|
+
|
|
207
|
+
```python
|
|
208
|
+
from vintasend_managed_templates.composition import TemplateComposer
|
|
209
|
+
|
|
210
|
+
composer = TemplateComposer.from_backend(manager_backend, tag_prefix="tpl_")
|
|
211
|
+
renderer = ManagedTemplateEmailRenderer(manager_backend, inner_renderer, composer=composer)
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
### One field at a time
|
|
215
|
+
|
|
216
|
+
A template carries three sources — body, subject and preheader — and each composes against the
|
|
217
|
+
**same field** of the template it references. A child's body extends the base's body; its subject
|
|
218
|
+
extends the base's subject. So a base can define a subject prefix and a body wrapper at once, and
|
|
219
|
+
neither leaks into the other. A field the base leaves empty composes to nothing rather than to an
|
|
220
|
+
error.
|
|
221
|
+
|
|
222
|
+
### Whitespace
|
|
223
|
+
|
|
224
|
+
A structural tag (`extends`, `block`, `endblock`) alone on its line is taken out *with* the line,
|
|
225
|
+
so a layout written across several lines does not compose into one padded with blank ones. The
|
|
226
|
+
placeholder tags (`children`, `include`, `super`) are never line-trimmed: what replaces them lands
|
|
227
|
+
exactly where the tag stood, indentation and all.
|
|
228
|
+
|
|
229
|
+
### Abstract templates
|
|
230
|
+
|
|
231
|
+
A template is *abstract* when it declares a `{% managed_children %}` hole, or declares blocks
|
|
232
|
+
without extending anything — a layout meant to be built on rather than sent. That is a fact about
|
|
233
|
+
the source, so it follows the template as it is edited: a template becomes abstract the moment
|
|
234
|
+
someone writes the hole into it and stops being abstract the moment they take it out.
|
|
235
|
+
|
|
236
|
+
**The check** recomputes from the source every time, which makes it the authority:
|
|
237
|
+
|
|
238
|
+
```python
|
|
239
|
+
service.is_abstract(base) # True
|
|
240
|
+
service.is_abstract(welcome) # False
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
**The flag** is that same answer, denormalized onto the template so it can be queried:
|
|
244
|
+
|
|
245
|
+
```python
|
|
246
|
+
base.is_abstract # True -- stored, not recomputed
|
|
247
|
+
service.get_filtered_templates({"is_abstract": False}) # every sendable template
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Filtering is the reason the flag exists. Without it, a picker that has to leave the bases out would
|
|
251
|
+
read and parse every row in the store to draw one page. Nobody writes the flag — there is no field
|
|
252
|
+
for it on either write input — because a stored copy that disagreed with the source would be a lie
|
|
253
|
+
a filter goes on repeating. It is a **backend's job to derive it on every write** with
|
|
254
|
+
`composition.is_abstract`; see [Implementing a manager backend](#implementing-a-manager-backend).
|
|
255
|
+
|
|
256
|
+
Reach for the check when the flag cannot be trusted: a template edited in memory since it was read,
|
|
257
|
+
or one written before its backend maintained the column.
|
|
258
|
+
|
|
259
|
+
Composing an abstract template directly is allowed and gives you the layout with an empty hole.
|
|
260
|
+
Neither the check nor the flag refuses anything — keeping bases out of a picker is the host's call.
|
|
261
|
+
|
|
262
|
+
### Versions
|
|
263
|
+
|
|
264
|
+
A reference with no version resolves the same way any other read does: to whatever version that key
|
|
265
|
+
currently is. Pin it when a template must keep composing against an exact parent — re-rendering an
|
|
266
|
+
old notification resolves the child's version explicitly, but its unpinned bases still resolve to
|
|
267
|
+
today's.
|
|
268
|
+
|
|
269
|
+
`version=N` is the only spelling, on both tags that take a reference:
|
|
270
|
+
|
|
271
|
+
```
|
|
272
|
+
{% managed_extends "base-email" version=2 %}
|
|
273
|
+
{% managed_include "footer" version=7 %}
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
Nothing inside the quoted key is interpreted, so a key is only ever a key — a template genuinely
|
|
277
|
+
named `base-email[v2]` is referenced exactly as written, with no escaping and no special case.
|
|
278
|
+
|
|
279
|
+
### Checking a template before it ships
|
|
280
|
+
|
|
281
|
+
Composition failures are this package's, not the engine's, so nothing downstream can report them.
|
|
282
|
+
Catch them where someone can still fix them:
|
|
283
|
+
|
|
284
|
+
```python
|
|
285
|
+
service.validate_composition(template) # raises exactly what rendering would have
|
|
286
|
+
service.get_composed_template("welcome") # what the engine will actually receive
|
|
287
|
+
service.get_template_references(template) # the bases and fragments it names, unresolved
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
| Exception | Raised when |
|
|
291
|
+
|---|---|
|
|
292
|
+
| `ManagedTemplateCompositionSyntaxError` | A tag is malformed, unknown, or unbalanced |
|
|
293
|
+
| `ManagedTemplateCompositionReferenceError` | A base or fragment does not exist (also a `ManagedTemplateNotFoundError`) |
|
|
294
|
+
| `ManagedTemplateCompositionCycleError` | The references loop |
|
|
295
|
+
| `ManagedTemplateCompositionDepthError` | The chain runs past the composer's `max_depth` (25 by default) |
|
|
296
|
+
|
|
297
|
+
All four subclass `ManagedTemplateCompositionError`.
|
|
298
|
+
|
|
299
|
+
### Turning it off
|
|
300
|
+
|
|
301
|
+
Composition is on by default. A store that predates it and holds `managed_`-prefixed text meant to
|
|
302
|
+
reach the engine verbatim can opt out:
|
|
303
|
+
|
|
304
|
+
```python
|
|
305
|
+
renderer = ManagedTemplateEmailRenderer(manager_backend, inner_renderer, compose_templates=False)
|
|
306
|
+
service = ManagedTemplateService(manager_backend, renderer, compose_templates=False)
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
Reads are never composed either way: `get_template` hands back exactly what is stored, which is
|
|
310
|
+
what an editing UI needs. `get_composed_template` is the explicit way to ask for the assembled form.
|
|
311
|
+
|
|
312
|
+
## Templates and versions
|
|
313
|
+
|
|
314
|
+
Templates are **versioned, never edited in place**. `update_template` copies the latest version
|
|
315
|
+
forward, applies the non-`None` fields of the input, and returns the new version — so a published
|
|
316
|
+
version's body can never change under a notification that already referenced it.
|
|
317
|
+
|
|
318
|
+
```python
|
|
319
|
+
from vintasend_managed_templates.dataclasses import ManagedTemplateUpdateInput
|
|
320
|
+
|
|
321
|
+
service.update_template("welcome", ManagedTemplateUpdateInput(
|
|
322
|
+
name=None, # None leaves the field as the previous version had it
|
|
323
|
+
description=None,
|
|
324
|
+
template_body="<p>Hi {{ name }}, welcome aboard!</p>",
|
|
325
|
+
template_subject=None,
|
|
326
|
+
template_preheader=None,
|
|
327
|
+
tags=None, # None carries tags forward; [] clears them
|
|
328
|
+
))
|
|
329
|
+
|
|
330
|
+
service.get_template("welcome") # latest version
|
|
331
|
+
service.get_template("welcome", version=1) # a specific one
|
|
332
|
+
service.get_template_versions("welcome") # every version, newest first
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
`version=None` means "the latest version of this key" everywhere in the service — reads, status
|
|
336
|
+
changes, tagging, and rendering — so callers only deal with version numbers when they actually
|
|
337
|
+
want a specific one.
|
|
338
|
+
|
|
339
|
+
## Statuses
|
|
340
|
+
|
|
341
|
+
A version moves through `DRAFT → ACTIVE → INACTIVE → ARCHIVED`, and every move is written to the
|
|
342
|
+
backend's audit trail:
|
|
343
|
+
|
|
344
|
+
```python
|
|
345
|
+
service.activate("welcome", changed_by="hugo@example.com")
|
|
346
|
+
service.deactivate("welcome")
|
|
347
|
+
service.archive("welcome", version=1)
|
|
348
|
+
service.get_status_history("welcome") # newest change first
|
|
349
|
+
service.can_transition_to(template, ManagedTemplateStatus.ACTIVE)
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
The default transition table:
|
|
353
|
+
|
|
354
|
+
| From | May move to |
|
|
355
|
+
|---|---|
|
|
356
|
+
| `DRAFT` | `ACTIVE`, `ARCHIVED` |
|
|
357
|
+
| `ACTIVE` | `INACTIVE`, `ARCHIVED` |
|
|
358
|
+
| `INACTIVE` | `ACTIVE`, `ARCHIVED` |
|
|
359
|
+
| `ARCHIVED` | — terminal |
|
|
360
|
+
|
|
361
|
+
Anything else raises `ManagedTemplateStatusTransitionError`. Setting a version to the status it
|
|
362
|
+
already holds is a no-op: no history entry, no error. Override `ALLOWED_STATUS_TRANSITIONS` on a
|
|
363
|
+
subclass for a different lifecycle, or pass `validate_status_transitions=False` to leave the
|
|
364
|
+
ordering entirely to your application.
|
|
365
|
+
|
|
366
|
+
Two things the service deliberately does *not* decide for you:
|
|
367
|
+
|
|
368
|
+
* **A key may have several `ACTIVE` versions at once.** Activating one does not deactivate the
|
|
369
|
+
others; choosing which active version wins at render time is the host's call.
|
|
370
|
+
* **`changed_by` is passed through untouched, `None` included.** Attribution is never required.
|
|
371
|
+
|
|
372
|
+
## Tags
|
|
373
|
+
|
|
374
|
+
Tags are many-to-many with template *versions* and are identified by a slug derived from the text
|
|
375
|
+
someone typed. Slugging lives in `vintasend_managed_templates.tags` rather than in a backend, so a
|
|
376
|
+
Django store and a SQLAlchemy store agree on what `Promoção` slugs to. Every call that takes a slug
|
|
377
|
+
also accepts the original text.
|
|
378
|
+
|
|
379
|
+
```python
|
|
380
|
+
service.add_template_tags("welcome", ["Black Friday"]) # creates the tag if it is new
|
|
381
|
+
service.remove_template_tags("welcome", ["black-friday"])
|
|
382
|
+
service.set_template_tags("welcome", ["onboarding"]) # replaces; [] clears
|
|
383
|
+
service.get_templates_by_tags(["onboarding", "email"], match_all=False)
|
|
384
|
+
service.get_active_tags() # what a tag picker should show
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
Retagging **edits the version in place** instead of creating one. Tags are how a template is
|
|
388
|
+
found, not part of what it renders, so relabelling for findability does not spawn a version and
|
|
389
|
+
drop it back to `DRAFT`.
|
|
390
|
+
|
|
391
|
+
Archiving a tag (`archive_tag` / `restore_tag`) takes it out of the pickers but keeps every link:
|
|
392
|
+
filtering by an archived tag still returns the templates carrying it. `delete_tag` is the
|
|
393
|
+
irreversible one — it removes the label from the templates too.
|
|
394
|
+
|
|
395
|
+
Text with nothing sluggable in it (`" "`, `"!!!"`) raises `ManagedTemplateInvalidTagError` at the
|
|
396
|
+
call site, rather than becoming a tag no filter can ever name.
|
|
397
|
+
|
|
398
|
+
## Filtering and pagination
|
|
399
|
+
|
|
400
|
+
Filters are plain dicts, typed by the TypedDicts in `filters.py`, and compose with `and` / `or` /
|
|
401
|
+
`not`:
|
|
402
|
+
|
|
403
|
+
```python
|
|
404
|
+
service.get_filtered_templates({
|
|
405
|
+
"and": [
|
|
406
|
+
{"status": {"lookup": "in", "value": [ManagedTemplateStatus.ACTIVE]}},
|
|
407
|
+
{"name": {"lookup": "includes", "value": "welcome", "case_sensitive": False}},
|
|
408
|
+
{"includes_any_of_tags": ["onboarding", "transactional"]},
|
|
409
|
+
{"created_at_range": {"from": datetime(2026, 1, 1)}},
|
|
410
|
+
]
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
service.get_paginated_filtered_templates(filters, page=1, page_size=20) # page is 1-indexed
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
Fields: `name`, `description`, `key`, `version`, `template_managed_backend`, `status`,
|
|
417
|
+
`created_at_range`, `updated_at_range`, `includes_all_tags`, `includes_any_of_tags`,
|
|
418
|
+
`most_recent_active_version`. String lookups are `exact` / `starts_with` / `ends_with` /
|
|
419
|
+
`includes`; numeric ones are `gt` / `gte` / `lt` / `lte`.
|
|
420
|
+
|
|
421
|
+
### One row per key: `most_recent_active_version`
|
|
422
|
+
|
|
423
|
+
The store holds a row per *version*, so an unfiltered read shows a template once for every
|
|
424
|
+
version it has ever had. `most_recent_active_version` collapses that to one row per key — the
|
|
425
|
+
highest-numbered `ACTIVE` or `DRAFT` version, which is what is live plus the draft on its way to
|
|
426
|
+
replacing it. A key whose versions are all `INACTIVE` or `ARCHIVED` has no current version and
|
|
427
|
+
drops out.
|
|
428
|
+
|
|
429
|
+
```python
|
|
430
|
+
service.get_all_templates() # one row per key — the current version
|
|
431
|
+
service.get_all_templates(include_all_versions=True) # every version of every key
|
|
432
|
+
service.get_paginated_templates(page=1, page_size=20) # same default
|
|
433
|
+
service.get_filtered_templates({"most_recent_active_version": True}) # the filter itself
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
**The two listing methods apply it by default**; pass `include_all_versions=True` for the raw
|
|
437
|
+
read. `get_filtered_templates` and `get_paginated_filtered_templates` do *not* add it — a filter
|
|
438
|
+
means what it says — so name the field yourself when a filtered listing should be one row per key
|
|
439
|
+
too. `False` is the exact complement (every other row, retired keys included), the same set
|
|
440
|
+
`{"not": {"most_recent_active_version": True}}` returns.
|
|
441
|
+
|
|
442
|
+
Unlike every other field, this one is answered against the whole key rather than against the row
|
|
443
|
+
being tested, so a backend evaluates it with a subquery over the key's other versions.
|
|
444
|
+
|
|
445
|
+
`validate_filter` runs before every filtered read and raises `ManagedTemplateInvalidFilterError`
|
|
446
|
+
for a typo'd field name, an `and`/`or` that is not a non-empty list, a logical group with sibling
|
|
447
|
+
keys, a tag filter given as a bare string (which would otherwise be iterated character by
|
|
448
|
+
character and silently match nothing), or a non-boolean `most_recent_active_version` (the string
|
|
449
|
+
`"false"` is truthy, so it would ask for exactly what the caller meant to switch off). Lookup
|
|
450
|
+
*values* stay the backend's authority.
|
|
451
|
+
|
|
452
|
+
The empty-collection rules follow Python's own `all()` / `any()`: an empty `includes_all_tags`
|
|
453
|
+
constrains nothing, an empty `includes_any_of_tags` matches nothing.
|
|
454
|
+
|
|
455
|
+
## Rendering a specific version
|
|
456
|
+
|
|
457
|
+
Which version of a template a notification renders is decided in this order:
|
|
458
|
+
|
|
459
|
+
1. an explicit `version=` argument to `service.render()`,
|
|
460
|
+
2. the notification's own `requested_template_version`,
|
|
461
|
+
3. whatever version the backend considers current.
|
|
462
|
+
|
|
463
|
+
```python
|
|
464
|
+
service.render(notification, context) # the notification's pin, or the latest
|
|
465
|
+
service.render(notification, context, version=3) # preview v3 regardless of the pin
|
|
466
|
+
service.render_template(notification, template, context) # a template already in hand
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
The argument is for rendering a version the notification is *not* pinned to -- previewing an
|
|
470
|
+
unpublished draft, or reproducing what an old notification looked like. Leave it off and you get
|
|
471
|
+
what a real send would produce.
|
|
472
|
+
|
|
473
|
+
### Pinning a notification to a version
|
|
474
|
+
|
|
475
|
+
`ManagedTemplateRenderer` reads `requested_template_version` off the notification, so a
|
|
476
|
+
notification recorded against v3 renders v3 however many versions follow. That field is
|
|
477
|
+
vintasend's, not this package's -- see
|
|
478
|
+
[Template Version Pinning](https://github.com/vintasoftware/vintasend#template-version-pinning)
|
|
479
|
+
-- and this package is what makes it mean anything:
|
|
480
|
+
|
|
481
|
+
```python
|
|
482
|
+
notification_service.create_notification(
|
|
483
|
+
...,
|
|
484
|
+
body_template="welcome", # the template key
|
|
485
|
+
requested_template_version=3, # render v3, now and forever
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
# Or pin to whatever version is current at this moment, without naming it:
|
|
489
|
+
notification_service.create_notification(..., pin_template_versions=True)
|
|
490
|
+
|
|
491
|
+
# The same, as the default for every call on the service:
|
|
492
|
+
notification_service = NotificationService(..., pin_template_versions=True)
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
With `pin_template_versions=True`, the service asks this package's renderer for the current
|
|
496
|
+
version through `get_latest_template_version()`, which resolves it the same way `get_template(key)`
|
|
497
|
+
does. A key with nothing behind it answers `None` and the notification is created unpinned, rather
|
|
498
|
+
than failing the creation over a template that may well exist by the time it is sent.
|
|
499
|
+
|
|
500
|
+
Whichever version renders is reported back on the send input as `template_version`, so vintasend
|
|
501
|
+
can store it as `used_template_version`. For an unpinned notification that is the only record of
|
|
502
|
+
which version went out, since the template has moved on by the time anyone asks.
|
|
503
|
+
|
|
504
|
+
## Implementing a manager backend
|
|
505
|
+
|
|
506
|
+
Subclass `BaseTemplateManagerBackend` and implement every abstract method. It splits into four
|
|
507
|
+
groups:
|
|
508
|
+
|
|
509
|
+
* **Versions** — `create_template`, `get_template`, `update_template`, `delete_template`
|
|
510
|
+
* **Statuses** — `create_template_status_update`, `get_template_status_history`
|
|
511
|
+
* **Tags** — `get_or_create_tags`, `create_tag`, `get_tag`, `update_tag`, `set_tag_status`,
|
|
512
|
+
`delete_tag`, `get_tags`, `get_template_tags`, `set_template_tags`
|
|
513
|
+
* **Queries** — `get_all_templates`, `get_templates_by_status`, `get_filtered_templates`,
|
|
514
|
+
`get_paginated_templates`, `get_paginated_filtered_templates`
|
|
515
|
+
|
|
516
|
+
What a backend owns, beyond storage: assigning version numbers, deriving tag slugs with
|
|
517
|
+
`slugify_tag` and keeping them unique with `next_available_slug`, deriving `is_abstract` with
|
|
518
|
+
`composition.is_abstract` on every write that touches a source, and translating the filter dicts
|
|
519
|
+
into its own query language.
|
|
520
|
+
|
|
521
|
+
`is_abstract` is the one easy to miss, because no method is named for it. It is a denormalization
|
|
522
|
+
of the template's own source, kept so the `is_abstract` filter can be a column lookup instead of a
|
|
523
|
+
full-store parse, and neither write input carries it. A backend that never sets it reports every
|
|
524
|
+
template as concrete and that filter quietly stops working.
|
|
525
|
+
|
|
526
|
+
`tests/fakes.py` in this repo has `InMemoryTemplateManagerBackend`, a complete, dependency-free
|
|
527
|
+
implementation of the seam — the shortest readable reference for what each method owes its caller.
|
|
528
|
+
The suite drives it end to end rather than mocking it, so it is a real implementation, not a stub.
|
|
529
|
+
|
|
530
|
+
### Exceptions
|
|
531
|
+
|
|
532
|
+
All of them subclass `ManagedTemplateError`:
|
|
533
|
+
|
|
534
|
+
| Exception | Raised when |
|
|
535
|
+
|---|---|
|
|
536
|
+
| `ManagedTemplateNotFoundError` | The key, or that version of it, does not exist |
|
|
537
|
+
| `ManagedTemplateInvalidFilterError` | A filter is malformed or names an unknown field |
|
|
538
|
+
| `ManagedTemplateStatusTransitionError` | The status move is not allowed from the current status |
|
|
539
|
+
| `ManagedTemplateChangeUserNotFoundError` | An update names a `changed_by` user that does not exist |
|
|
540
|
+
| `ManagedTemplateTagNotFoundError` | No tag has that slug |
|
|
541
|
+
| `ManagedTemplateTagAlreadyExistsError` | `create_tag` collides with an existing slug |
|
|
542
|
+
| `ManagedTemplateInvalidTagError` | A tag's text has nothing that can be slugified |
|
|
543
|
+
| `ManagedTemplateCompositionError` | A template could not be assembled -- see [Composition](#composition-bases-blocks-and-includes) for its four subclasses |
|
|
544
|
+
|
|
545
|
+
## Development
|
|
546
|
+
|
|
547
|
+
```bash
|
|
548
|
+
poetry install
|
|
549
|
+
poetry run pytest # coverage is on by default and fails the run below 90%
|
|
550
|
+
poetry run ruff check
|
|
551
|
+
poetry run mypy
|
|
552
|
+
poetry run tox # the full 3.10–3.14 matrix
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
The suite runs fully offline against the in-memory backend — no database, no services.
|
|
556
|
+
|