A theme has to answer for a kind of document it has never seen #132

Closed
opened 2026-09-09 10:25:20 +00:00 by tiagoagueda · 1 comment
Owner

Observation

all of them plaing nice with possible template extentions

Second prerequisite. A theme currently promises to render two things; five kinds turn that
promise into a matrix, and the way a theme is chosen forbids the extension being asked for.

What exists

A theme is two things at once. In the model it is a fixed choice:

class Theme(models.TextChoices):
    PLAIN = "plain", _("Plain")
    CLASSIC = "classic", _("Classic")

and the docstring says the constraint out loud:

Kept as choices rather than user-editable rows: a theme is a Django template plus a
stylesheet, and letting people upload those would mean executing their markup during
rendering. User themes belong behind a deliberate decision, not in the first version.

On disk it is a directory with one template per kind:

templates/documents/themes/plain/cv.html      classic/cv.html
templates/documents/themes/plain/letter.html  classic/letter.html

and rendering builds the path from the two: f"documents/themes/{cv.theme}/cv.html".

Two themes and two kinds is four templates. Five kinds is ten, and a theme that has not been
taught a kind resolves to a template that does not exist — a TemplateDoesNotExist at the
moment somebody presses Export PDF.

What this asks for

A rule for what a theme owes a kind it has never heard of, and a way for themes to arrive
from outside without handing a stranger's markup to the renderer.

Worth being careful about

"Template extensions" runs straight into that docstring. The refusal is not squeamish:
rendering executes the template, so an uploadable theme is remote code execution by design.
Anything here has to say which of these it means — a curated set vendored into the image
(the postulo-templates repository is that shape), a theme shipped inside an installed
plugin package and therefore already trusted as much as the plugin is, or genuinely
user-supplied markup, which needs a sandboxed engine and is a different project.

A fallback is a design decision, not a safety net. If a kind with no template in the
chosen theme falls back to plain, somebody's classic CV arrives beside a plain portfolio
and the pair looks unrelated. If it refuses instead, the kind cannot be used with that theme
and the interface has to say so before the export button, not after.

Theme as TextChoices is a migration boundary. Themes from packages are not choices
known at migration time; the field becomes a plain string with validation, exactly as
kind on a plugin already is, and every existing row keeps its value.

Two axes, not one. A theme is per document (CV.theme, CoverLetter.theme). Five kinds
means the picker on each kind must offer only the themes that can render that kind, which
is a registry question rather than a template question.

Direction and language belong to the template too. #67 laid the interface out for
right-to-left and document_direction() gives a rendered document its own direction; a
theme arriving from outside has to honour that or an Arabic CV renders left to right. That
is a thing to state in the contract rather than to hope for.

The postulo-templates repository already exists as a curated collection for CVs and
letters. Whatever is decided here decides what that repository is for: a place to copy
from by hand, or something an instance installs.

## Observation > all of them plaing nice with possible template extentions Second prerequisite. A theme currently promises to render two things; five kinds turn that promise into a matrix, and the way a theme is chosen forbids the extension being asked for. ## What exists A theme is two things at once. In the model it is a fixed choice: ```python class Theme(models.TextChoices): PLAIN = "plain", _("Plain") CLASSIC = "classic", _("Classic") ``` and the docstring says the constraint out loud: > Kept as choices rather than user-editable rows: a theme is a Django template plus a > stylesheet, and letting people upload those would mean executing their markup during > rendering. User themes belong behind a deliberate decision, not in the first version. On disk it is a directory with one template per kind: ``` templates/documents/themes/plain/cv.html classic/cv.html templates/documents/themes/plain/letter.html classic/letter.html ``` and rendering builds the path from the two: `f"documents/themes/{cv.theme}/cv.html"`. Two themes and two kinds is four templates. Five kinds is ten, and a theme that has not been taught a kind resolves to a template that does not exist — a `TemplateDoesNotExist` at the moment somebody presses *Export PDF*. ## What this asks for A rule for what a theme owes a kind it has never heard of, and a way for themes to arrive from outside without handing a stranger's markup to the renderer. ## Worth being careful about **"Template extensions" runs straight into that docstring.** The refusal is not squeamish: rendering executes the template, so an uploadable theme is remote code execution by design. Anything here has to say which of these it means — a curated set vendored into the image (the `postulo-templates` repository is that shape), a theme shipped inside an installed plugin package and therefore already trusted as much as the plugin is, or genuinely user-supplied markup, which needs a sandboxed engine and is a different project. **A fallback is a design decision, not a safety net.** If a kind with no template in the chosen theme falls back to `plain`, somebody's classic CV arrives beside a plain portfolio and the pair looks unrelated. If it refuses instead, the kind cannot be used with that theme and the interface has to say so *before* the export button, not after. **`Theme` as `TextChoices` is a migration boundary.** Themes from packages are not choices known at migration time; the field becomes a plain string with validation, exactly as `kind` on a plugin already is, and every existing row keeps its value. **Two axes, not one.** A theme is per document (`CV.theme`, `CoverLetter.theme`). Five kinds means the picker on each kind must offer only the themes that can render *that* kind, which is a registry question rather than a template question. **Direction and language belong to the template too.** #67 laid the interface out for right-to-left and `document_direction()` gives a rendered document its own direction; a theme arriving from outside has to honour that or an Arabic CV renders left to right. That is a thing to state in the contract rather than to hope for. **The `postulo-templates` repository already exists** as a curated collection for CVs and letters. Whatever is decided here decides what that repository is *for*: a place to copy from by hand, or something an instance installs.
tiagoagueda added this to the 0.3.0 milestone 2026-09-09 10:25:20 +00:00
Author
Owner

