Implementation plan for {{<dimension>}} placeholder substitution in content
blocks (#349): an author writes heading: 360Β° feedback for {{subject}} and
each participant sees the heading with their resolved category values
substituted at render time. First consumer is the 360Β° pattern; the mechanism
is generic ({{site}}, {{cohort}}, {{rater_role}} all work).
Builds entirely on respondent categories (v0.20,
respondent-categories-implementation-plan.md).
No schema change β substitution happens in
_annotate_question_render_sequence, which already annotates
content_block_heading / content_block_subtitle / content_block_html.
Resolved decisions (issue #349 Β§Design decisions)
- Roster select, not free text (release 1). The batch form's existing
<select>over the dimension'sallowed_values(tokens.html, fed bysurvey_tokensPOST readingcategory_<dim>) is the roster β zero backend change. Free text would let a typo silently split a subject into two reporting groups. A "free-text dimension" flag for large rotating rosters shipped as the planned follow-up (#353):RespondentCategory.free_textrenders a text input on the batch form (stored values are unchanged), with a fuzzy near-duplicate warning and distinct-value counts on the Tokens page as the typo-split mitigations. - Batch-level subject. Matches how 360s are run (cohorts invited per
subject) and the shipped model:
survey_tokenswrites onecategory_valuesdict per batch. A per-token override needs a token-edit surface β defer. - Retention / DSR: category values are response metadata. Subject names
are staff data, not patient data, but still personal data. They persist in
SurveyAccessToken.category_values(survey-scoped; swept by survey deletion / retention),RespondentAssignment(cascades withSurveyProgressβ covered bytest_respondent_categories_models), and thecategory_*columns of the response and token CSV exports. Add a line todocs/compliance/data-subject-request-procedure.mdnoting: (a) these columns are included in access exports, (b) a rater's erasure removes the response and its assignments, but token-borne values remain on the token row until the survey's retention sweep β the DSR doc must say so. - Thank-you page: echo yes. The resolved values are the rater's own
context. Stash them in the session at submission
(
category_values_<slug>, same one-time pattern asreceipt_token_<slug>), pop insurvey_thank_you, render as small badges next to the receipt token, auto-escaped.
Edge cases (from the #349 design conversation)
- Delphi inter-round feedback. Substitution applies to author-written
text only β
heading/subtitle/ authoredbody_md. The system-generated feedback body (is_delphi_feedbackbranch of the annotator,_render_delphi_feedback_summary) is not substituted; it is already context-specific. - Outline export/import round-trip.
{{subject}}inbody_md/heading:must survive export β import as literal text. Add a round-trip test next totest_export_content_block_round_trip(test_content_block.py). Placeholders belong in theheading:/subtitle:config lines and the body β not the internal label, where## Name {slug}braces could be misparsed as a slug. - Anonymous / no-progress renders. No
SurveyProgressβ noRespondentAssignmentrows β placeholders render empty on the live path (same semantics as visibility rules: unresolved = not shown). The preview path uses the?simulate_category=values; the Simulate category panel is the documented way to see substituted values. Add a one-line hint to the panel when any content block contains a placeholder, so the author is never shown a bare{{subject}}with no explanation.
Substitution contract
- Syntax:
{{<dimension>}}inoptions.heading,options.subtitle,options.body_md, eachoptions.links[].label, andoptions.consent.statementofcontent_blockquestions (link labels and consent statements since issue #352). Nothing else substitutes (question.textβ follow-up if asked for). - Resolution per path:
- Live (
_handle_participant_submission): the map already computed by_resolve_categories_for_progress(hoist the local so the annotator sees it);{}when there is no progress or no categories. - Preview (
survey_preview): the parsed?simulate_category=values (sim_values);{}when absent. - Builder detail (
survey_detail): no substitution β the author sees the raw placeholder, exactly as typed. - Rules:
- Unknown dimension (not defined on the survey): left literal β
{{subjekt}}renders visibly so the author catches it in preview,|default:segment included (the fallback never masks a typo). - Known but unresolved: the optional
{{dim|default:<text>}}fallback when present (issue #351), else an empty string. - Escaping:
heading/subtitlesubstitute as plain strings and rely on template auto-escaping (detail.htmlemits them unmarked).body_mdsubstitutes afterrender_content_block_markdown, insertinghtml.escape(value, quote=True)into the sanitised HTML β never through|safeunescaped. Post-sanitisation insertion is safe because the inserted text is entity-escaped and cannot form tags or attributes. - Placeholders inside code spans / link labels also substitute (harmless, escaped).
- Plumbing: new optional kwarg β
_annotate_question_render_sequence(survey, questions, category_values=None).None= no substitution (builder); a dict = substitute. New pure helper_substitute_category_placeholders(text, category_values, known_dims, *, escape_values)next to the annotator, unit-tested in isolation. Known dimension names come fromsurvey.respondent_categories(fetched once per annotator call when a content block is present).
The |default: fallback (issue #351) is authored source text, not
participant data: it substitutes as typed on the plain-text fields
(template auto-escaping applies) and as-rendered on the body_md path β
markdown has already escaped and sanitised it with the body, so it is
never re-escaped. _CATEGORY_PLACEHOLDER_RE captures the optional
|default:... segment (no } allowed in the fallback text).
Commit plan
Each commit is self-contained: code + tests + the doc line it implements.
- Helper + annotator + live path. The kwarg, the helper, hoisting
resolvedin_handle_participant_submission. Tests (test_content_block_substitution.py): walkthrough (author defines roster β content block with{{subject}}β token batch β take β heading contains the name); unresolved β empty; unknown placeholder left literal; value with<,>,&,"escaped in output; Delphi feedback body not substituted; outline round-trip with{{subject}}(added next totest_export_content_block_round_trip). - Preview path. Pass
sim_valuesto the annotator; hint line in the Simulate category panel when a content block contains a placeholder. Tests: simulate shows the substituted heading; without simulate β empty; hint present. - Thank-you echo. Session snapshot + badges next to the receipt token. Tests: echoed after submit; absent on direct GET; auto-escaped.
- Docs + version bump.
survey-layouts-technical.mdΒ§Content blocks (this contract) and Β§360 pattern;publish-and-collection.mdtoken-batch category step; the DSR procedure line (decision 3); mark this plan's decisions resolved. Bump0.20.2β0.21.0(minor β new participant-facing feature, backward-compatible). Runs/lint.
Out of scope (follow-ups)
- ~~Per-block default text for unresolved dimensions
(e.g.
{{subject|default:your colleague}})~~ β shipped in issue #351 (_CATEGORY_PLACEHOLDER_REcaptures the optional|default:segment; see Β§Substitution contract). - ~~Substitution in link labels and consent statements~~ β shipped in
issue #352 (
_substituted_content_block_linkscopies each link with the label substituted; the consent statement substitutes in_annotate_question_render_sequence. Both substitute the raw, auto-escaped value. Consent statements are also part of the outline export/import grammar since issue #358 β the exporter emitsconsent:/consent_required:config lines and skips the linked yesno question, and the importer rebuilds both β so the{{subject}}placeholder in a consent statement is covered bytest_export_content_block_placeholder_round_tripalongside link labels). - Free-text dimension values (large rotating rosters).
- ~~Token-level subject override; "Generate full 360 set for a subject"
batch action; Tokens page grouping by subject~~ β shipped in issues
#354, #355, and #356: a per-invitation
category_valuesedit on the Tokens page (unused invitations only, audit-logged), a "Generate full 360 set" action (one batch perrater_rolevalue, all carrying the subject), and a subject filter on the token list. The flat CSV export is unchanged.