Technical reference for the survey layouts feature. For the user-facing guide, see Survey Layouts.
Overview
The layout feature adds a layout field to Survey and two related
models (SectionMenu, SectionMenuItem) that configure the Section menu
layout. The runtime applies a selection filter in the ordering pipeline
so that only chosen sections (plus mandatory ones) are rendered.
Prerequisite
The progress-tracking feature (PR #319, v0.12.0) must be merged. The following fields already exist and must NOT be re-added:
Survey.allow_resume(BooleanField, default True)Survey.allow_response_redaction(BooleanField, default True)SurveyProgress.selected_group_ids(JSONField, default list)SurveyProgress.resume_token,status,current_repeat_index,completed_at(seedocs/survey-progress-tracking-technical.md)- Migration
0059_progress_resume_and_redaction_fieldsis applied.
Data model
class Survey(models.Model):
...
layout = models.CharField(
max_length=20,
choices=[("linear", "Linear"), ("section_menu", "Section menu")],
default="linear",
)
# Already exists (v0.12.0, migration 0059) β do not re-add
allow_resume = models.BooleanField(default=True)
allow_response_redaction = models.BooleanField(default=True)
class SectionMenu(models.Model):
survey = models.OneToOneField(
Survey, related_name="section_menu", on_delete=models.CASCADE,
)
prompt_text = models.CharField(
default="Which sections would you like to complete?",
)
min_selected = models.PositiveIntegerField(default=1)
max_selected = models.PositiveIntegerField(null=True, blank=True) # null = no cap
order_mode = models.CharField(
max_length=20,
choices=[("authored", "As authored"), ("participant", "In pick order")],
default="authored",
)
show_select_all = models.BooleanField(default=False)
show_estimated_time = models.BooleanField(default=False)
class SectionMenuItem(models.Model):
menu = models.ForeignKey(SectionMenu, related_name="items", on_delete=models.CASCADE)
group = models.ForeignKey(QuestionGroup, on_delete=models.CASCADE)
is_pickable = models.BooleanField(default=True) # False = mandatory
order = models.PositiveIntegerField(default=0)
estimated_minutes = models.PositiveIntegerField(null=True, blank=True)
class Meta:
unique_together = ("menu", "group")
ordering = ["order", "id"]
SectionMenuItem rows are only created for surveys with
layout = section_menu. A linear survey has no rows and renders exactly
as it did before v0.13.0.
Migration: 0060_survey_layouts (single squashed migration that adds
the layout field and both models, including BigAutoField on the new
tables).
Runtime β why no new branching action is needed
The picker is a pre-step, not a branching rule. When the participant submits their selection:
- The chosen group IDs (plus the mandatory ones) are stored on the
progress record as
selected_group_ids(already exists onSurveyProgressβ see prerequisite note). - The runtime ordering pipeline (
_resolved_group_order_idsinchecktick_app/surveys/views.py) accepts an optionalselected_group_idsparameter. When provided, it filters the ordered group list to only those IDs. This is the single hook thesection_menulayout adds to the runtime. _order_questions_by_grouppassesselected_group_idsthrough to_resolved_group_order_ids, and drops questions from non-selected groups (including the "remaining groups" fallback).- The branching config (
_build_branching_config) is built from the filtered question list, sojump_to/show/hideconditions that target a now-absent section are simply never triggered. - Repeats work unchanged β a repeatable section the participant didn't pick just isn't in their path.
This keeps the Survey Map = Preview = Live ordering contract intact. The Survey Map shows the full authored survey (it is an authoring-time view); the live route applies the selection filter on top.
Picker route and template
The picker is a step inside the existing take view
(_handle_participant_submission), not a separate URL. When
survey.layout == "section_menu" and the participant has no
selected_group_ids on their progress record, the take view renders the
picker template instead of the question list:
- Template:
surveys/section_menu_picker.html - POST action:
action=select_sectionswithselected_groupsas a list of group IDs. The handler: - Validates min/max against the
SectionMenuconfig. - Unions in mandatory section IDs.
- Orders the final selection (authored order or participant tick
order, depending on
order_mode). - Stores the IDs on
SurveyProgress.selected_group_ids. - Re-renders the take view, which now shows the filtered question list.
- POST action:
action=change_sectionsclearsselected_group_idsand returns to the picker. - On resume: if
selected_group_idsis already populated, skip the picker and go straight to the questions. A "Change sections" link in the progress bar ondetail.htmlreturns to the picker.
Preview
The preview view (survey_preview) accepts a ?simulate_groups=1,3
query parameter (also submitted as repeated keys:
?simulate_groups=1&simulate_groups=3). When present, the questions are
filtered to those groups (plus mandatory ones) via
_order_questions_by_group. A "Simulate section selection" panel on
detail.html (shown only in preview mode) provides checkboxes for
pickable sections and an Apply button.
By default (no simulate_groups parameter), the preview shows only
mandatory sections β pickable sections are hidden until the author
ticks them, mirroring what a participant sees before making a selection.
The Reset button (which returns to the mandatory-only baseline) only
appears after an explicit selection has been made.
Item sync
_sync_section_menu_items(menu, survey) is called on every Organise
page visit and Survey Map visit when layout == section_menu. It
ensures one SectionMenuItem per survey group, preserving existing
flags (is_pickable, estimated_minutes) and order. Groups removed from
the survey are cascade-deleted via the FK.
Outline grammar
The SECTION_MENU block is parsed in
parse_bulk_markdown_with_collections (markdown_import.py), alongside
the existing REPEAT parsing.
SECTION_MENU
prompt: "Which areas would you like to cover?"
min: 1
max: 4
order: authored
select_all: true
estimated_time: true
# Demographics {demographics}
## Name {name}
(text)
# Medical history {medical-history} ~ pickable
## Condition {condition}
(text)
# Medications {medications} ~ pickable, 5 min
## Drug {drug}
(text)
- The
~ pickablesuffix is placed on the actual content group headings (not on separate lines in theSECTION_MENUblock). This avoids duplicate group headings and keeps the parser single-pass. ~is used (not?) because?is already the condition prefix in the outline grammar; reusing it would be ambiguous.- Estimated time can be appended:
~ pickable, 5 min. - Groups without
~under aSECTION_MENUblock are mandatory. - Config lines are indented (spaces or
>blockquote markers). - A blank line ends the config block.
The export side (_export_survey_to_markdown in views.py) emits the
SECTION_MENU block at the top and appends ~ pickable suffixes to
pickable group headings. The config survives export β import round-trips.
The bulk upload view applies the parsed section_menu config: sets
survey.layout, creates/updates the SectionMenu + items, and applies
per-group pickable/estimated_minutes flags.
Issues and edge cases
| Issue | Why it matters | Handling |
|---|---|---|
| Branching targets a pickable section | A jump_to to a section the participant didn't pick is a dead branch. |
Live warning on the Organise page configuration card. Checks both target_group (section jumps) and target_question (question jumps into pickable sections). Non-blocking β just warns. Links to Survey Map for review. |
| Repeats inside non-selected sections | A repeat that's never reached. | Handled automatically by the filter. Survey Map still shows the full structure. |
| Empty selection | min_selected=0 with no mandatory sections = blank survey. |
Warning on save: at least one section must be mandatory OR min_selected β₯ 1. |
| Single-section surveys | A menu with one pickable section is pointless. | Hide the Section menu card hint when the survey has < 2 sections; warn on switch. |
| Save progress + selection | A participant who picks 3 sections, leaves, and returns must not have to re-pick. | selected_group_ids already exists on SurveyProgress (v0.12.0). On resume, skip the picker if it's populated. |
| Min/Max drift | Author lowers max_selected after a participant has already saved 4 picks. |
On resume, clamp to the new max and prompt the participant to drop one. Edge case β worth a test. |
| Mandatory sections can't be deselected | Picker must disable their checkboxes. | Render as locked rows, not checkboxes. |
| Outline round-trip | Menu config must survive export β import. | SECTION_MENU block in the outline grammar + round-trip test in test_outline_section_menu.py. |
| Preview | Author must be able to try the picker without a real participant. | "Simulate selection" panel on the preview page with ?simulate_groups= query param. |
| Naming | "Template" is already used for published sections. | This feature is called Layout. Do not reuse "template". |
| Survey Map | The map is authoring-time and shows the full survey. | Pickable/mandatory badge summary above the visualiser. No change to the runtime map. |
| String options in export | _export_survey_to_markdown crashed on questions with string options (not dicts). |
Fixed with isinstance(option, dict) guards before all .get() calls. |
Publication workflow
The allow_resume and allow_response_redaction toggles already exist
on Survey (added in v0.12.0, migration 0059). They are presented in
the publication workflow alongside the existing visibility and audience
declarations.
The layout feature does not add new publication-workflow toggles β the
Survey.layout field is set on the Organise page, not in the publication
workflow.
Respondent categories (parked β see Β§Planned layouts above) would extend the publication workflow in two places if implemented:
- Category dimensions are defined at design time on the Organise
page (alongside layout selection). The author names each dimension
(e.g.
rater_role,site) and its allowed values. - Category values are assigned at publication time, when generating
token batches.
SurveyAccessTokengains an optionalcategory_valuesJSONField so a batch of tokens can carry{"rater_role": "peer"}β every token in that batch resolves to the same category value at take time. For self-select dimensions (public / unlisted visibility), the value comes from a question answer instead.
This means a single published survey can be distributed to different respondent types via different token batches, with each respondent seeing the subset of sections/questions tagged for their category β without cloning the survey. The 360Β° / multi-rater pattern (see Β§Patterns and modes) is the canonical example: one survey, four token batches (self / peer / supervisor / patient), four different views.
Build order (completed)
All steps are complete. Step 1 was done in PR #319 (v0.12.0); steps 2β10 were done in PR #320 (v0.13.0).
- ~~Save-and-resume~~ β merged in PR #319 (v0.12.0).
- ~~
Survey.layoutfield + migration~~ - ~~Section menu card on the Organise page~~
- ~~
SectionMenu/SectionMenuItemmodels + admin~~ - ~~Picker page + selection filter in
_resolved_group_order_ids~~ - ~~Builder layout label~~
- ~~Outline grammar + import/export round-trip~~
- ~~Warnings~~
- ~~Survey Map badges + preview simulate panel~~
- ~~Docs updates~~
Open questions
- "Change sections" after starting. Allowing it is more flexible but risks discarded answers. Default to allow-with-confirm, or lock the selection after Continue? Worth a quick user test.
Randomised (RCT) layout
The RCT layout is the system-assigned counterpart to Section menu. Instead
of the participant choosing which sections to complete, the system assigns
them to an arm at first access, and the arm's group set becomes their
selected_group_ids. The runtime hook (_resolved_group_order_ids
filtering by selected_group_ids) is reused unchanged β RCT only changes
who populates selected_group_ids, not how the pipeline consumes it.
Data model
class Survey(models.Model):
class Layout(models.TextChoices):
LINEAR = "linear", "Linear"
SECTION_MENU = "section_menu", "Section menu"
RCT = "rct", "Randomised (RCT)"
class RandomisedMenu(models.Model):
survey = models.OneToOneField(
Survey, related_name="randomised_menu", on_delete=models.CASCADE,
)
class AllocationStrategy(models.TextChoices):
BALANCED = "balanced", "Balanced (blocked)"
SIMPLE = "simple", "Simple (weighted)"
allocation_strategy = models.CharField(
max_length=20,
choices=AllocationStrategy.choices,
default=AllocationStrategy.BALANCED,
)
seed = models.BigIntegerField(
null=True, blank=True,
help_text=(
"Optional fixed seed for deterministic allocation across "
"runs. Blank = system-generated per participant."
),
)
class RandomisedArm(models.Model):
menu = models.ForeignKey(
RandomisedMenu, related_name="arms", on_delete=models.CASCADE,
)
name = models.CharField(max_length=100)
allocation_ratio = models.PositiveIntegerField(default=1)
order = models.PositiveIntegerField(default=0)
groups = models.ManyToManyField(
QuestionGroup, related_name="arms", blank=True,
)
class Meta:
unique_together = ("menu", "name")
ordering = ["order", "id"]
class SurveyProgress(models.Model):
...
# RCT arm assignment. Only populated for surveys with
# layout = rct (see docs/survey-layouts.md). Null for other layouts.
randomisation_seed = models.BigIntegerField(
null=True, blank=True,
help_text="Stable seed used for RCT arm allocation (set on first access)",
)
assigned_arm = models.ForeignKey(
"RandomisedArm", null=True, blank=True,
on_delete=models.SET_NULL,
related_name="progress_records",
help_text="The RCT arm this participant was assigned to at first access",
)
A linear or section_menu survey has no RandomisedMenu row and the
new SurveyProgress fields stay null.
Migration: 0061_randomised_layout (adds the Layout.RCT choice,
both models, the M2M through table, and both SurveyProgress fields).
Allocation strategy
_assign_arm_for_progress(progress, menu) is a standalone pluggable
allocator in views.py (kept separate from the runtime pipeline so the
Delphi RoundAllocator could sit next to it without touching RCT β which
it now does, as _assign_round_for_progress in delphi.py).
- Balanced (blocked): builds permuted blocks of size
sum(arm.allocation_ratio), walks the block by allocation count for the survey. Deterministic from(seed, allocation_count). - Simple (weighted): weighted random pick from
seed. Deterministic fromseedalone.
allocation_count is SurveyProgress.objects.filter(survey=β¦,
assigned_arm__in=arms).count() β there is a known race window if two
participants hit first-access simultaneously. For the first RCT PR this
is acceptable; a follow-up can serialise allocation under a
select_for_update or a per-survey allocation counter.
The seed lives on SurveyProgress.randomisation_seed and is set on first
access if absent (system-generated, random.randint(0, 2**63-1)). If the
author sets RandomisedMenu.seed, it is used as the salt for every
allocation β useful for reproducible dry-runs but should be left blank
for real trials to avoid arm predictability.
Runtime hook
In _handle_participant_submission, when survey.layout == RCT and
progress.assigned_arm is None, the runtime:
- Ensures a
RandomisedMenuexists (creates a default with two empty arms if missing β "Intervention" and "Control" β so a freshly switched survey is at least runnable). - Calls
_assign_arm_for_progress(progress, menu). - Resolves
selected_group_idsfrom the arm'sgroups(M2M), ordered by_resolved_group_order_idsso the arm's sections keep the Organise-page order. - Stores both
assigned_arm_idandselected_group_idsonSurveyProgressand re-renders the take view.
On resume (assigned_arm already set), the picker/assignment is skipped
and the take view renders with the previously-resolved
selected_group_ids. There is no picker page for RCT β the participant
never sees their arm.
Outline grammar
The RANDOMISED block sits at the top of the outline (analogous to
SECTION_MENU) and the ~ arm:<name> suffix marks which arm(s) a
section belongs to:
RANDOMISED
strategy: balanced
seed: 7
# Demographics {demographics} ~ arm:intervention, arm:control
## Name {name}
(text)
# Intervention {intervention} ~ arm:intervention
## Dose {dose}
(text)
# Control {control} ~ arm:control
## Placebo {placebo}
(text)
strategyisbalancedorsimple.seedis optional (an integer; omit for system-generated).~ arm:<name>may repeat (a section in multiple arms). Sections with no~ arm:suffix are reachable by all arms (the union of all arm group sets); the configuration warns if a section is unreachable.- A blank line ends the config block.
The export side emits the RANDOMISED block and ~ arm:<name> suffixes,
and the config survives export β import round-trips.
Warnings
- No arms configured β the survey cannot be taken; block the take view with a clear error and warn on the Organise page.
- Single arm β RCT with one arm is degenerate; warn (don't block β the author may be mid-setup).
- Allocation ratio sum zero β every arm has ratio 0; warn.
- Unreachable section β a section not in any arm's group set is never seen by any participant; warn.
- Branching targets an arm-exclusive section β a
jump_tointo a section not in the participant's assigned arm is a dead branch; warn (non-blocking, links to Survey Map). - All arms share the same group set β the RCT is structurally identical to a linear survey; warn.
Preview
survey_preview accepts ?simulate_arm=<arm_id>. When present, the
questions are filtered to that arm's groups (ordered by
_resolved_group_order_ids). A "Simulate arm" panel on detail.html
(preview mode only) lists the arms as radio buttons and applies the
filter on submit.
Survey Map
The Survey Map shows the full authored survey. When layout == rct, a
badge summary above the visualiser lists each arm and its sections, so
the author can see at a glance which sections are arm-exclusive.
Issues and edge cases
| Issue | Why it matters | Handling |
|---|---|---|
| Allocation race | Two participants hitting first access simultaneously could both compute the same allocation_count. |
Acceptable for first PR. Document; follow-up can select_for_update. |
| Author edits arms after enrolment | A participant already enrolled keeps their assigned_arm; the RandomisedArm FK protects them via SET_NULL only if the arm is deleted. |
Warn on arm deletion: enrolled participants keep their stored selected_group_ids even if the arm is removed. |
| Empty arm | An arm with no groups yields an empty survey. | Warn on save; block the take view if the assigned arm has no groups (fall back to all sections). |
| Min/Max drift analogue | Author shrinks an arm's group set after enrolment. | Stored selected_group_ids is preserved on resume; the arm's current group set is only consulted on first assignment. |
| Outline round-trip | Arm assignment must survive export β import. | RANDOMISED block + ~ arm: suffixes; round-trip test in test_outline_randomised.py. |
| Determinism | Authors running dry-runs want reproducible allocation. | Optional RandomisedMenu.seed salts the allocator; blank in real trials. |
Delphi compatibility
The RCT design was deliberately shaped so the Delphi workflow could reuse its ingredients. Delphi now reuses them:
SurveyProgress.assigned_arm(FK, set at first access, queryable for analysis) is the direct precedent forSurveyProgress.delphi_round(FK toDelphiRound). Same shape, separate field β arms and rounds are orthogonal dimensions.SurveyProgress.randomisation_seedis the precedent for Delphi's per-participant stable identifier used to keep round-N+1 assignment stable across resume.- The runtime hook (
_resolved_group_order_idsfiltering byselected_group_ids) is unchanged. Delphi resolvesselected_group_idsfrom the current round's section set instead of an arm's group set β same hook, different allocator. _assign_arm_for_progressis a standalone pluggable function;_assign_round_for_progresssits next to it without touching RCT.- The
?simulate_arm=preview path is the precedent for Delphi's round preview (?simulate_round=). The RCT arm preview only filters questions by arm group IDs β it does not aggregate responses. The response-aggregation helper (aggregate_responses_by_groupindelphi.py) was the one Delphi-critical pattern that no earlier layout exercised, and was the first thing the Delphi build added (see Β§Delphi (consensus rounds) layout). Inter-round aggregate feedback itself is rendered via content blocks (see Β§Content blocks) whoseoptions.body_mdis substituted from the previous round's aggregated responses at view time. - Qualitative aggregation (thematic summary of free-text responses) is
handled by the existing
theme_analyzer.summarise_themes()function, which is already opt-in, unlock-gated, sanitised, and gracefully degrades. Delphi reuses it per long-text question when the author generates inter-round feedback. - RCT does not introduce scheduling, anonymity-beyond-aggregation, or convergence tracking β those are Delphi-only and were kept out of the RCT PR to keep RCT scope tight.
Guided layout
The Guided layout shows one question per screen with Next/Back
navigation, instead of scrolling through all questions on a single
page. It is primarily a rendering change layered on top of the
existing ordering pipeline β no new model, no new SurveyProgress field,
and no new runtime hook. The migration (0062_guided_layout) only adds
the guided choice to Survey.layout.
Rendering contract
All questions still render in the DOM (so branching, repeats, follow-ups,
autosave, and the save-and-resume buttons all work unchanged). A
client-side guided.js module shows one step at a time by toggling data
attributes that the scoped CSS in detail.html reacts to:
data-guidedon the form (gates the scoped CSS).data-guided-groupon each group fieldset (so the section header shows for the current question's group).data-guided-extraon patient/professional details fieldsets (final steps before submit).data-guided-current/data-guided-current-group/data-guided-current-instancemarkers set byguided.json the visible step and its ancestors.- A sticky guided nav bar (Back / step indicator / Next, with a real Submit button revealed on the last step so native form validation runs).
Visibility contract with branching.js
branching.js sets inline style.display on [data-question-id]
("" = visible, "none" = hidden by a condition). The guided layer
uses data attributes + stylesheet rules, never inline display, so the
two never fight: a question hidden by branching stays hidden (inline
none beats CSS); a question shown by branching but not current is
hidden by the CSS rule (inline "" removes the inline style so the
stylesheet display:none applies). The guided nav skips
branching-hidden questions so the participant never lands on one.
Orthogonality and Delphi compatibility
Guided is orthogonal to the selection mechanism (linear,
section_menu, rct, or the Delphi round allocator): it operates on
whatever [data-question-id] / [data-guided-extra] elements the
pipeline produced, regardless of how selected_group_ids was
populated. This means:
- The Delphi round allocator reuses
guided.jsunchanged β it just renders a different subset of questions per round, and the guided nav walks them one at a time. - Guided composes with section_menu and rct: a section_menu survey in guided layout shows the picker first, then walks the chosen sections one question at a time; an rct survey in guided layout walks the assigned arm's sections one question at a time.
Save and resume
The guided layer does not change the submit path or the save/resume
buttons. The scoped CSS only hides the default button[type="submit"]
that is a direct child of form[data-guided] (replaced by the guided
nav's own submit button on the last step). The AJAX-driven "Save and
come back later" button is type="button" and is not a direct-child
submit, so it is unaffected. Autosave (save_draft) fires on
input/change events that bubble to the form regardless of which
step is visible.
Staged (longitudinal) layout
The Staged layout unlocks sections over time in defined phase windows.
Each phase has a window measured in integer days from an anchor
(participant enrolment or survey open). The runtime recomputes the
open phases on each access and resolves selected_group_ids from their
union, reusing the same _resolved_group_order_ids filtering hook as
section_menu and rct. Unlike rct (fixed arm assignment), the open set
changes over time, so selected_group_ids is recomputed and
overwritten each visit β never read from a stored value.
Data model
class Survey(models.Model):
class Layout(models.TextChoices):
LINEAR = "linear", "Linear"
SECTION_MENU = "section_menu", "Section menu"
RCT = "rct", "Randomised (RCT)"
GUIDED = "guided", "Guided"
STAGED = "staged", "Staged (longitudinal)"
class StagedMenu(models.Model):
survey = models.OneToOneField(
Survey, related_name="staged_menu", on_delete=models.CASCADE,
)
class Anchor(models.TextChoices):
ENROLMENT = "enrolment", "From participant enrolment"
SURVEY_OPEN = "survey_open", "From survey open date"
anchor = models.CharField(
max_length=20, choices=Anchor.choices, default=Anchor.ENROLMENT,
)
class StagedPhase(models.Model):
menu = models.ForeignKey(
StagedMenu, related_name="phases", on_delete=models.CASCADE,
)
name = models.CharField(max_length=100)
order = models.PositiveIntegerField(default=0)
start_offset_days = models.PositiveIntegerField(default=0)
end_offset_days = models.PositiveIntegerField(
null=True, blank=True, # null = open-ended
)
groups = models.ManyToManyField(
QuestionGroup, related_name="phases", blank=True,
)
class Meta:
unique_together = ("menu", "name")
ordering = ["order", "id"]
A linear / section_menu / rct / guided survey has no StagedMenu
row. No new SurveyProgress field is needed: unlike rct (fixed arm
assignment), staged recomputes the open set each access.
Migration: 0063_staged_layout (adds the Layout.STAGED choice and
both models, including BigAutoField on the new tables and the M2M
through table).
Phase resolution
checktick_app/surveys/staged.py holds pure, testable functions used
by the take view. Kept separate from the runtime pipeline so a future
Delphi round scheduler can sit next to it without touching the take
view (see Β§Delphi compatibility below).
anchor_time(menu, enrolment, survey_start)β the reference time for phase windows. Forenrolment, the participant'sSurveyProgress.created_at(falling back tosurvey_starton first access). Forsurvey_open,Survey.start_at.is_phase_open(phase, anchor, now)β True whenanchor + start <= now < anchor + end(end null = open-ended).open_phases(menu, enrolment, survey_start, now)β ordered list of open phases.open_group_ids(menu, enrolment, survey_start, now)β ordered, de-duplicated group IDs from currently-open phases.
All offsets are integer days. datetime arithmetic uses timezone-aware
values throughout.
Runtime hook
In _handle_participant_submission, when survey.layout == STAGED and
progress is not None, the runtime:
- Computes
open_idsviastaged.open_group_ids(menu, enrolment, survey_start, now). - Orders the final selection via
_resolved_group_order_ids(survey)so the open phases' sections keep the Organise-page order. - Stores the result on
SurveyProgress.selected_group_ids(overwriting any previous value β staged recomputes each visit). - If the open set is empty, renders
surveys/staged_no_phases.html(a friendly "check back later" page) instead of an empty form. The progress row is preserved so resume works when a phase opens later.
On resume, the open phases are recomputed β if a new phase has opened since the last visit, the participant sees it; if a phase has closed, they no longer see it. There is no picker for staged β the open set is system-controlled.
Outline grammar
The STAGED block sits at the top of the outline (analogous to
SECTION_MENU / RANDOMISED) and the ~ phase:<name> suffix marks
which phase(s) a section belongs to:
STAGED
anchor: enrolment
phase Baseline: 0 .. 14
phase Follow-up: 14 .. 28
phase Review: 180
# Demographics {demographics} ~ phase:Baseline, phase:Follow-up
## Name {name}
(text)
# Baseline {baseline} ~ phase:Baseline
## BQ {bq}
(text)
# Follow-up {followup} ~ phase:Follow-up
## FQ {fq}
(text)
anchorisenrolmentorsurvey_open.phase <name>: <start> [.. <end>]defines a phase window in integer days.<end>is optional (open-ended). Phases referenced only via~ phase:suffixes (no config line) default tostart=0, end=None.~ phase:<name>may repeat (a section in multiple phases). Sections with no~ phase:suffix under aSTAGEDblock are in no phase (unreachable; warned on the Organise page).- A blank line ends the config block.
The export side emits the STAGED block and ~ phase:<name> suffixes,
and the config survives export β import round-trips.
Warnings
- No phases configured β the survey has no phases; add at least one.
- Single phase β structurally identical to a linear survey.
- survey_open anchor with no start date β anchor is None, nothing ever opens.
- Unreachable section β a section not in any phase.
- Overlapping phase windows for the same section β the runtime unions them, so the section stays open across both (flagged so the author knows).
- Branching targets a phased section β a
jump_tointo a phased section is a dead branch when the phase is closed.
Preview
survey_preview accepts ?simulate_phase=<phase_id>. When present, the
questions are filtered to that phase's groups (ordered by
_resolved_group_order_ids) regardless of whether the phase is
currently open. A "Simulate phase" panel on detail.html (preview mode
only) lists the phases as radio buttons and applies the filter on
submit.
Survey Map
The Survey Map shows the full authored survey. When layout == staged,
a phase composition badge summary appears above the visualiser
listing each phase, its day-window, and its sections, so the author can
see at a glance which sections belong to which phase.
Issues and edge cases
| Issue | Why it matters | Handling |
|---|---|---|
| Recompute vs store | Unlike rct (fixed arm), the open set changes over time. | Recompute selected_group_ids on every access; never read a stored value. |
| No phases open | A participant arrives between phases. | Render staged_no_phases.html; preserve the progress row so resume works when a phase opens. |
| No StagedMenu configured | A freshly-switched survey has no phases. | Render staged_no_phases.html; the organise-view warnings flag the misconfiguration. |
| survey_open anchor, no start_at | Anchor is None, nothing ever opens. | Warn on the Organise page; the take view renders the no-phases page. |
| Overlapping phase windows | A section in two overlapping phases stays open across both. | Warn (non-blocking); the runtime unions the open phases. |
| Outline round-trip | Phase config must survive export β import. | STAGED block + ~ phase: suffixes; round-trip test in test_outline_staged.py. |
| Future-phase leakage | Participants must not see sections from phases that haven't opened. | The take view only resolves groups from currently-open phases; future phases are never in selected_group_ids. |
Delphi compatibility
The Staged design was deliberately shaped so the Delphi workflow could reuse its ingredients. Delphi now reuses them:
StagedPhase(withstart_offset_days/end_offset_daysand an M2M toQuestionGroup) is the direct precedent for theDelphiRoundmodel. Same shape, separate model β phases and rounds are orthogonal dimensions.- The runtime hook (
_resolved_group_order_idsfiltering byselected_group_ids) is unchanged. Delphi resolvesselected_group_idsfrom the current round's section set instead of the open phases' union β same hook, different scheduler. staged.py's pure functions (anchor_time,is_phase_open,open_phases,open_group_ids) are the precedent for Delphi's round-scheduling helpers.delphi.pysits next to it without touching staged.- The
?simulate_phase=preview path is the precedent for Delphi's round preview (?simulate_round=). Inter-round aggregate feedback is rendered via content blocks (see Β§Content blocks) β no new feedback model in Delphi. - Staged does not introduce anonymity-beyond-aggregation or convergence tracking β those are Delphi-only and were kept out of the Staged PR to keep Staged scope tight.
Matrix (free navigation) layout
The Matrix layout shows all sections as cards on a landing page. The
participant jumps in and out of any section in any order, with completion
indicators showing which sections are done. Unlike the other layouts (which
filter selected_group_ids and render a single take page that submits the
whole survey at once), matrix has a landing page + per-section take pages +
a final submit. This is the biggest layout change since section_menu.
Data model
class Survey(models.Model):
class Layout(models.TextChoices):
...
MATRIX = "matrix", "Matrix (free navigation)"
class MatrixMenu(models.Model):
survey = models.OneToOneField(
Survey, related_name="matrix_menu", on_delete=models.CASCADE,
)
prompt_text = models.CharField(
max_length=255,
default="Click a section to begin. You can complete them in any order.",
)
class OrderMode(models.TextChoices):
AUTHORED = "authored", "As authored"
PARTICIPANT = "participant", "In visit order"
order_mode = models.CharField(
max_length=20, choices=OrderMode.choices, default=OrderMode.AUTHORED,
)
allow_revisit = models.BooleanField(default=True)
class SurveyProgress(models.Model):
...
completed_group_ids = models.JSONField(
default=list, blank=True,
help_text=(
"Section IDs the participant has marked complete in a matrix "
"survey (soft indicator; final submit re-validates)."
),
)
A linear / section_menu / rct / guided / staged survey has no
MatrixMenu row and completed_group_ids stays empty.
Migration: 0064_matrix_layout (adds the Layout.MATRIX choice, the
MatrixMenu model, and the SurveyProgress.completed_group_ids field).
Runtime hook
Matrix does not use the selected_group_ids filtering hook the way the
other layouts do. Instead, it has a dedicated runtime path in
_handle_participant_submission:
- GET without
?section=<gid>β rendersmatrix_landing.html(cards with completion indicators). Each card shows the section state (complete/in_progress/not_started) computed frompartial_answers+completed_group_ids. - GET with
?section=<gid>β filtersselected_group_idsto just that one group sodetail.htmlrenders only that section's questions. The template shows "Save and complete section" + "Back to overview" buttons instead of the standard Submit. - POST
action=complete_sectionβ collects answers, validates the section's required questions, markscompleted_group_ids, redirects to the landing page. - POST
action=save_draftβ saves answers; if the section was complete, un-marks it (editing β back to "in progress"). - POST
action=submit_surveyβ final whole-survey submission. Re-validates all required questions across all sections (hard gate βcompleted_group_idsis a soft indicator and can lie). Builds theSurveyResponsefromprogress.partial_answers(accumulated across section visits) plus demographics/professional from POST.
For public/unlisted surveys without a credential, a session-based
SurveyProgress row is created on first access (matrix requires
server-side completion tracking). This mirrors the section_menu picker's
throwaway progress row handling.
Section states
checktick_app/surveys/matrix.py holds pure, testable functions used by
the take view. Kept separate from the runtime pipeline so the Delphi
round scheduler could sit next to it without touching the take view β
which it now does, as delphi.py.
section_state(progress, survey_id, group_id)β returnscomplete,in_progress, ornot_started.missing_required_question_ids(partial_answers, survey_id, group_id)β used bycomplete_section(validates before marking complete) and by the finalsubmit_survey(re-validates all sections).landing_card_states(progress, survey_id, group_ids)β one card-state dict per group, in order.all_sections_complete(progress, survey_id, group_ids)β soft check; final submit re-validates as a hard gate.add_completed_group/remove_completed_groupβ idempotent list helpers.
Outline grammar
The MATRIX block sits at the top of the outline (analogous to
SECTION_MENU / RANDOMISED / STAGED) and carries only config lines.
No ~ suffixes are needed β every section in the survey is a card on the
matrix landing page by default.
MATRIX
prompt: "Choose a section to begin"
order: participant
allow_revisit: false
# Demographics {demographics}
## Name {name}
(text)
# History {history}
## Condition {condition}
(text)
promptis the landing-page prompt text (quoted).orderisauthoredorparticipant.allow_revisitistrueorfalse.- A blank line ends the config block.
The grammar is optional β the Organise page UI is the primary config path. The grammar exists so the AI builder and power users can import/export matrix surveys via the outline.
The export side emits the MATRIX block with config lines, and the config
survives export β import round-trips.
Warnings
- Single-section survey β matrix with < 2 sections is pointless; warn on layout switch.
- Cross-section branching β a
jump_tothat targets a question in a different section is meaningless in matrix (the participant navigates via the landing page, not linearly). Same-section jumps work normally. Non-blocking; links to Survey Map for review.
Preview
survey_preview accepts ?simulate_section=<gid>. When present, the
questions are filtered to that one section so the author can preview what
a participant would see when they open that card. A "Simulate section"
panel on detail.html (preview mode only) lists the sections as radio
buttons.
Survey Map
The Survey Map shows the matrix configuration badges above the visualiser: prompt text, order mode, and revisit toggle.
Issues and edge cases
| Issue | Why it matters | Handling |
|---|---|---|
| Soft vs hard completion | completed_group_ids can lie if the participant edits a section after marking it complete. |
save_draft on a completed section un-marks it. Final submit_survey re-validates all required questions regardless of completed_group_ids. |
| Per-section validation | The participant should not be able to mark a section complete with missing required questions. | complete_section validates required questions before marking complete; redirects back to the section with an error if any are missing. |
| Accumulated answers | Answers are spread across multiple section visits, not in a single POST. | SurveyProgress.partial_answers accumulates across visits. submit_survey builds the SurveyResponse from partial_answers, not from POST. |
| Public/unlisted surveys | Matrix requires server-side completion tracking, but _get_or_create_progress returns None for public/unlisted without credentials. |
Create a session-based SurveyProgress row on first access (mirrors section_menu picker handling). |
| Demographics/professional | These fields are not per-section question answers. | Collected from POST on submit_survey (the landing page's submit form includes them). |
| Outline round-trip | Matrix config must survive export β import. | MATRIX block + config lines; round-trip test in test_outline_matrix.py. |
| Cross-section branching | jump_to to a different section is meaningless. |
Warn on the Organise page (non-blocking). |
Delphi compatibility
The Matrix design was deliberately shaped so the Delphi workflow could reuse its ingredients. Delphi now reuses them:
SurveyProgress.completed_group_idsis the reusable ingredient for Delphi's within-round completion tracking. Thedelphi_completed_roundsfield has the same shape (list of IDs, soft indicator, hard gate on final submit). Matrix proves the pattern: accumulate β validate β submit.- The per-section take +
complete_sectionpattern is the precedent for Delphi's per-round take +complete_round. The runtime hook (_handle_participant_submissionbranching on layout) is unchanged β Delphi added another branch, not a modification to matrix's. matrix.py's pure functions (section_state,missing_required_question_ids,landing_card_states) are the precedent for Delphi's round-state helpers.delphi.pysits next to it without touching matrix.- The
?simulate_section=preview path is the precedent for Delphi's round preview (?simulate_round=). Inter-round aggregate feedback is rendered via content blocks (see Β§Content blocks) β no new feedback model in Delphi. - Matrix does not introduce convergence tracking or anonymity-beyond-aggregation β those are Delphi-only and were kept out of the Matrix PR to keep Matrix scope tight.
Content blocks (cross-layout)
Content blocks are a SurveyQuestion type (content_block) that renders
static content β heading, Markdown body, and hyperlinks β with no answer
input. They go anywhere a question goes: any group, any position, multiple
per group, any layout. A content block placed first in the first section is
a landing page; one placed between substantive sections is an interstitial
disclosure; one placed last is a closing acknowledgement.
Data model
class SurveyQuestion(models.Model):
class Types(models.TextChoices):
...
CONTENT_BLOCK = "content_block", "Content block"
A content_block question's options JSONField holds:
{
"heading": str, # rendered heading (optional β block can have no heading)
"subtitle": str, # rendered subtitle (optional)
"body_md": str, # Markdown body (optional)
"image_id": int | None, # FK to QuestionImage (optional, builder-only upload)
"links": [ # reference / disclosure links (optional, zero or more)
{"label": str, "url": str},
...
],
"consent": { # consent checkbox (optional)
"question_id": int, # FK to a linked yesno question
"statement": str, # consent statement text
"required": bool, # if True, participant must agree to progress
} | None,
"render_once": bool, # default True; render once even in repeatable groups
}
All components are optional β the author includes only what they need.
SurveyQuestion.text is the internal builder label (e.g. "Content
block", "Introduction") β it is NOT rendered to participants. This mirrors
the template_patient / template_professional pattern, where
question.text is the builder label and the rendered content comes from
options.
Image upload is builder-only β the outline grammar does not carry image references. Authors upload images via the builder; the outline only stores the question type and text.
A content block is never required β it has no answer. This is
enforced in three places (defence in depth):
- Parser (
markdown_import.py): forcesrequired=Falseregardless of the*suffix. - Special template creation (
builder_group_template_add): creates withrequired=False. - Model (
SurveyQuestion.clean()): raisesValidationErrorifrequired=True.
missing_required_question_ids skips content_block questions
automatically (they are never required).
Markdown sanitisation
body_md is rendered to HTML via
checktick_app/core/markdown_safety.py:render_content_block_markdown,
which runs Python-Markdown (extra, nl2br, sane_lists) then
nh3.clean with an explicit tag/attribute allowlist. No <script>,
<style>, <iframe>, <form>, or event-handler attributes survive.
Link href/img src URLs are restricted to http, https, and mailto
schemes. See docs/security-overview.md Β§A03 (S16/S17).
Content-block link URLs are validated against the same scheme allowlist
at save time via sanitise_link_url (builder form and parser).
Consent
Consent is an optional component of a content block. When the author
configures consent (via the content block's configure panel), a linked
yesno question is created in the same group, hidden from the builder
list via a _content_block_parent marker in its options. The answer is
stored on the yesno question ("yes"/"no") β clean audit trail ("who
agreed to X?"), clean export, clean data-protection defence. The content
block renders the consent checkbox inline using the yesno question's form
field name. When the author sets "required", the participant cannot
progress until they agree (missing_required_question_ids validates it).
Clearing the consent statement in the configure panel deletes the linked yesno question.
The yesno type already supports custom labels (see markdown_import.py).
A boolean checkbox type is not added β an unticked checkbox is ambiguous
(didn't see it vs. actively declined), which is exactly what a medical
app should avoid for consent. The parser's existing boolean alias maps
to yesno; if a real boolean type is ever added, that alias needs
forking.
Rendering
detail.html renders a content_block question as a single block with
optional components in order: heading, subtitle, image, body, links,
consent checkbox. No standard answer input (except the consent checkbox
when configured). The standard q.text H2 is suppressed for content
blocks β the internal label is not shown to participants. The matrix
landing page composes with it β a content block as the first question in
the first section renders above the cards as a landing header (see
Β§Matrix layout).
For non-matrix layouts, a "landing page" or "section intro" is simply a
QuestionGroup whose only question is a content_block β the runtime
renders sections in order, so a single-content-block section renders as
a static page between question sections.
Outline grammar
## Introduction
(content_block)
heading: Welcome
variant: disclosure
link: Privacy notice|https://example.com/privacy
link: Study protocol|https://example.com/protocol
Welcome to the study. Please read the privacy notice and links above
before continuing.
headingis optional (the rendered heading;## Introductionis the internal label, not rendered to participants).variantis optional (defaults totext).render_onceis optional (defaults totrue).link:lines are one per reference,Label|URLformat.- The body is everything after the config lines (multiline Markdown).
- A blank line after the config block starts the body; a new
#/##heading ends it.
The export side emits the (content_block) type and config lines, and
the body survives export β import round-trips.
Builder
Content blocks are a Special Template option in the builder (alongside
patient and professional details), not a regular question type. The author
adds a content block via the "Special Templates" tab, which creates a
question with text="Content block" and default (empty) options. A
"Configure content block" panel (in the question row, like the
patient/professional configure panels) provides all optional components:
- Heading input (the rendered heading,
options.heading) - Subtitle input (the rendered subtitle,
options.subtitle) - Body textarea (Markdown,
options.body_md) - Image upload (reuses
QuestionImage, non-medical warning) - Links repeater (parallel
link_label[]/link_url[]lists, zero or more) - Consent section (statement text + required toggle; creates a linked yesno question)
- Render once toggle (
options.render_once)
SurveyQuestion.text is the internal builder label (e.g. "Content
block"), not rendered to participants. options.heading is the rendered
heading. body_md is the primary authored content of the block, rendered
as Markdown β HTML, and can be multiple paragraphs.
Branching
Content blocks cannot be condition sources (they have no answer). As condition targets:
JUMP_TOa content block is fine (just renders the block).SHOW/HIDEon a content block produces a non-blocking warning on the Organise page (content blocks have no answer β considerjump_toinstead).
CSV export and reporting
Content blocks are skipped in CSV export (no column) and in the reporting
workflow (not in CHARTABLE_TYPES / TEXT_TYPES / NUMERIC_TYPES).
Delphi compatibility
A content block whose body_md (and optionally heading) is rendered
from aggregate data (medians, IQRs, themes) instead of authored Markdown
is Delphi's inter-round feedback view. Same model, different body source
β the author marks the block as "Delphi feedback" (the
is_delphi_feedback flag in options, no migration needed since
options is a JSONField) and the runtime substitutes options.body_md
/ options.heading from the previous round's DelphiRoundFeedback
cache at view time. question.text (the internal label) stays stable
across rounds. No new model, no new SurveyProgress field.
Long text (textarea)
A long_text question type β a textarea version of text β gives
authors a "please describe..." / "any other comments" question with a
multi-line input. It landed in v0.16.1 (migration 0065_long_text).
Data model
class SurveyQuestion(models.Model):
class Types(models.TextChoices):
...
LONG_TEXT = "long_text", "Long text (textarea)"
long_text is a new type, not a text variant β the existing pattern
is one type per rendering shape (see template_patient /
template_professional), and the builder, template, and export are all
type-dispatched already. The answer is stored as a string, same as
text. CSV export reuses the existing string answer path
(_format_answer_for_export).
Builder
The builder form renders a <textarea> for long_text questions
(rows=4). No config section is shown (no options, no format variants).
Outline grammar
## Any other comments
(long_text)
- Aliases:
textarea,paragraph,long_text. - The question text is the label; the answer is a multi-line string.
- Round-trips through export β import like the other types.
Migration
0065_long_text adds the LONG_TEXT choice to SurveyQuestion.type.
No new model, no new SurveyProgress field β just the type and the
builder/template/parser branches.
Planned layouts
The following layouts are planned for future releases. See Survey Layouts for the user-facing descriptions. They are ordered by priority β the order CheckTick intends to implement them, based on how often the use case is the reason a research team reaches for REDCap or Qualtrics instead of a simpler tool.
Delphi (consensus rounds) was the most recent layout to ship; its full technical reference is now in Β§Delphi (consensus rounds) layout below, alongside the other live layouts.
Diary / EMA (ecological momentary assessment) was the most recent layout to ship; its full technical reference is in Diary / EMA β Technical Reference and the user-facing guide is Diary / EMA.
Respondent categories β priority: medium β PARKED
Status (2026-09): parked. This is a generalisation of a pattern the codebase already uses in per-layout form (
assigned_arm,delphi_round,access_token), not a prerequisite for any shipped or planned layout. The per-layout FKs work, and shipping more layouts (screening, CAT, crossover) does not depend on it. It is retained here as a recorded design decision so a future implementer doesn't re-derive it from scratch; the 360Β° / multi-rater pattern below is scoped on the assumption this feature eventually ships. Revisit when there is a concrete use case that the per-layout FKs cannot cover (e.g. a survey that needs two independent categorisation axes simultaneously, neither of which is an arm or a round).
A cross-cutting feature, not a layout. Lets a survey creator categorise respondents along one or more dimensions (rater role, recruitment site, cohort, language, stratum, β¦) within a single published survey, so different respondents see different subsets of the same survey without cloning it. The category value drives item visibility at take time.
This generalises a pattern the codebase already uses in per-layout form:
SurveyProgress.assigned_arm (RCT), SurveyProgress.delphi_round
(Delphi), and SurveyProgress.access_token (token visibility) are each
a single-dimension respondent category introduced by the layout that
needed it. The migration comment in 0061_randomised_layout.py and the
delphi_round docstring both state this explicitly: "arms and rounds
are orthogonal dimensions." Respondent categories makes that admission
first-class instead of incidental.
Once this feature ships, the 360Β° / multi-rater use case stops being a
layout and becomes a question-bank instrument built on respondent
categories (see Β§Patterns and modes below). Stepped-wedge clusters also
become a category dimension rather than a new Cluster entity.
Not in scope for this feature (recorded to forestall scope creep at implementation time):
- Language / translation. Today each translation is a separate
Surveyrow linked by atranslation_groupUUID, with per-rowlanguage,is_original,translated_from, and per-row publication state, retention, and encryption decisions. Language is a content-level variant (the question text differs), not a visibility-level variant (same questions, different subset). Subsuming language into respondent categories would require categories to carry per-variant content, not just per-variant visibility β a fundamentally bigger model (content_variantsmaps on everySurveyQuestion, translation LLM workflow changes, per-variant publication readiness, per-variant retention/encryption). The two features share a UX shape (one logical survey, multiple recipient types) but not a data model. A future "survey variant" layer above both would be the right unifying abstraction; forcing language into the category mechanism would not. A survey can have both β e.g. an English survey withrater_rolecategories and a Welsh translation of the same with its ownrater_rolecategories.
Technical notes:
- New RespondentCategory model (one per dimension per survey) holding
the dimension name (e.g. rater_role, site, cohort) and the set
of allowed values. Dimensions are author-defined; there is no fixed
enum.
- New RespondentAssignment model (one per SurveyProgress Γ
dimension) storing the resolved value. Alternatively a JSONField on
SurveyProgress (category_values), but a side table is easier to
query and report on. Either way, the value is resolved once at first
access and preserved on resume β same lifecycle as assigned_arm.
- Resolution sources (in priority order):
1. Invite token β SurveyAccessToken gains an optional
category_values JSONField. The token carries the dimension
values for that batch (e.g. all tokens in the "peer rater" batch
carry {"rater_role": "peer"}). This is the 360 rater-invite
pattern generalised, and the primary mechanism for token-visibility
surveys.
2. Self-select question β a question at the start of the survey
whose answer populates a category dimension ("which clinic are you
from?"). Works for public/unlisted visibility where there is no
token.
3. System assignment β reuses the RCT seed allocator. This is
just "RCT arm as a category dimension" β the existing
assigned_arm becomes the system-assigned value for an
arm dimension. No new allocator code; the dimension is marked
system_assigned.
4. Profile field β org membership, team, or a custom profile
field. Useful for authenticated-visibility surveys.
- Item visibility: QuestionGroup and SurveyQuestion gain an optional
required_categories rule (JSONField, e.g.
{"rater_role": ["peer", "supervisor"]}). At take time, the runtime
drops any section/question whose required_categories don't match the
participant's resolved category_values. This is the generalisation
of selected_group_ids (section_menu) and arm-based filtering (RCT):
same hook (_resolved_group_order_ids filtering), different source of
the filter set.
- Branching stays answer-driven. Category-based visibility is a
static pre-step filter, not a branching rule β the same argument as
Β§Runtime β why no new branching action is needed (L83): questions
filtered out by category are simply absent from the rendered list, so
any jump_to / show / hide conditions targeting them are never
triggered. No new branching action or condition source is needed for
the vast majority of use cases ("different rater types see different
sections" is static).
- Dynamic branching on category ("if rater_role=supervisor AND
answer to Q3=yes, jump to Q7") is a possible future extension but is
not part of the initial design. It would require a new condition
source type on SurveyQuestionCondition ("category" alongside
"question") and corresponding branching.js changes. Most cross-cut
cases can be expressed as static visibility by splitting the survey
into per-category sections. Defer until there is a clear use case that
static visibility cannot cover.
- Publication workflow: the category dimensions are defined at design
time (Organise page). The category values are assigned at
publication time, when generating token batches ("this batch of 50
tokens is for rater_role=peer") or declaring the self-select
question. See Β§Publication workflow below.
- Reporting: results can be sliced/pivoted by any category dimension.
This is the cross-rater aggregation 360 needs, but it is also what
every observational study wants ("compare arms of my non-RCT survey"
/ "break down by recruitment site").
- ?simulate_category=rater_role:peer,site:clinic_a preview path for
authors to test the filtered view.
Two-stage screening / eligibility routing β priority: medium
A brief screener determines eligibility, then routes to the full survey, an exit page, or an alternative survey. Ubiquitous in clinical recruitment (e.g. "Are you 18+? Have you had symptoms for >2 weeks?").
Could be done with branching today, but the eligibility β consent β survey β exit pattern is so common that a dedicated layout gives researchers a structured template, automatic CONSORT-style flow diagram data, and clean screen-fail tracking.
Builds on: Branching + content blocks (for consent). Low effort, high usability payoff. May ship as a layout template (pre-configured branching + content blocks) rather than a full new runtime hook.
Technical notes:
- New ScreeningMenu model (OneToOne to Survey) holding the
eligibility rule (a branching expression evaluated against the
screener answers), the eligible redirect (the full survey slug or
"inline" to continue in the same survey), and the ineligible
redirect (an exit content block or a separate survey slug).
- New ScreeningResult model (one per participant) recording the
screener answers, the eligibility decision, and the redirect taken.
This is the CONSORT flow diagram data source.
- The runtime hook is a pre-step in _handle_participant_submission
(like the section_menu picker): if the screener is incomplete, render
the screener; if complete, evaluate eligibility and redirect.
- ?simulate_eligible= / ?simulate_ineligible= preview paths.
Computer-Adaptive Testing (CAT) β priority: medium
The next question is selected algorithmically based on prior responses using item-response theory (IRT). Used for PROMIS, NIH Toolbox, and other validated clinical outcome measures. Enables shorter, more precise instruments.
Distinct from branching (which is rule-based: if Q3=yes, show Q5). CAT is statistical β the engine estimates a latent trait (e.g. depression severity) and selects the next item to minimise uncertainty. This is a fundamentally different selection mechanism.
Builds on: Guided (one-question-per-screen rendering), but needs an IRT scoring engine and an item bank with difficulty/discrimination parameters. Substantial new domain logic.
Technical notes:
- New CatBank model (OneToOne to Survey) holding the IRT model
type (rasch, 2pl, grm), the stopping rule (SE threshold, max
items, or both), and the prior distribution parameters.
- New CatItemParam model (one per question in the bank) holding the
difficulty (b), discrimination (a), and guessing (c, 3PL only)
parameters.
- The runtime hook replaces _resolved_group_order_ids for CAT
surveys: instead of a fixed order, the next question is selected by
the IRT engine after each response. This is the first layout that
does not reuse the fixed-order pipeline β it is a genuine
alternative selection mechanism.
- ?simulate_theta= preview path for authors to test the engine at a
given trait level.
- High effort. Worth it if CheckTick wants to attract clinical outcomes
researchers who need PROMIS-capable instruments.
Crossover / within-subject RCT β priority: medium
Each participant experiences all conditions in sequence (AB, ABA, ABAB) with washout periods between. Common in clinical pharmacology and behavioural interventions.
Distinct from RCT (between-subject: one arm per participant). Crossover is within-subject: all conditions, sequenced. Requires carryover control, period scheduling, and sequence-balanced allocation (Latin squares, Williams designs).
Builds on: RCT allocation engine + Staged phase windows, but the allocation logic (sequence assignment, not arm assignment) and the per-period analysis are distinct.
Technical notes:
- New CrossoverMenu model (OneToOne to Survey) holding the design
type (latin_square, williams, counterbalanced), the number of
periods, and the washout duration.
- New CrossoverSequence model (one per possible sequence) holding the
ordered list of condition labels.
- SurveyProgress gets a crossover_sequence FK (like
assigned_arm) and a crossover_period integer (current period).
- The runtime hook resolves selected_group_ids from the current
period's condition in the participant's assigned sequence β same
hook, different allocator.
- ?simulate_sequence= / ?simulate_period= preview paths.
Conjoint / Discrete Choice Experiment (DCE) β priority: low
Choice-based experiments where participants make trade-off choices between attribute profiles. Used in health economics, patient preference studies, and HTA submissions.
Requires a fractional-factorial experimental design engine, choice-card rendering (profile A vs profile B), and specialised analysis (conditional/mixed logit). None of the current layouts touch this.
Technical notes:
- New ConjointDesign model (OneToOne to Survey) holding the
attributes, levels, and the fractional-factorial design matrix.
- New question type choice_card rendering two profiles side by side.
- The runtime hook generates choice cards from the design matrix β
each participant sees a different subset determined by their
randomisation_seed (reusing the RCT seed field).
- Niche but high-value for health economics teams. Significant new
rendering + design logic.
Stepped wedge / cluster-randomised β priority: low
Clusters (sites, wards, practices) cross from control to intervention in a randomised sequence over time. Common in implementation science and cluster RCTs.
Randomisation is at cluster level, not individual. Participants within a cluster receive the arm the cluster is currently in. Requires cluster-level scheduling and a cluster registry.
Builds on: RCT + Staged, but the cluster entity and cluster-level scheduling are new.
Technical notes:
- New Cluster model (one per site/ward/practice) with a
cluster_arm FK that changes over time per the stepped wedge
schedule.
- New SteppedWedgeMenu model (OneToOne to Survey) holding the
number of steps, the step interval, and the randomisation sequence
of clusters.
- Participants are linked to a cluster (via a profile field or a
survey-level question); their selected_group_ids is resolved from
the cluster's current arm.
- ?simulate_step= / ?simulate_cluster= preview paths.
Patterns and modes
Features that are not layouts (they don't add a runtime selection hook) but are related and worth tracking. Both were previously listed under Planned layouts; they have been moved here because they are better expressed as cross-cutting features than as new layout enum values.
360Β° / multi-rater assessment β pattern (built on respondent categories)
Multiple respondents (peers, supervisors, patients) rate a single subject. Common in medical education and clinician appraisal.
Previously listed as a low-priority layout. Now scoped as a question-bank pattern built on respondent categories (see above):
- A
rater_rolecategory dimension with valuesself,peer,supervisor,patient. - A
subjectcategory dimension (or asubject_reffield onSurveyProgress) so responses can be grouped for cross-rater aggregation in reporting. - Sections tagged with
required_categoriesso each rater role sees only its question set about the same subject. - Invite tokens carry the
rater_rolevalue for that batch. - No new runtime hook, no new
MultiRaterMenumodel, no new layout enum value. The 360 instrument (the actual question set) ships in the question bank as a ready-made template.
If adoption is strong and a dedicated rater-selection UX is later needed ("which rater are you?" picker), it can be promoted to a first-class layout then β but the mechanism will already be in place.
Think-aloud / cognitive interview mode β mode flag
Qualitative interview mode for instrument validation β the researcher probes while the participant thinks aloud. Usually audio-recorded.
This is interview-mediated, not self-administered. It is a mode flag
on Survey, not a layout.
Technical notes:
- A mode field on Survey (self_administered vs interview_mediated).
- In interview mode, the take page renders a simplified view for the
researcher to drive, with a probe field per question and an optional
audio recording link.
- Low effort, but narrow use case. May remain a pattern rather than a
first-class feature.
Delphi (consensus rounds) layout
This section is the full technical reference for the Delphi layout, the most recent layout to ship. Each subsection mirrors the structure used by the other live layouts (RCT, Guided, Staged, Matrix).
Overview
The Delphi layout implements a structured multi-round consensus workflow. Participants complete a series of rounds; between rounds they see aggregate feedback from the previous round (quantitative distributions + qualitative themes) and revise their answers. The author controls the number of rounds, the section set per round, the round windows, and when to generate inter-round feedback.
The design reuses the runtime hook proven by section_menu / rct /
staged / matrix: _resolved_group_order_ids filtering by
selected_group_ids. Delphi resolves selected_group_ids from the
current round's section set β same hook, different allocator.
Response aggregation (the first commit)
No earlier layout has needed to collect responses across participants and compute aggregate statistics. This is the one Delphi-critical pattern that is unproven, so the Delphi build starts with it in isolation.
A new checktick_app/surveys/delphi.py module exposes pure functions:
def aggregate_responses_by_group(
survey_id: int,
group_ids: list[int],
*,
submission_filter: Callable[[QuerySet], QuerySet] | None = None,
) -> dict[int, dict[int, dict]]:
"""Collect and aggregate responses for the given groups.
Returns a nested dict: {group_id: {question_id: stats}}.
Per question type:
- likert / numeric: median, IQR (Q1/Q3), min, max, count,
distribution (value -> count).
- mc_single / dropdown: frequency distribution, mode, count.
- mc_multi: frequency per option, co-occurrence top-3, count.
- yesno: yes_count, no_count, yes_pct, count.
- long_text: list of response strings (for LLM thematic analysis).
- text: list of response strings (short, for collation only).
- content_block: skipped (no answer).
``submission_filter`` is an optional callable that filters the
SurveyProgress/Submission queryset β used by Delphi to scope to a
specific round's submissions. Defaults to all completed submissions.
"""
The function is pure (no side effects, no LLM calls) and tested in isolation with synthetic responses. It reads decrypted answers via the existing answer-access layer (the same path the CSV export and summary report use), so the unlock gate is the caller's responsibility.
Inter-round feedback generation (the second commit)
Inter-round feedback has two components: quantitative (medians, IQRs, distributions β pure Python, always computed) and qualitative (thematic summary of free-text responses). The qualitative component supports two approaches, and the author chooses which to use:
Manual thematic analysis (always available, default)
Researchers often prefer to code themes manually in NVivo, Excel, or
SPSS β particularly for regulatory or publication-grade work where the
LLM's paraphrasing is not auditable enough. The collate_round_comments
function collects all free-text responses for a round's questions into a
structured dict suitable for CSV/JSON export:
def collate_round_comments(
survey_id: int,
group_ids: list[int],
*,
survey_key: bytes | None = None,
responses: QuerySet | list | None = None,
) -> dict[int, dict]:
"""Collate free-text responses per question for manual thematic analysis.
Returns {question_id: {question_text, question_type, responses: [str]}}.
Only text/long_text questions are included. The view serialises this
to CSV (one row per response, columns for question text and response)
or JSON for download.
"""
The download is always available β it does not require the LLM, does not require a specific tier, and does not require opt-in. It is the default path for researchers who want to do their own analysis.
LLM thematic analysis (opt-in, tier-gated)
When the author wants the LLM to generate a thematic summary, they click
a separate "Generate LLM theme summary" button (opt-in, tier-gated,
unlock-gated). This reuses the existing theme_analyzer.summarise_themes()
function, which is already:
- Opt-in: a button, never automatic.
- Unlock-gated: only decrypted content is sent to the LLM.
- Per-question: bounded token volume, one question at a time.
- Sanitised through
sanitize_markdown()before rendering. - Graceful degradation: if the LLM is unavailable, the caller falls back to plain collation.
For Delphi, the LLM thematic analysis is author-triggered and pre-computed at round close, not per-participant-view. This preserves the opt-in principle while avoiding re-running the LLM for every participant who views round N+1.
The flow
- Author closes round N (sets the round's
closed_at). - The organise page shows two buttons:
- "Download comments" (always available) β calls
collate_round_comments()and returns a CSV/JSON file. - "Generate inter-round feedback" (opt-in, tier-gated, unlock-gated)
β calls
aggregate_responses_by_group()for the round's groups, then callssummarise_themes()per long-text question. - The result (quantitative stats + sanitised qualitative theme markdown,
if the LLM path was taken) is cached on a
DelphiRoundFeedbackmodel. - Participants in round N+1 see the cached feedback via content blocks
whose
options.body_mdis substituted from the cached markdown at view time.
The cached content is aggregate, sanitised, and non-identifiable β safe to store. Raw responses are never stored in the feedback; only the LLM's paraphrased themes (when that path is taken) and the quantitative distributions. If the LLM is unavailable or not opted into, quantitative feedback still renders; qualitative content blocks show a graceful "download the comments to review manually" message linking to the download button.
Audit logging records metadata only (round id, question id, response
count, token count, model name, success/failure, duration) β never the
free-text input or the LLM output verbatim, per the medical-app logging
rules in AGENTS.md.
Why both paths
The dual approach respects that thematic analysis is a research method, not just a feature. Researchers who publish Delphi results in peer-reviewed journals are often required to describe their coding process; an LLM-generated summary cannot be audited or reproduced with the same rigour as manual coding. By making the download always available and the LLM explicitly opt-in, CheckTick supports both workflows without forcing one on the researcher.
Data model
class Survey(models.Model):
...
layout = models.CharField(
max_length=20,
choices=[
("linear", "Linear"),
("section_menu", "Section menu"),
("rct", "Randomised (RCT)"),
("guided", "Guided"),
("staged", "Staged (longitudinal)"),
("matrix", "Matrix (free navigation)"),
("delphi", "Delphi (consensus rounds)"),
],
default="linear",
)
class DelphiMenu(models.Model):
"""Configuration for a survey with ``layout = delphi``."""
survey = models.OneToOneField(
Survey, related_name="delphi_menu", on_delete=models.CASCADE,
)
anchor = models.CharField(
max_length=20,
choices=[("enrolment", "Participant enrolment"),
("survey_open", "Survey open")],
default="enrolment",
)
min_rounds = models.PositiveIntegerField(default=2)
max_rounds = models.PositiveIntegerField(default=3)
show_progress = models.BooleanField(
default=True,
help_text="Show participants which round they are in.",
)
allow_revision = models.BooleanField(
default=True,
help_text="Allow participants to revise previous-round answers "
"in the current round.",
)
class DelphiRound(models.Model):
"""A single round of a Delphi survey.
Like ``StagedPhase`` (start/end offsets, M2M to groups) but for
consensus rounds. Each round has a window and a section set. The
author opens and closes rounds manually (or via the window offsets).
"""
menu = models.ForeignKey(
DelphiMenu, related_name="rounds", on_delete=models.CASCADE,
)
order = models.PositiveIntegerField(default=0)
name = models.CharField(max_length=100, default="Round 1")
start_offset_days = models.PositiveIntegerField(default=0)
end_offset_days = models.PositiveIntegerField(null=True, blank=True)
groups = models.ManyToManyField(QuestionGroup, blank=True)
opened_at = models.DateTimeField(null=True, blank=True)
closed_at = models.DateTimeField(null=True, blank=True)
class Meta:
unique_together = ("menu", "order")
ordering = ["order", "id"]
class DelphiRoundFeedback(models.Model):
"""Pre-computed inter-round feedback cache.
Created when the author clicks "Generate inter-round feedback" after
closing a round. Stores the aggregated quantitative stats (always
computed) and, optionally, the sanitised qualitative theme markdown
(only when the author opts into the LLM thematic analysis path).
The quantitative stats (``stats_json``) are always populated β they
are pure Python and require no LLM. The qualitative fields
(``theme_markdown``, ``llm_*``) are only populated when the author
clicks the "Generate LLM theme summary" button; they are blank when
the author chooses manual thematic analysis only (via the
"Download comments" button).
"""
round = models.ForeignKey(
DelphiRound, related_name="feedback", on_delete=models.CASCADE,
)
question = models.ForeignKey(
"SurveyQuestion", on_delete=models.CASCADE,
)
stats_json = models.JSONField(default=dict)
# LLM thematic analysis fields β only populated when the author opts
# into the LLM path. Blank when the author uses manual download only.
theme_markdown = models.TextField(
blank=True,
help_text="Sanitised LLM theme summary (qualitative questions, "
"opt-in only).",
)
llm_generated = models.BooleanField(
default=False,
help_text="True if the LLM theme summary was generated for this "
"question. False if the author chose manual analysis only.",
)
generated_at = models.DateTimeField(auto_now_add=True)
llm_model = models.CharField(max_length=100, blank=True)
llm_token_count = models.PositiveIntegerField(default=0)
llm_success = models.BooleanField(default=False)
class Meta:
unique_together = ("round", "question")
class SurveyProgress(models.Model):
...
# Delphi round assignment. Only populated for surveys with
# layout = "delphi". Null for other layouts. The precedent is
# ``assigned_arm`` (RCT) β same shape, separate field.
delphi_round = models.ForeignKey(
"DelphiRound", null=True, blank=True,
on_delete=models.SET_NULL,
related_name="progress_rows",
)
# Delphi within-round completion tracking. The precedent is
# ``completed_group_ids`` (Matrix) β same shape (list of IDs).
delphi_completed_rounds = models.JSONField(default=list, blank=True)
Migration: 0067_delphi_layout (adds the delphi choice to
Survey.layout, the DelphiMenu / DelphiRound /
DelphiRoundFeedback models, and the SurveyProgress fields).
Allocation strategy
Delphi round assignment is simpler than RCT arm allocation: there is no
randomisation. The participant is assigned to the current open round
on first access, and advances to the next round when the current round
closes and the next opens. The assignment is stable across resume
(delphi_round FK is preserved).
def current_round(menu: DelphiMenu, *, enrolment, survey_start, now) -> DelphiRound | None:
"""Return the currently-open round, or None if no round is open."""
def next_round(menu: DelphiMenu, progress: SurveyProgress) -> DelphiRound | None:
"""Return the next round the participant should advance to."""
def assign_round_for_progress(
progress: SurveyProgress, menu: DelphiMenu, *, now
) -> DelphiRound | None:
"""Assign and persist the current round for ``progress``.
Idempotent: if ``progress.delphi_round`` is already set and still
open, returns it. If the assigned round has closed, advances to the
next open round. If no round is open, returns None (participant sees
a "check back later" page, like Staged).
"""
These sit in delphi.py next to staged.py and matrix.py β pure
functions, no side effects except assign_round_for_progress which
persists the FK.
Runtime hook
The runtime hook is a new branch in _handle_participant_submission,
analogous to the staged branch:
if survey.layout == Survey.Layout.DELPHI and progress is not None:
menu = getattr(survey, "delphi_menu", None)
current = assign_round_for_progress(progress, menu, now=timezone.now())
if current is None:
# No round open β render "check back later" page.
return _render_delphi_waiting(request, survey, progress)
# Resolve selected_group_ids from the current round's groups.
round_group_ids = set(current.groups.values_list("id", flat=True))
selected_group_ids = [
g for g in _resolved_group_order_ids(survey)
if g in round_group_ids
]
The inter-round feedback is rendered via content blocks whose
options.body_md is substituted from the DelphiRoundFeedback cache
at view time β no new feedback rendering model, just a body source
swap in _annotate_question_render_sequence.
Outline grammar
DELPHI
anchor: enrolment
min_rounds: 2
max_rounds: 3
show_progress: true
allow_revision: true
round: Round 1
start: 0
end: 14
round: Round 2
start: 14
end: 28
# Demographics {demographics} ~ round:1, round:2
## Name {name}
(text)
# Round 1 questions {r1-questions} ~ round:1
## Importance {importance}
(likert) 1-5
# Round 2 questions {r2-questions} ~ round:2
## Revised importance {revised-importance}
(likert) 1-5
The ~ round:<N> suffix on group headings assigns a group to a round.
A group can appear in multiple rounds (e.g. demographics in every
round). The round: config lines inside the DELPHI block define the
round windows. The grammar round-trips through export β import like
the other layouts.
Warnings
| Issue | Why it matters | Handling |
|---|---|---|
| No rounds configured | A Delphi survey with no rounds is unrunnable. | Block take view with a clear error; warn on Organise page. |
| Round with no groups | A round with an empty group set renders nothing. | Warn on Organise page; block take view. |
| Overlapping round windows | Rounds are meant to be sequential. | Warn if two rounds' windows overlap; non-blocking. |
| Group in no round | A group not assigned to any round is unreachable. | Warn on Organise page (like Staged). |
| Feedback not generated | Round N+1 opens but round N has no DelphiRoundFeedback. |
Warn on Organise page; participants see content blocks with empty body (graceful). |
| LLM unavailable for themes | Qualitative LLM feedback can't be generated. | Quantitative feedback still renders; qualitative content blocks show "download comments to review manually". The manual download is always available. Non-blocking. |
| Participant hasn't completed round N | Advancing to round N+1 without completing N loses revision context. | Gate round advancement on delphi_completed_rounds containing the current round. |
| Revision disabled | allow_revision=False but round N+1 re-shows round N's questions. |
Hide previous-round answers in round N+1 (render blank). Warn the author. |
Preview
?simulate_round=<round_id> filters the questions to that round's
groups (ordered by _resolved_group_order_ids). A "Simulate round"
panel on the preview page lets the author pick a round without a real
participant. ?simulate_round=<id>&with_feedback=1 also substitutes
the DelphiRoundFeedback body into content blocks so the author can
preview the inter-round feedback view.
Survey Map
The Survey Map shows the full authored survey with round badges per group (like the phase badges in Staged and the arm badges in RCT). A round-summary card above the visualiser lists the rounds, their windows, and whether feedback has been generated.
Issues and edge cases
| Issue | Why it matters | Handling |
|---|---|---|
| Round windows vs manual open/close | The author can both set window offsets and manually open/close. | Manual open/close overrides the window. If opened_at is set, the window is ignored for that round. |
| Participant mid-round when round closes | The round closes while the participant is still answering. | Grace period: the participant can submit for 24h after closed_at. After that, their progress is frozen for that round. |
| Last round | No next round to advance to. | After the last round closes, the survey is complete for that participant. SurveyProgress.status β completed. |
| Min/max rounds drift | Author changes max_rounds after participants have completed 3 rounds. |
Clamp to the new max; warn the author. Existing progress is preserved. |
| Feedback regeneration | Author regenerates feedback after more responses come in. | Delete the old DelphiRoundFeedback rows and recreate. Participants in round N+1 see the updated feedback on next view. |
| Anonymity | Delphi often requires anonymous responses. | Aggregation never reveals individual responses. The LLM prompt explicitly forbids verbatim quotes. Authors can mark a survey as "anonymous" (existing field) to hide participant identity from the author too. |
Delphi compatibility
Delphi is the destination layout β the earlier layouts were shaped to reuse their ingredients here. Delphi does not need to be compatible with a future layout; it is the consumer of the patterns proven by RCT, Staged, Matrix, and Content blocks.
Related documentation
- Survey Layouts β user-facing guide.
- Organise β the page this feature lives on.
- Branching Technical Guide β runtime ordering pipeline, builder route security, outline grammar.
- Survey Progress Tracking (Technical)
β the prerequisite feature that carries
selected_group_ids.