Done in 3332d8a1.

The two questions, answered together

What a theme owes a kind it has never seen: nothing, provided it says so first. A theme declares what it sets by having a template for it — templates maps a kind to a path, and there is no second place to keep the list, so the declaration cannot drift from what is on disk. The picker on each form offers only the themes that set that kind.

The issue put the alternative fairly: a fallback to plain means somebody's Classic CV arrives beside a plain portfolio and the pair does not look like one person's application. That is not a milder failure than refusing — it is the same failure, discovered after the envelope is sealed. So refusing is the answer, and before the export button is where: the pair is never offered, and reaching it directly raises CannotRender with a sentence rather than a TemplateDoesNotExist naming a file nobody wrote.

A name nothing recognises is a different question and gets the opposite answer. A theme that cannot set this kind is a live choice somebody could still make and must not. A theme left behind by a plugin that was uninstalled is a row remembering something that has gone — refusing there would mean removing a plugin had quietly taken somebody's CV with it. So an unknown name falls back to plain and the document still exports; nothing is inconsistent with anything, because there is no other theme to clash with. The edit form goes one step further and opens on plain rather than erroring about a field nobody touched: the CV already looks like that, and the form stops pretending otherwise.

Theme as TextChoices was indeed a migration boundary

Choices are written into every migration that touches the field, so a theme from an installed plugin could never have been one. documents/0006_theme_is_a_name.py drops them: the column is a plain name now, max_length 20 → 60, and every existing row keeps its value. The rule moves onto the field as a @deconstructible validator — SetsThisKind("cv") — so it travels with the column and answers the API, full_clean() and a fixture, not only the form.

get_theme_display() had to go with the choices, which turned out to be the tell: a label read out of choices can only ever name a theme compiled into the model, so a plugin's theme would have shown as a bare slug everywhere it appeared. theme_label asks the registry instead.

Where a theme may come from

