A theme has to answer for a kind of document it has never seen #132
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Blocks
#133 A document is a kind, and a kind is a plugin
Postulo/postulo
Reference
Postulo/postulo#132
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Observation
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:
and the docstring says the constraint out loud:
On disk it is a directory with one template per kind:
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
TemplateDoesNotExistat themoment 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-templatesrepository is that shape), a theme shipped inside an installedplugin 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 portfolioand 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.
ThemeasTextChoicesis a migration boundary. Themes from packages are not choicesknown at migration time; the field becomes a plain string with validation, exactly as
kindon 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 kindsmeans 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; atheme 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-templatesrepository already exists as a curated collection for CVs andletters. Whatever is decided here decides what that repository is for: a place to copy
from by hand, or something an instance installs.
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 —
templatesmaps 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
plainmeans 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 raisesCannotRenderwith a sentence rather than aTemplateDoesNotExistnaming 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
plainand 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 onplainrather than erroring about a field nobody touched: the CV already looks like that, and the form stops pretending otherwise.ThemeasTextChoiceswas indeed a migration boundaryChoices 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.pydrops them: the column is a plain name now,max_length20 → 60, and every existing row keeps its value. The rule moves onto the field as a@deconstructiblevalidator —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 ofchoicescan 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_labelasks the registry instead.Where a theme may come from
You listed three candidates and the answer is two of them.
plainandclassic, as now.documents/themes.pywith the reasoning rather than as a refusal to reconsider.plugins/themes.pyis the door for the second, and it is the same rulelocale.pyalready states for translations: a plugin brings atemplates/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 — sofor_kind()is the registry question the issue said it was, andchoices_for()is what the picker asks.ThemeKindis deliberately notDocumentKind: 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 thelanganddiritself. Each shipped theme is held to it by a test that renders an Arabic CV and looks fordir="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-templatesThe repository is now a collection of plugins, not of loose template directories — each with a manifest, a
templates/directory, and oneThemeper way of setting a document.docs/PLUGINS.mdgains A theme, if you set documents with the shape to copy.Also
ThemeandThemeKindjoinpostulo.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.tests/test_document_themes.py, 25 tests. Full suite 4106 passed, 29 skipped; browser suite 54 passed.