vintasend-templates-management-api 3.5.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.
Files changed (31) hide show
  1. vintasend_templates_management_api-3.5.0/PKG-INFO +778 -0
  2. vintasend_templates_management_api-3.5.0/README.md +739 -0
  3. vintasend_templates_management_api-3.5.0/openapi.yaml +2708 -0
  4. vintasend_templates_management_api-3.5.0/pyproject.toml +236 -0
  5. vintasend_templates_management_api-3.5.0/vintasend_templates_management_api/__init__.py +0 -0
  6. vintasend_templates_management_api-3.5.0/vintasend_templates_management_api/asgi.py +15 -0
  7. vintasend_templates_management_api-3.5.0/vintasend_templates_management_api/settings.py +132 -0
  8. vintasend_templates_management_api-3.5.0/vintasend_templates_management_api/templates_manager/__init__.py +0 -0
  9. vintasend_templates_management_api-3.5.0/vintasend_templates_management_api/templates_manager/api.py +875 -0
  10. vintasend_templates_management_api-3.5.0/vintasend_templates_management_api/templates_manager/apps.py +76 -0
  11. vintasend_templates_management_api-3.5.0/vintasend_templates_management_api/templates_manager/auth.py +148 -0
  12. vintasend_templates_management_api-3.5.0/vintasend_templates_management_api/templates_manager/bodies.py +74 -0
  13. vintasend_templates_management_api-3.5.0/vintasend_templates_management_api/templates_manager/capabilities.py +52 -0
  14. vintasend_templates_management_api-3.5.0/vintasend_templates_management_api/templates_manager/conf.py +77 -0
  15. vintasend_templates_management_api-3.5.0/vintasend_templates_management_api/templates_manager/contract.py +267 -0
  16. vintasend_templates_management_api-3.5.0/vintasend_templates_management_api/templates_manager/cors.py +82 -0
  17. vintasend_templates_management_api-3.5.0/vintasend_templates_management_api/templates_manager/errors.py +97 -0
  18. vintasend_templates_management_api-3.5.0/vintasend_templates_management_api/templates_manager/filters.py +254 -0
  19. vintasend_templates_management_api-3.5.0/vintasend_templates_management_api/templates_manager/hooks.py +147 -0
  20. vintasend_templates_management_api-3.5.0/vintasend_templates_management_api/templates_manager/management/__init__.py +0 -0
  21. vintasend_templates_management_api-3.5.0/vintasend_templates_management_api/templates_manager/management/commands/__init__.py +0 -0
  22. vintasend_templates_management_api-3.5.0/vintasend_templates_management_api/templates_manager/management/commands/export_openapi.py +127 -0
  23. vintasend_templates_management_api-3.5.0/vintasend_templates_management_api/templates_manager/preview.py +153 -0
  24. vintasend_templates_management_api-3.5.0/vintasend_templates_management_api/templates_manager/query.py +300 -0
  25. vintasend_templates_management_api-3.5.0/vintasend_templates_management_api/templates_manager/serialize.py +162 -0
  26. vintasend_templates_management_api-3.5.0/vintasend_templates_management_api/templates_manager/service.py +546 -0
  27. vintasend_templates_management_api-3.5.0/vintasend_templates_management_api/templates_manager/urls.py +23 -0
  28. vintasend_templates_management_api-3.5.0/vintasend_templates_management_api/templates_manager/views.py +22 -0
  29. vintasend_templates_management_api-3.5.0/vintasend_templates_management_api/urls.py +21 -0
  30. vintasend_templates_management_api-3.5.0/vintasend_templates_management_api/vintasend_config.example.py +66 -0
  31. vintasend_templates_management_api-3.5.0/vintasend_templates_management_api/wsgi.py +10 -0