You listed three candidates and the answer is two of them.

  • Vendored into the image — plain and classic, as now.
  • Inside an installed plugin — trusted exactly as much as the plugin is, which is a decision an administrator made with the author, licence and source in front of them (#94).
  • Markup somebody uploads — no, and not a gap. Rendering executes the template, so an uploadable theme is remote code execution with a file picker on it. The docstring the issue quoted was right; it is now in documents/themes.py with the reasoning rather than as a refusal to reconsider.

plugins/themes.py is the door for the second, and it is the same rule locale.py already states for translations: a plugin brings a templates/ directory beside its package, and registering the plugin puts it on Django's search path — appended, so Postulo's own directory stays first and a plugin can add a page of markup but never replace one. Between plugins the first registered wins. The outward directory walk is now shared: nearest_directory(module, name) with "locale" and "templates", rather than the same loop twice.

A plugin that declares no theme gets no template directory. An unused search path is a file somebody can shadow by accident.

Two axes, and the one that reads the picker

Themes are per document (CV.theme, CoverLetter.theme), and the kind is per form — so for_kind() is the registry question the issue said it was, and choices_for() is what the picker asks. ThemeKind is deliberately not DocumentKind: that one names what a file is, and most of its values (certificate, reference, portfolio) arrive as uploads and are never rendered. The theme vocabulary is the shorter list of things Postulo composes itself, and it grows when one is written rather than when a new sort of file is accepted.

The picker also names the provider — "Vellum, from Vellum Press" — where a plugin gives one. The menu is the only place a person meets a theme, and a theme is markup that runs when they export; whose it is belongs beside the name.

Direction and language

Part of the contract, stated in the module rather than hoped for. Postulo's own get it from base_cv.html / base_letter.html; a theme from outside is free not to extend those and then owes the lang and dir itself. Each shipped theme is held to it by a test that renders an Arabic CV and looks for dir="rtl", and the end-to-end plugin test renders a Hebrew letter through a theme that does not extend the base.

What this means for postulo-templates

The repository is now a collection of plugins, not of loose template directories — each with a manifest, a templates/ directory, and one Theme per way of setting a document. docs/PLUGINS.md gains A theme, if you set documents with the shape to copy.

Also

  • Theme and ThemeKind join postulo.plugins.api, which settles one of the two questions that module listed as open. What a dashboard widget is handed (#125) is the one left.
  • A shipped theme covering every kind Postulo has is now a test, not a habit — that is what makes "declare what you set" safe to allow.
  • tests/test_document_themes.py, 25 tests. Full suite 4106 passed, 29 skipped; browser suite 54 passed.
Done in `3332d8a1`. ## The two questions, answered together **What a theme owes a kind it has never seen: nothing, provided it says so first.** A theme declares what it sets *by having a template for it* — `templates` maps a kind to a path, and there is no second place to keep the list, so the declaration cannot drift from what is on disk. The picker on each form offers only the themes that set that kind. The issue put the alternative fairly: a fallback to `plain` means somebody's Classic CV arrives beside a plain portfolio and the pair does not look like one person's application. That is not a milder failure than refusing — it is the same failure, discovered after the envelope is sealed. So refusing is the answer, and *before the export button* is where: the pair is never offered, and reaching it directly raises `CannotRender` with a sentence rather than a `TemplateDoesNotExist` naming a file nobody wrote. **A name nothing recognises is a different question and gets the opposite answer.** A theme that cannot set this kind is a live choice somebody could still make and must not. A theme left behind by a plugin that was uninstalled is a row remembering something that has gone — refusing there would mean removing a plugin had quietly taken somebody's CV with it. So an unknown name falls back to `plain` and the document still exports; nothing is inconsistent with anything, because there is no other theme to clash with. The edit form goes one step further and opens on `plain` rather than erroring about a field nobody touched: the CV already looks like that, and the form stops pretending otherwise. ## `Theme` as `TextChoices` was indeed a migration boundary Choices are written into every migration that touches the field, so a theme from an installed plugin could never have been one. `documents/0006_theme_is_a_name.py` drops them: the column is a plain name now, `max_length` 20 → 60, and every existing row keeps its value. The rule moves onto the field as a `@deconstructible` validator — `SetsThisKind("cv")` — so it travels with the column and answers the API, `full_clean()` and a fixture, not only the form. `get_theme_display()` had to go with the choices, which turned out to be the tell: a label read out of `choices` can only ever name a theme compiled into the model, so a plugin's theme would have shown as a bare slug everywhere it appeared. `theme_label` asks the registry instead. ## Where a theme may come from You listed three candidates and the answer is two of them. - **Vendored into the image** — `plain` and `classic`, as now. - **Inside an installed plugin** — trusted exactly as much as the plugin is, which is a decision an administrator made with the author, licence and source in front of them (#94). - **Markup somebody uploads** — no, and not a gap. Rendering executes the template, so an uploadable theme is remote code execution with a file picker on it. The docstring the issue quoted was right; it is now in `documents/themes.py` with the reasoning rather than as a refusal to reconsider. `plugins/themes.py` is the door for the second, and it is the same rule `locale.py` already states for translations: a plugin brings a `templates/` directory beside its package, and registering the plugin puts it on Django's search path — **appended**, so Postulo's own directory stays first and a plugin can add a page of markup but never replace one. Between plugins the first registered wins. The outward directory walk is now shared: `nearest_directory(module, name)` with `"locale"` and `"templates"`, rather than the same loop twice. A plugin that declares no theme gets no template directory. An unused search path is a file somebody can shadow by accident. ## Two axes, and the one that reads the picker Themes are per document (`CV.theme`, `CoverLetter.theme`), and the kind is per form — so `for_kind()` is the registry question the issue said it was, and `choices_for()` is what the picker asks. `ThemeKind` is deliberately *not* `DocumentKind`: that one names what a *file* is, and most of its values (certificate, reference, portfolio) arrive as uploads and are never rendered. The theme vocabulary is the shorter list of things Postulo composes itself, and it grows when one is written rather than when a new sort of file is accepted. The picker also names the provider — "Vellum, from Vellum Press" — where a plugin gives one. The menu is the only place a person meets a theme, and a theme is markup that runs when they export; whose it is belongs beside the name. ## Direction and language Part of the contract, stated in the module rather than hoped for. Postulo's own get it from `base_cv.html` / `base_letter.html`; a theme from outside is free not to extend those and then owes the `lang` and `dir` itself. Each shipped theme is held to it by a test that renders an Arabic CV and looks for `dir="rtl"`, and the end-to-end plugin test renders a Hebrew letter through a theme that does *not* extend the base. ## What this means for `postulo-templates` The repository is now a collection of **plugins**, not of loose template directories — each with a manifest, a `templates/` directory, and one `Theme` per way of setting a document. `docs/PLUGINS.md` gains **A theme, if you set documents** with the shape to copy. ## Also - `Theme` and `ThemeKind` join `postulo.plugins.api`, which settles one of the two questions that module listed as open. What a dashboard widget is handed (#125) is the one left. - A shipped theme covering every kind Postulo has is now a test, not a habit — that is what makes "declare what you set" safe to allow. - `tests/test_document_themes.py`, 25 tests. Full suite 4106 passed, 29 skipped; browser suite 54 passed.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Reference
Postulo/postulo#132
No description provided.