@@ -0,0 +1,778 @@
1
+ Metadata-Version: 2.4
2
+ Name: vintasend-templates-management-api
3
+ Version: 3.5.0
4
+ Summary: REST API that exposes a VintaSend ManagedTemplateService over HTTP for template-management UIs
5
+ License-Expression: MIT
6
+ Keywords: vintasend,notifications,templates,django,django-ninja,rest-api
7
+ Author: Vinta Software
8
+ Author-email: contact@vinta.com.br
9
+ Requires-Python: >=3.10,<3.15
10
+ Classifier: Development Status :: 5 - Production/Stable
11
+ Classifier: Environment :: Web Environment
12
+ Classifier: Framework :: Django
13
+ Classifier: Framework :: Django :: 4.2
14
+ Classifier: Framework :: Django :: 5.0
15
+ Classifier: Framework :: Django :: 5.1
16
+ Classifier: Framework :: Django :: 5.2
17
+ Classifier: Intended Audience :: Developers
18
+ Classifier: Operating System :: OS Independent
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3 :: Only
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Topic :: Communications :: Email
26
+ Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
27
+ Requires-Dist: django (>=4.2,<6.0)
28
+ Requires-Dist: django-ninja (>=1.4.3,<2.0.0)
29
+ Requires-Dist: python-dotenv (>=1.1.1,<2.0.0)
30
+ Requires-Dist: pyyaml (>=6.0.2,<7.0.0)
31
+ Requires-Dist: vintasend (==3.5.0)
32
+ Requires-Dist: vintasend-managed-templates (==3.5.0)
33
+ Project-URL: Changelog, https://github.com/vintasoftware/vintasend/blob/main/RELEASE_NOTES.md
34
+ Project-URL: Homepage, https://github.com/vintasoftware/vintasend-templates-management-api
35
+ Project-URL: Issues, https://github.com/vintasoftware/vintasend-templates-management-api/issues
36
+ Project-URL: Repository, https://github.com/vintasoftware/vintasend-templates-management-api
37
+ Description-Content-Type: text/markdown
38
+
39
+ # VintaSend Templates API
40
+
41
+ REST API that exposes a
42
+ [`ManagedTemplateService`](https://github.com/vintasoftware/vintasend-managed-templates)
43
+ over HTTP, built with Django and [django-ninja](https://django-ninja.dev/).
44
+
45
+ It exists so a template-management UI does not have to embed a template store: the UI
46
+ becomes a pure API client, and any implementation of this contract can serve it. It is
47
+ the templates-side sibling of
48
+ [`vintasend-api`](https://github.com/vintasoftware/vintasend-api), which does the same
49
+ for notifications, and follows the same conventions — bearer-token auth, a `{ data }` /
50
+ `{ error }` envelope, 1-indexed pages, camelCase on the wire.
51
+
52
+ **[`openapi.yaml`](./openapi.yaml) is generated, not maintained.** It is produced from
53
+ the route declarations in
54
+ [`api.py`](./vintasend_templates_management_api/templates_manager/api.py) and the schemas in
55
+ [`contract.py`](./vintasend_templates_management_api/templates_manager/contract.py) and
56
+ [`query.py`](./vintasend_templates_management_api/templates_manager/query.py) by
57
+ `manage.py export_openapi`. A test asserts the committed copy matches the live routes, so
58
+ a change that was not regenerated fails the suite rather than shipping a stale contract.
59
+
60
+ ```
61
+ ┌─────────────────────┐ HTTPS + API key ┌───────────────────────┐
62
+ │ Template mgmt UI │ ───────────────────▶ │ vintasend-templates- │
63
+ │ server-side only │ ◀─────────────────── │ api (this project) │
64
+ └─────────────────────┘ JSON contract └───────────┬───────────┘
65
+ │
66
+ ┌──────────────┴──────────────┐
67
+ │ Your ManagedTemplateService│
68
+ │ template manager backend + │
69
+ │ managed template renderer │
70
+ └─────────────────────────────┘
71
+ ```
72
+
73
+ The API owns everything that needs backend credentials — database access, template
74
+ rendering. The UI owns presentation and user authentication.
75
+
76
+ ## Why Django, for a library with no web framework
77
+
78
+ The same reason as `vintasend-api`: the template store decides. The reference backend,
79
+ `vintasend-django-templates-manager`, persists templates through the Django ORM, and
80
+ reading them needs a Django app registry and connection handling — another framework's
81
+ process would have to boot a half-configured Django anyway.
82
+
83
+ Nothing is lost for non-Django deployments. `BaseTemplateManagerBackend` is a pluggable
84
+ seam, so a FastAPI application storing templates through SQLAlchemy is served by this
85
+ same API: point `MANAGED_TEMPLATE_SERVICE_FACTORY` at a factory that builds a
86
+ SQLAlchemy-backed service and the HTTP layer neither knows nor cares.
87
+
88
+ ## Endpoints
89
+
90
+ | Method | Path | Purpose |
91
+ | --- | --- | --- |
92
+ | GET | `/health` | Liveness probe (unauthenticated) |
93
+ | GET | `/api/v1/capabilities` | Filter capabilities of the configured backend |
94
+ | GET | `/api/v1/templates` | List template versions with filters and pagination |
95
+ | POST | `/api/v1/templates` | Create a template's first version |
96
+ | GET | `/api/v1/templates/{key}` | One version — the latest unless `?version=` is given |
97
+ | DELETE | `/api/v1/templates/{key}` | Delete one never-published version (the latest unless `?version=`); a published one is a 409 `CONFLICT` |
98
+ | GET | `/api/v1/templates/{key}/versions` | Every version, newest first |
99
+ | POST | `/api/v1/templates/{key}/versions` | Create a new version from the latest |
100
+ | GET | `/api/v1/templates/{key}/versions/{version}` | One pinned version |
101
+ | DELETE | `/api/v1/templates/{key}/versions/{version}` | Delete one pinned, never-published version; a published one is a 409 `CONFLICT` |
102
+ | GET | `/api/v1/templates/{key}/composition` | One version assembled — what the engine actually receives |
103
+ | GET | `/api/v1/templates/{key}/status-history` | Status audit trail, most recent first |
104
+ | POST | `/api/v1/templates/{key}/status` | Move a version to an explicitly named status |
105
+ | POST | `/api/v1/templates/{key}/activate` | Publish a version |
106
+ | POST | `/api/v1/templates/{key}/deactivate` | Retire a version without archiving it |
107
+ | POST | `/api/v1/templates/{key}/archive` | Archive a version (terminal by default) |
108
+ | POST | `/api/v1/templates/{key}/preview` | Render a version against a supplied context |
109
+ | PUT | `/api/v1/templates/{key}/tags` | Replace one version's tags, in place |
110
+ | GET | `/api/v1/tags` | List tags with filters and pagination |
111
+ | POST | `/api/v1/tags` | Create a tag ahead of any template using it |
112
+ | GET | `/api/v1/tags/{slug}` | One tag |
113
+ | PATCH | `/api/v1/tags/{slug}` | Rename a tag (its slug is regenerated) |
114
+ | DELETE | `/api/v1/tags/{slug}` | Delete a tag, stripping it from every template |
115
+ | POST | `/api/v1/tags/{slug}/archive` | Retire a tag from the pickers |
116
+ | POST | `/api/v1/tags/{slug}/restore` | Put an archived tag back on offer |
117
+
118
+ A browsable version of the generated schema is served at `/api/v1/docs`.
119
+
120
+ Conventions a client can rely on:
121
+
122
+ - `page` is **1-indexed**.
123
+ - `hasMore` is `true` when the next page has at least one row, so a list that exactly fills
124
+ its last page never offers an empty one. The template-manager seam has no count method,
125
+ so no total is available: after a full page the API reads the one row that would follow
126
+ it.
127
+ - **Request bodies are JSON.** A request that declares `application/json` (or any
128
+ `application/*+json`) must carry valid JSON, so an empty body there is a 400. A request
129
+ that declares no media type, or another one, counts as an omitted body when its body is
130
+ empty, which is how an all-optional body (`activate`, `deactivate`, `archive`, `preview`,
131
+ `POST /templates/{key}/versions`) is left out. Anything else is a 400 rather than a guess.
132
+ `curl -d` sends form encoding unless told otherwise, so pass
133
+ `-H 'Content-Type: application/json'`.
134
+ - **`GET /templates` lists one row per key by default.** A row in the store is a *version*,
135
+ so the raw read returns a key once per version it has ever had. `mostRecentActiveVersion`
136
+ defaults to `true` and narrows that to each key's current version — the highest-numbered
137
+ `active` or `draft` one. Send `mostRecentActiveVersion=false` to list every version.
138
+ - **`version` omitted means "the latest"** everywhere except `status-history`, where it
139
+ means "every version" — that endpoint forwards it to the backend, which returns the
140
+ whole key's trail.
141
+ - **`GET /templates` orders only when asked, and only by what the backend can sort.**
142
+ `orderByField` takes `key`, `name`, `version`, `status`, `createdAt` or `updatedAt`;
143
+ `orderByDirection` takes `asc` or `desc` and defaults to `asc`. Neither has a default
144
+ field, so omitting them asks for the backend's own order. See
145
+ [Ordering](#ordering-the-listing).
146
+ - Every template payload carries `isAbstract`: whether that version is a base to build on rather than one to send. See [Composition](#composition-templates-built-on-templates).
147
+ - Every template payload carries `allowedTransitions`: the statuses that version can move
148
+ to right now, as the *configured service* answers it. A UI enables buttons from that
149
+ rather than reimplementing the lifecycle or discovering it by catching a 409.
150
+ - **A send renders the newest `active` version, never a draft.** Reads here are the editing
151
+ view: `version` omitted returns the newest version whatever its status, and a preview with no
152
+ `version` renders that. When several versions are active, an unpinned send renders the
153
+ highest-numbered one, so activating an older version does not change what is sent.
154
+ - **Only a never-published version can be deleted.** A version that was ever activated is
155
+ refused with a 409 `CONFLICT`: a notification may be pinned to it, and its status history
156
+ records who published it. Archive it instead. That applies to `DELETE /templates/{key}` with no
157
+ `version` too, which resolves to the latest version, so prefer naming the version you mean.
158
+ Status history is never deleted.
159
+ - **`changedBy` in a status body is only a fallback.** A host that knows who is calling sets
160
+ `MANAGED_TEMPLATE_ACTOR_RESOLVER` (see [Attribution](#attribution)), and its answer replaces
161
+ whatever the body says.
162
+ - **`templateManagedBackend` in a create body can be overridden by the host.** A host serving one
163
+ backend sets `MANAGED_TEMPLATE_BACKEND_NAME` (see [Backend name](#backend-name)), and every new
164
+ template is stored under it, whatever the body says.
165
+ - Timestamps are ISO-8601 UTC strings, `null` when unset — never absent.
166
+ - Errors always use the envelope `{ "error": { "code", "message", "details"? } }`.
167
+
168
+ ### Tags
169
+
170
+ Tags label template versions so they can be found. A template carries any number of tags
171
+ and a tag is carried by any number of templates.
172
+
173
+ - **The slug is the identity.** It is normalized from the text — lowercased, accents
174
+ folded, everything else collapsed to `-` — and is unique store-wide, so `Black Friday`,
175
+ `black friday` and `BLACK-FRIDAY` are one tag. Store the `slug`, not the `text`.
176
+ - **Anywhere a tag is named, its text works too.** `GET /tags/Black%20Friday` and
177
+ `GET /tags/black-friday` are the same lookup, and the same holds for the filters.
178
+ - **Tags are created on the fly.** Send `tags` on `POST /templates` or
179
+ `POST /templates/{key}/versions` and any tag that does not exist yet is created. Use
180
+ `POST /tags` only when a collision should be reported rather than resolved — it is a
181
+ 409 there and silent everywhere else.
182
+ - **Renaming changes the slug.** `PATCH /tags/{slug}` regenerates it, so a stored filter
183
+ naming the old slug stops matching. Read the new `slug` off the response. Renaming onto
184
+ another tag's text is allowed and yields `-2`: two tags may read the same, and the slug
185
+ is what tells them apart.
186
+ - **Archive hides the tag; delete removes the label.** An archived tag drops out of
187
+ `GET /tags?status=active` but stays on the templates carrying it, and filtering by it
188
+ still finds them. Deleting is what strips the label, and it is not reversible.
189
+ - **Searching by tag** uses `includesAllTags` (carries *every* tag listed) or
190
+ `includesAnyOfTags` (carries *at least one*). Repeat the parameter per tag. Sending both
191
+ combines them with AND, like every other filter pair. A template matching several of the
192
+ tags listed is returned once, not once per match.
193
+
194
+ ```
195
+ GET /api/v1/templates?includesAllTags=billing&includesAllTags=urgent
196
+ GET /api/v1/templates?includesAnyOfTags=billing&includesAnyOfTags=marketing
197
+ ```
198
+ - **Tags belong to a version, not a key**, so two versions of one template can be labelled
199
+ differently — a draft can be tagged for review without relabelling what is live.
200
+
201
+ ### Versions in the listing
202
+
203
+ The store holds a row per version, and a template-management UI almost always wants a list of
204
+ *templates*. `GET /api/v1/templates` therefore defaults to `mostRecentActiveVersion=true`:
205
+
206
+ ```
207
+ GET /api/v1/templates # one row per key — the current version
208
+ GET /api/v1/templates?mostRecentActiveVersion=false # every version of every key
209
+ GET /api/v1/templates/{key}/versions # every version of one key, newest first
210
+ ```
211
+
212
+ Current means the **highest-numbered `active` or `draft` version**: what is published, plus the
213
+ draft on its way to replacing it. A key whose versions are all `inactive` or `archived` has no
214
+ current version and does not appear in the default listing — ask for
215
+ `mostRecentActiveVersion=false` to see it.
216
+
217
+ A `status` filter applies on top of the default, so `?status=archived` on its own finds
218
+ nothing: the one row kept per key is never `inactive` or `archived`. Send both:
219
+
220
+ ```
221
+ GET /api/v1/templates?status=archived&mostRecentActiveVersion=false
222
+ ```
223
+
224
+ `false` lifts the restriction rather than inverting it: it lists everything, not only the rows
225
+ the default hides. The complement is a legitimate query in the library's filter vocabulary
226
+ (`{"most_recent_active_version": False}`) but there is no wire parameter for it, because no UI
227
+ has asked to list *only* superseded versions.
228
+
229
+ The narrowing happens in the store, so pagination still counts what the backend counted. A
230
+ backend declining `fields.mostRecentActiveVersion` has the filter dropped like any other
231
+ unsupported one, and its listing shows every version.
232
+
233
+ ### Ordering the listing
234
+
235
+ ```
236
+ GET /api/v1/templates?orderByField=name&orderByDirection=asc
237
+ ```
238
+
239
+ `orderByField` accepts `key`, `name`, `version`, `status`, `createdAt` and `updatedAt` —
240
+ each a scalar the backend already stores per row, so a store can answer it from an index.
241
+ Tags are absent because ordering by a many-to-many has no single value to compare, and
242
+ `mostRecentActiveVersion` because it is a filter rather than a field.
243
+
244
+ `orderByDirection` is `asc` or `desc`, and defaults to `asc` when a field is given without
245
+ one. Sent on its own it is a `400`: there is nothing to order by, and ignoring it would
246
+ look exactly like a backend that cannot sort.
247
+
248
+ **Ask `/capabilities` first.** Every `orderBy.*` key defaults to `false`, so a backend that
249
+ has not declared a field cannot sort by it and the request is a `400` naming the key:
250
+
251
+ ```jsonc
252
+ // GET /api/v1/capabilities
253
+ {
254
+ "data": {
255
+ "orderBy.key": true,
256
+ "orderBy.name": true,
257
+ "orderBy.version": true,
258
+ "orderBy.status": false, // this backend cannot sort by status
259
+ "orderBy.createdAt": true,
260
+ "orderBy.updatedAt": true
261
+ // ... plus the fields.*, logical.* and stringLookups.* keys
262
+ }
263
+ }
264
+ ```
265
+
266
+ Build the sortable columns of a UI from that report and the `400` never happens. Unlike an
267
+ unsupported *filter*, which is dropped so the request still succeeds, an unsupported
268
+ *order* is refused — see [Design notes](#design-notes) for why the two differ.
269
+
270
+ The order is applied by the backend to the whole result set before a page is chosen, so
271
+ page 2 of an ordered listing is the second page of that order rather than the backend's
272
+ own second page re-sorted.
273
+
274
+ ### Composition: templates built on templates
275
+
276
+ A managed template can build on another one, and all of it is resolved **before** any
277
+ template engine runs — so the stored `bodyTemplate` is only half of what renders:
278
+
279
+ ```
280
+ base-email <html><body>
281
+ {% managed_block header %}<h1>Acme</h1>{% managed_endblock %}
282
+ {% managed_children %}
283
+ {% managed_include "footer" %}
284
+ </body></html>
285
+
286
+ welcome {% managed_extends "base-email" %}
287
+ {% managed_block header %}<h1>Welcome!</h1>{% managed_endblock %}
288
+ <p>Hi {{ name }}, welcome aboard.</p>
289
+ ```
290
+
291
+ The tag language belongs to
292
+ [`vintasend-managed-templates`](https://github.com/vintasoftware/vintasend-managed-templates#composition-bases-blocks-and-includes)
293
+ — `managed_extends`, `managed_children`, `managed_block` / `managed_endblock`,
294
+ `managed_super`, `managed_include`, with `version=2` to pin a reference. This API stores those templates as written and exposes what they assemble to.
295
+
296
+ **`GET /api/v1/templates/{key}/composition`** returns the assembled sources, plus what the
297
+ version directly references and whether it is abstract:
298
+
299
+ ```
300
+ GET /api/v1/templates/welcome/composition # the latest version
301
+ GET /api/v1/templates/welcome/composition?version=3
302
+
303
+ { "data": {
304
+ "key": "welcome",
305
+ "version": 3,
306
+ "isAbstract": false,
307
+ "references": [
308
+ { "kind": "extends", "key": "base-email", "version": null, "field": "bodyTemplate" }
309
+ ],
310
+ "composedBodyTemplate": "<html><body><h1>Welcome!</h1>...",
311
+ "composedSubjectTemplate": null,
312
+ "composedPreheaderTemplate": null
313
+ } }
314
+ ```
315
+
316
+ Nothing there is rendered against a context: `{{ name }}` and every other engine tag
317
+ survives untouched. `POST /templates/{key}/preview` is what renders — and it composes
318
+ first, so a preview shows the base's chrome around the child's content, exactly as a real
319
+ send would.
320
+
321
+ A template that cannot be assembled — a base that does not exist, a chain that loops, a
322
+ malformed tag — is a **409 `TEMPLATE_COMPOSITION_ERROR`** carrying the library's message,
323
+ which names the reference chain that broke. It is a 409 and not a 404 because the template
324
+ asked for is there; what it names is not.
325
+
326
+ Previewing tells the two ways a template can be broken apart by code, so a UI knows whether
327
+ to send the editor to the chain or to the template:
328
+
329
+ - A template that cannot be assembled is the same **409 `TEMPLATE_COMPOSITION_ERROR`** the
330
+ composition endpoint gives.
331
+ - A template that assembles but fails to render is a **409 `PREVIEW_UNAVAILABLE`** carrying
332
+ the renderer's message.
333
+ - A store that fails while assembling is a plain **500 `INTERNAL_ERROR`**: its message stays
334
+ on the server, and the error goes to the unhandled-error hook.
335
+
336
+ Two more things worth knowing:
337
+
338
+ - **Each of the three sources composes against the same field of what it references.** A
339
+ child's body extends the base's body, its subject extends the base's subject. `field` on
340
+ every reference says which one it was written in.
341
+ - **References are direct only.** What the referenced templates themselves reference is not
342
+ followed, and nothing is resolved — a reference to a template that does not exist is
343
+ reported here rather than raising.
344
+
345
+ #### `isAbstract`
346
+
347
+ Every template payload carries `isAbstract`: `true` when the version is a base to build on
348
+ rather than a template to send — it declares a `{% managed_children %}` hole, or blocks
349
+ without extending anything.
350
+
351
+ ```
352
+ GET /api/v1/templates?isAbstract=false # what a "pick a template to send" screen lists
353
+ GET /api/v1/templates?isAbstract=true # the bases, for a "pick a base to extend" screen
354
+ GET /api/v1/templates # both
355
+ ```
356
+
357
+ On a listing it is read from the backend's stored flag, so filtering is a column lookup
358
+ rather than a parse of every row, and serializing a page costs nothing. The composition
359
+ endpoint recomputes it from the source instead, which makes that copy the authority — worth
360
+ asking for when the flag cannot be trusted, such as a backend that predates it.
361
+
362
+ A backend declining `fields.isAbstract` has the filter dropped like any other unsupported
363
+ one, and its listing shows both.
364
+
365
+ ### Error codes
366
+
367
+ | Code | Status | Means |
368
+ | --- | --- | --- |
369
+ | `BAD_REQUEST` | 400 | Invalid input. See below. |
370
+ | `UNAUTHORIZED` | 401 | Missing or wrong API key. |
371
+ | `FORBIDDEN` | 403 | The caller was authenticated and is not allowed to do this. Declared on every route for hosts that check permissions in front of this API; the API key alone never produces it. |
372
+ | `NOT_FOUND` | 404 | No such template key, or no such version of it. |
373
+ | `CONFLICT` | 409 | The request cannot be applied in the current state: a tag whose text already slugs onto an existing one, or deleting a published version. |
374
+ | `INVALID_STATUS_TRANSITION` | 409 | The lifecycle does not allow that status change. |
375
+ | `PREVIEW_UNAVAILABLE` | 409 | The template could not be rendered; the message says why. |
376
+ | `TEMPLATE_COMPOSITION_ERROR` | 409 | The template could not be assembled — a missing base, a loop, a malformed tag. The message names the chain. |
377
+ | `INTERNAL_ERROR` | 500 | Unexpected failure. Reported generically, with an `X-Request-Id` header; logged as one redacted line. |
378
+
379
+ Every 400 carries `details.issues`, a list of `{ "path", "message" }`, whatever the mistake
380
+ was, so a client reads one shape:
381
+
382
+ | Mistake | `path` |
383
+ | --- | --- |
384
+ | An invalid query parameter or body field | the field, dotted when nested |
385
+ | A version in the path that is not a positive integer (`/versions/1abc`) | `version` |
386
+ | A body that is not valid JSON, not a JSON object, or not sent as JSON | empty |
387
+ | An order the backend cannot apply | `orderByField` / `orderByDirection` |
388
+ | A value the backend refused, such as tag text with nothing sluggable in it | empty, repeating the message |
389
+
390
+ An unexpected error is logged as one line: its class name, the request id, the method and the
391
+ route pattern (`api/v1/templates/<key>/preview`, not the path). Its message, its traceback, the
392
+ request body and a preview's context are never logged: errors from the template store or the
393
+ template engine can carry template content and context values, which in the applications this
394
+ API serves can be health data. Django's own `django.request` record for the 500 is suppressed
395
+ too, since it would repeat the concrete path and attach the request object. The request id is
396
+ the client's `X-Request-Id` when it matches `[A-Za-z0-9._-]{1,128}`, and a fresh UUID otherwise,
397
+ so a client cannot forge a log line through it. Set `MANAGED_TEMPLATE_UNHANDLED_ERROR_HANDLER` to send errors somewhere with its own
398
+ scrubbing instead (see [Unexpected errors](#unexpected-errors)).
399
+
400
+ ## Authentication
401
+
402
+ By default, every `/api/v1` request must carry the shared secret:
403
+
404
+ ```
405
+ Authorization: Bearer $VINTASEND_API_KEY
406
+ ```
407
+
408
+ Call this API from your UI's own server side so the key never reaches a browser. If you
409
+ do need to call it from a browser, set `VINTASEND_API_CORS_ORIGINS` to the allowed
410
+ origins — and put a per-user auth layer in front of it first.
411
+
412
+ A host that knows its callers sets `VINTASEND_API_AUTHENTICATOR` instead, and the shared key
413
+ is then neither checked nor required. See [Authenticating callers yourself](#authenticating-callers-yourself).
414
+
415
+ ## Installing and embedding
416
+
417
+ The package is a Django app, so the API can run inside a Django project you already deploy
418
+ rather than as a service of its own:
419
+
420
+ ```bash
421
+ pip install vintasend-templates-management-api
422
+ ```
423
+
424
+ Add the app and include its URLconf under a prefix of your choosing:
425
+
426
+ ```python
427
+ # settings.py
428
+ INSTALLED_APPS = [
429
+ # ...
430
+ "vintasend_templates_management_api.templates_manager.apps.TemplatesManagerConfig",
431
+ ]
432
+ ```
433
+
434
+ ```python
435
+ # urls.py
436
+ from django.urls import include, path
437
+
438
+ urlpatterns = [
439
+ # ...
440
+ path("templates-api/", include("vintasend_templates_management_api.templates_manager.urls")),
441
+ ]
442
+ ```
443
+
444
+ That serves `/templates-api/health` and `/templates-api/api/v1/...`. The routes are the same as
445
+ in the standalone project, under your prefix. Include the URLconf once per project. The main
446
+ API's URL namespace is `vintasend_templates_management_api`, so its routes reverse as
447
+ `vintasend_templates_management_api:<name>` and do not collide with
448
+ [`vintasend-api`](https://github.com/vintasoftware/vintasend-api)'s when one project mounts both.
449
+
450
+ The app reads these settings from your settings module. Each has a default, so leave out what
451
+ you do not use:
452
+
453
+ | Setting | Required | Default | Description |
454
+ | --- | --- | --- | --- |
455
+ | `MANAGED_TEMPLATE_SERVICE_FACTORY` | yes | `""` | Dotted path to the callable building your service. See [Configuring your service](#configuring-your-service). |
456
+ | `VINTASEND_API_KEY` | unless an authenticator is set | `""` | Shared secret clients send as a bearer token. |
457
+ | `VINTASEND_API_AUTHENTICATOR` | no | unset | `(request) -> None`, replacing the shared-key check. See below. |
458
+ | `MANAGED_TEMPLATE_BACKEND_NAME` | no | `""` | Backend name every new template is stored under. See [Backend name](#backend-name). |
459
+ | `MANAGED_TEMPLATE_ACTOR_RESOLVER` | no | unset | `(request) -> str \| None`, who made a status change. See [Attribution](#attribution). |
460
+ | `MANAGED_TEMPLATE_UNHANDLED_ERROR_HANDLER` | no | unset | `(exc, request, request_id) -> None`. See [Unexpected errors](#unexpected-errors). |
461
+ | `VINTASEND_API_CORS_ORIGINS` | no | `[]` | Browser origins allowed to call the API, as a list. Only read by the optional CORS middleware. |
462
+
463
+ The three callables take a dotted path or the callable itself. The required settings, and a
464
+ callable that cannot be imported, fail `manage.py check`, the same as in the standalone project.
465
+
466
+ Nothing else from the standalone project is needed: not its settings module, its middleware or
467
+ its `handler404`. Two of those are optional extras:
468
+
469
+ - **CORS.** Add `"vintasend_templates_management_api.templates_manager.cors.CorsMiddleware"` to
470
+ `MIDDLEWARE` only if a browser calls the API directly. It applies to the API's routes under
471
+ whatever prefix you mount them, and to nothing else your project serves.
472
+ - **A JSON 404.** Errors from the API's routes always use the `{ error }` envelope. A path that
473
+ matches no route at all gets your project's 404 page. To answer those in the envelope too, set
474
+ `handler404 = "vintasend_templates_management_api.templates_manager.views.envelope_404"` in
475
+ your root URLconf. It applies to every unmatched path in your project, not only the API's.
476
+
477
+ ### Authenticating callers yourself
478
+
479
+ `VINTASEND_API_AUTHENTICATOR` names a callable `(request) -> None` that runs before every
480
+ `/api/v1` route. Return to let the request through. Raise `ApiError("UNAUTHORIZED", ...)` for a
481
+ caller with no valid credential and `ApiError("FORBIDDEN", ...)` for one you know and refuse: a
482
+ 401 would tell a signed-in user to sign in again. Both reach the client in the contract's error
483
+ envelope. The callable may be `async`. `/health` is never authenticated.
484
+
485
+ To check a token yourself, such as one your identity provider issued, read it with
486
+ `bearer_token`. It takes the request or the header's value, matches the `Bearer` scheme in any
487
+ case, and returns `None` when there is no token:
488
+
489
+ ```python
490
+ # myproject/templates_api.py
491
+ from vintasend_templates_management_api.templates_manager.auth import ApiError, bearer_token
492
+
493
+
494
+ def authenticate(request):
495
+ token = bearer_token(request)
496
+ user = verify_token(token) if token is not None else None # your own verification
497
+ if user is None:
498
+ raise ApiError.unauthorized("Sign in to manage templates.")
499
+ if not user.can_edit_templates:
500
+ raise ApiError.forbidden("You cannot manage templates.")
501
+ request.templates_user = user # for the actor resolver below
502
+
503
+
504
+ def resolve_actor(request):
505
+ return request.templates_user.email
506
+ ```
507
+
508
+ ```python
509
+ # settings.py
510
+ VINTASEND_API_AUTHENTICATOR = "myproject.templates_api.authenticate"
511
+ MANAGED_TEMPLATE_ACTOR_RESOLVER = "myproject.templates_api.resolve_actor"
512
+ ```
513
+
514
+ The authenticator only decides who may call. Who made a status change is still
515
+ `MANAGED_TEMPLATE_ACTOR_RESOLVER`'s answer, as in [Attribution](#attribution).
516
+
517
+ The setting has the same name and shape in `vintasend-api`, so a project mounting both can
518
+ point them at one function, and that function may raise either package's `ApiError`: a refusal
519
+ is recognised by its class name and its code, as the TypeScript packages do, not by its class.
520
+ Only `UNAUTHORIZED` and `FORBIDDEN` count as a refusal; an `ApiError` with any other code, like
521
+ any other exception, is an unexpected error and answers 500.
522
+
523
+ ## Running it on its own
524
+
525
+ The package also carries a complete Django project, for a deployment that runs the API as a
526
+ service of its own. Configure it through environment variables (see
527
+ [Environment variables](#environment-variables)), or through a `.env` file in the directory you
528
+ start it from. The project does not depend on a WSGI server, so install one alongside it:
529
+
530
+ ```bash
531
+ pip install vintasend-templates-management-api gunicorn
532
+ export DJANGO_SETTINGS_MODULE=vintasend_templates_management_api.settings
533
+ django-admin check # fails on missing or unusable settings
534
+ gunicorn vintasend_templates_management_api.wsgi:application --bind 0.0.0.0:3334
535
+ ```
536
+
537
+ Your service factory must be importable by the process, so install it as a package or put its
538
+ directory on `PYTHONPATH`. `vintasend_templates_management_api.asgi:application` is there for an
539
+ ASGI server, though the API is synchronous throughout.
540
+
541
+ ## Getting started
542
+
543
+ To work on the API itself, from a checkout:
544
+
545
+ ```bash
546
+ poetry install
547
+ cp .env.example .env
548
+ ```
549
+
550
+ Then configure the service the API should read from (below), and run:
551
+
552
+ ```bash
553
+ poetry run python manage.py runserver 0.0.0.0:3334
554
+ ```
555
+
556
+ ## Configuring your service
557
+
558
+ The API ships no template store of its own. Point `MANAGED_TEMPLATE_SERVICE_FACTORY` at a
559
+ callable that returns a configured service:
560
+
561
+ ```python
562
+ # vintasend_templates_management_api/vintasend_config.py
563
+ from vintasend_managed_templates.managed_template_renderer import ManagedTemplateEmailRenderer
564
+ from vintasend_managed_templates.managed_template_service import ManagedTemplateService
565
+
566
+
567
+ def create_template_service():
568
+ backend = ... # your BaseTemplateManagerBackend
569
+ renderer = ManagedTemplateEmailRenderer(backend, ...) # wraps an ordinary renderer
570
+
571
+ return ManagedTemplateService(
572
+ template_manager_backend=backend,
573
+ template_renderer=renderer,
574
+ )
575
+ ```
576
+
577
+ Start from
578
+ [`vintasend_config.example.py`](./vintasend_templates_management_api/vintasend_config.example.py),
579
+ copying it to `vintasend_templates_management_api/vintasend_config.py` (gitignored) in a checkout.
580
+ With the package installed, the factory lives in any module of yours the process can import,
581
+ such as `myproject.templates_api.create_template_service`. The factory is called once per process and its result reused, so it must be safe to call once and the
582
+ service it returns must be safe to share across requests.
583
+
584
+ Only the preview endpoint uses the renderer, so a deployment that never previews can pass
585
+ one that raises. A preview never uses a fallback registered on the renderer: it renders a stored
586
+ version or reports a 404.
587
+
588
+ ### Attribution
589
+
590
+ Status routes record a `changedBy` in the audit trail. By default it comes from the request
591
+ body, which is only safe when everyone holding the API key is trusted to attribute honestly.
592
+ When the host knows who is calling (a gateway header, a session it validated), point
593
+ `MANAGED_TEMPLATE_ACTOR_RESOLVER` at a callable that answers it:
594
+
595
+ ```python
596
+ # vintasend_templates_management_api/vintasend_config.py
597
+ def resolve_actor(request) -> str | None:
598
+ return request.headers.get("X-Authenticated-User") # set by your trusted proxy
599
+ ```
600
+
601
+ ```bash
602
+ MANAGED_TEMPLATE_ACTOR_RESOLVER=vintasend_templates_management_api.vintasend_config.resolve_actor
603
+ ```
604
+
605
+ When it is set, its answer is what `/status`, `/activate`, `/deactivate` and `/archive` all
606
+ record, and any `changedBy` in the body is ignored. `None` records the change as unattributed.
607
+ The resolver may be `async`. The body field stays optional in the contract, so existing clients
608
+ keep working. In `settings.py` you can also assign the callable itself rather than a dotted path.
609
+
610
+ ### Backend name
611
+
612
+ `POST /templates` stores the `templateManagedBackend` its body carries. A host that serves one
613
+ template backend sets `MANAGED_TEMPLATE_BACKEND_NAME`, and every new template is stored under that
614
+ name instead, so a caller cannot label a template with another backend:
615
+
616
+ ```bash
617
+ MANAGED_TEMPLATE_BACKEND_NAME=medplum
618
+ ```
619
+
620
+ Unset, the body's value is stored. The body field stays required either way, so clients written
621
+ against the contract keep working.
622
+
623
+ ### Unexpected errors
624
+
625
+ `MANAGED_TEMPLATE_UNHANDLED_ERROR_HANDLER` names a callable `(exc, request, request_id) -> None`
626
+ that receives every error the API does not map to a contract error, in place of the default
627
+ one-line log. It may be `async`. It gets the exception itself, so keeping health data out of
628
+ wherever it sends it is your responsibility. If it raises, the default line is logged instead
629
+ and what it raised is not; the client gets the same generic 500 with the same `X-Request-Id`
630
+ either way.
631
+
632
+ ```python
633
+ def report_unhandled_error(exc, request, request_id):
634
+ error_tracker.capture(exc, tags={"request_id": request_id}) # with its own scrubbing
635
+ ```
636
+
637
+ The lifecycle is your service's, not this API's. A `ManagedTemplateService` subclass with
638
+ its own `ALLOWED_STATUS_TRANSITIONS`, or one built with
639
+ `validate_status_transitions=False`, is reported accurately through `allowedTransitions`
640
+ and enforced through the same 409.
641
+
642
+ ## Environment variables
643
+
644
+ | Variable | Required | Description |
645
+ | --- | --- | --- |
646
+ | `VINTASEND_API_KEY` | unless an authenticator is set | Shared secret clients must send as a bearer token. |
647
+ | `VINTASEND_API_AUTHENTICATOR` | no | Dotted path to `(request) -> None`, replacing the shared-key check. See [Authenticating callers yourself](#authenticating-callers-yourself). |
648
+ | `MANAGED_TEMPLATE_SERVICE_FACTORY` | yes | Dotted path to the callable building your service. |
649
+ | `VINTASEND_API_CORS_ORIGINS` | no | Comma-separated browser origins allowed to call the API. |
650
+ | `MANAGED_TEMPLATE_BACKEND_NAME` | no | Backend name every new template is stored under, replacing the create body's. See [Backend name](#backend-name). |
651
+ | `MANAGED_TEMPLATE_ACTOR_RESOLVER` | no | Dotted path to `(request) -> str \| None`, who made a status change. See [Attribution](#attribution). |
652
+ | `MANAGED_TEMPLATE_UNHANDLED_ERROR_HANDLER` | no | Dotted path to `(exc, request, request_id) -> None`. See [Unexpected errors](#unexpected-errors). |
653
+ | `DJANGO_SECRET_KEY` | no | Django requires one; this API signs nothing. |
654
+ | `DJANGO_DEBUG` / `DJANGO_ALLOWED_HOSTS` / `DJANGO_LOG_LEVEL` | no | Standard Django knobs. |
655
+ | `DJANGO_DB_*` | no | Only needed by backends that resolve their models through Django. |
656
+
657
+ The required ones are enforced by a Django system check, so a deployment missing one fails
658
+ on `manage.py check` and on `runserver` rather than on the first request. The three hooks are
659
+ checked the same way: a dotted path that does not import, or names something that is not
660
+ callable, fails the check. Run
661
+ `manage.py check` in your release step if you serve with gunicorn.
662
+
663
+ ## Development
664
+
665
+ ```bash
666
+ poetry run python manage.py runserver # dev server
667
+ poetry run pytest # tests
668
+ poetry run ruff check . # lint
669
+ poetry run ruff format . # format
670
+ poetry run mypy # type-check
671
+ poetry run python manage.py export_openapi # regenerate openapi.yaml
672
+ poetry run python manage.py export_openapi --check # fail if it is stale
673
+ ```
674
+
675
+ Tests drive the real Django application through the test client over a real
676
+ `ManagedTemplateService` composed of in-memory seams. Faking the *seams* rather than the
677
+ service is deliberate: version resolution, the transition table and filter validation are
678
+ the service's behaviour, and this API's job is to expose it faithfully — a fake service
679
+ would let a route drift from the thing it is meant to be exposing without the suite
680
+ noticing.
681
+
682
+ ## Design notes
683
+
684
+ Worth knowing if you are implementing this contract elsewhere, or wondering why something
685
+ is missing.
686
+
687
+ **Filters are dropped; orders are refused.** Both are negotiated against the same
688
+ capability report, and they resolve in opposite directions. That is deliberate:
689
+
690
+ | | Unsupported filter | Unsupported order |
691
+ |---|---|---|
692
+ | What happens | dropped, request succeeds | `400 BAD_REQUEST` |
693
+ | If it were ignored | more rows than asked for | the same rows, arbitrary sequence |
694
+ | Can the client tell? | yes, from the rows | **no** |
695
+
696
+ A client rendering an unordered page under a highlighted "sorted by name" column header is
697
+ displaying a sort that never happened, and nothing in the response says so. Sending
698
+ `orderByDirection` without `orderByField` is a 400 for the same reason — ignoring it looks
699
+ exactly like a backend that cannot sort, which hides the client bug.
700
+
701
+ **Neither ordering parameter has a default.** Every `orderBy.*` capability defaults to
702
+ `false`, so a default field would make the ordinary listing a 400 against most backends.
703
+ Omitted asks for the backend's own order — which is what an unordered listing has always
704
+ returned. Note this differs from `vintasend-api`, which *does* default its order; there,
705
+ every notification backend can sort.
706
+
707
+ Read `GET /api/v1/capabilities` and offer only the columns it reports as sortable.
708
+
709
+ **Pagination needs no negotiation.** `vintasend-api` reads `pagination.oneIndexed` off
710
+ each backend because notification backends genuinely differ. Here `ManagedTemplateService`
711
+ validates `page >= 1` itself and no call reaches a backend without passing through that
712
+ validation, so the wire's 1-indexing *is* the service's convention.
713
+
714
+ **Capabilities are declared, not required.** `BaseTemplateManagerBackend.get_filter_capabilities`
715
+ is concrete and returns `{}`, so a backend that says nothing keeps working. As with
716
+ notifications, a backend declares only what it *cannot* do, and `ManagedTemplateService`
717
+ merges its report over the library's default.
718
+
719
+ The `orderBy.*` keys are the one exception to "a missing key means supported": they
720
+ default to `false`. Ordering is newer vocabulary than the filters, so a `true` default
721
+ would have every backend written before it existed claim an order it silently ignores. A
722
+ backend that can sort declares it — and should verify each claim by *running* the sort, not
723
+ by reading its store's documentation.
724
+
725
+ **Composition is reported, not enforced on write.** A template that extends a base which
726
+ does not exist yet is stored without complaint: a UI drafting a set of templates would
727
+ otherwise have to create them in dependency order, and a base can legitimately be written
728
+ after the child that names it. The cost is that a broken reference is found on read rather
729
+ than on save — which is why `GET /templates/{key}/composition` exists and returns the
730
+ library's message verbatim, and why a save-then-check round trip is the pattern for an
731
+ editor that wants to warn before publishing. Publishing is where it matters: a template
732
+ that cannot be assembled cannot be sent, so check before `POST /templates/{key}/activate`.
733
+
734
+ **Preview contexts are plain dicts.** The renderer seam types a context as
735
+ `NotificationContextDict`, but that class cannot represent an ordinary nested object: its
736
+ validation requires every value inside a nested `dict` to itself be a
737
+ `NotificationContextDict`, with no base case for a scalar, so `{"user": {"name": "Ana"}}`
738
+ is inexpressible. `None` values and lists of strings are refused for related reasons, and
739
+ both appear in real stored contexts. Since a notification's stored `context_used` is a
740
+ plain dict and renderers only read from a context, the caller's object is passed through —
741
+ the same thing `vintasend-api` does. A preview that refused what a real send carries would
742
+ be a worse guide to production than no preview.
743
+
744
+ **Updates create versions.** `POST /templates/{key}/versions` rather than a `PATCH`,
745
+ because the backend copies the latest version forward and applies the non-`None` fields to
746
+ the copy. An already-published version is never modified, and the route shape says so.
747
+
748
+ **Deletion is per version.** The seam has no operation removing every version of a key, so
749
+ neither does this API; doing it as a loop here would be a multi-step deletion with no
750
+ transaction around it.
751
+
752
+ **Retagging is the one write that does not create a version.** `PUT /templates/{key}/tags`
753
+ edits a version in place, unlike every other write on a template. Tags describe how a
754
+ template is *found*, not what it renders, so relabelling one for search should not fork a
755
+ version and drop it back to draft. To change tags and content together, send `tags` on
756
+ `POST /templates/{key}/versions` instead — where an omitted `tags` carries the previous
757
+ version's forward and `[]` clears them.
758
+
759
+ **Tag listing is paged in this API, not in the store.** The template-manager seam has no
760
+ paginated tag read, so `GET /tags` slices the page in process. That is sound in a way
761
+ in-process *ordering* would not be: `get_tags` returns the whole set in one stable order,
762
+ so a page is a slice of a complete list rather than a re-sort of an arbitrary window.
763
+
764
+ **`fields.mostRecentActiveVersion` is a capability of its own.** It is not a column test like
765
+ every other field: a backend answers it by comparing a row against the other versions of its
766
+ key, which a store keeping no version history cannot do. Declining it means the list endpoint
767
+ drops it and returns a row per version — the honest answer for a store that has only one.
768
+
769
+ **The two tag filters are separate capabilities.** `fields.includesAllTags` and
770
+ `fields.includesAnyOfTags` are different queries — the first needs a per-template count
771
+ over the tags asked for, the second only needs membership — so a backend can support one
772
+ without the other. As with every filter, a declined one is dropped rather than failing the
773
+ request.
774
+
775
+ ## License
776
+
777
+ MIT
778
+