Clinical care
Questionnaire
A Questionnaire is a versioned, FHIR-inspired form: a tree of questions answered about a patient or an encounter. You touch it whenever you define a data-collection form, render one for filling, or submit answers — which can flow back out as…
3.1 · versioned documentation · 12 min read
A Questionnaire is a versioned, FHIR-inspired form: a tree of questions answered about a patient or an encounter. You touch it whenever you define a data-collection form, render one for filling, or submit answers — which can flow back out as Observations. It captures both clinical data and arbitrary non-clinical structured data.
The Django model is only the storage layer. The API schema you code against lives in the Pydantic resource specs under care/emr/resources/questionnaire*: enums, the nested shape of the JSON fields, validation, and the split between read and write contracts.
Source (model): care/emr/models/questionnaire.py
Source (specs):
resources/questionnaire/spec.py ·
resources/questionnaire/utils.py ·
resources/questionnaire/questionnaire_organization.py ·
resources/questionnaire_response/spec.py ·
resources/questionnaire_response_template/spec.py ·
resources/form_submission/spec.py
Models
Six models back the forms feature. The Questionnaire is the definition; everything else records submissions, answers, organization scope, or reusable prefill.
| Model | Purpose |
|---|---|
Questionnaire |
A versioned form definition: questions, styling, subject type, and status |
FormSubmission |
A submission of a questionnaire against a patient (and optional encounter) |
QuestionnaireResponse |
A stored set of answers for a subject, optionally tied to a submission |
QuestionnaireOrganization |
Scopes a questionnaire to an instance-level Organization |
QuestionnaireFacilityOrganization |
Scopes a questionnaire to a FacilityOrganization |
QuestionnaireResponseTemplate |
Reusable prefill template for questionnaire responses |
All extend EMRBaseModel, which supplies external_id, audit fields, and soft-delete semantics.
Questionnaire fields
Definition
| Field | Type | Req | Notes |
|---|---|---|---|
version |
CharField(255) |
yes | Version label. The write spec freezes it to "1.0" |
slug |
CharField(255) |
yes | unique; defaults to a generated uuid4. The write spec validates SlugType: 5–50 chars, URL-safe, and may not shadow an internal questionnaire type |
title |
CharField(255) |
yes | Display title. The write spec strips it and rejects blank/whitespace |
description |
TextField |
no | Defaults to "" |
subject_type |
CharField(255) |
yes | SubjectType enum — see values |
status |
CharField(255) |
yes | QuestionnaireStatus enum — see values |
styling_metadata |
JSONField |
no | Defaults to {}. Opaque UI/layout hints; never validated |
questions |
JSONField |
yes | Defaults to {} in the DB, but is a list[Question] tree in practice — see Question shape |
organization_cache |
ArrayField[int] |
— | Denormalized cache, server-maintained (see cache sync) |
internal_organization_cache |
ArrayField[int] |
— | Denormalized cache, server-maintained (see cache sync) |
The write spec also carries a type: str field (default "custom") that has no backing model column.
Organization scope caches
organization_cache and internal_organization_cache hold flattened organization IDs so access filtering can run without deep joins. The through-models below own them; clients never write them directly.
| Field | Maintained by |
|---|---|
organization_cache |
QuestionnaireOrganization.sync_questionnaire_cache() — instance Organization IDs plus each org's parent_cache |
internal_organization_cache |
QuestionnaireFacilityOrganization.sync_questionnaire_cache() — FacilityOrganization IDs plus each org's parent_cache |
Enum values
QuestionnaireStatus values
The lifecycle state of the definition, modeled on FHIR publication-status. Once a questionnaire is active, edit and delete are off the table — move it to retired instead.
| Value |
|---|
active |
retired |
draft |
SubjectType values
What kind of resource the form is about. This drives whether an encounter is required at submit time.
| Value |
|---|
patient |
encounter |
QuestionType values
The type of each question (Question.type). The commented-out members open_choice, attachment, and reference are not implemented.
| Value | Notes |
|---|---|
group |
Container; must have ≥1 sub-question |
boolean |
Validated against true/false/1/0 on submit |
decimal |
|
integer |
|
string |
|
text |
Length capped by settings.MAX_QUESTIONNAIRE_TEXT_RESPONSE_SIZE on submit |
display |
Display-only, no answer |
date |
ISO date |
dateTime |
Must include timezone on submit |
time |
%H:%M:%S |
choice |
Requires answer_option or answer_value_set |
url |
Must have scheme + netloc |
quantity |
Requires answer_option or answer_value_set; answers need a unit |
structured |
Skipped by response validation |
EnableOperator values
The comparison for an enable_when condition (EnableWhen.operator).
| Value |
|---|
exists |
equals |
not_equals |
greater |
less |
greater_or_equals |
less_or_equals |
EnableBehavior values
How multiple enable_when conditions combine (Question.enable_behavior).
| Value | Meaning |
|---|---|
all |
Enable only if all conditions pass (default on submit) |
any |
Enable if any condition passes |
DisabledDisplay values
How a disabled question renders (Question.disabled_display).
| Value |
|---|
hidden |
protected |
AnswerConstraint values
Whether free input outside the options is allowed (Question.answer_constraint).
| Value |
|---|
required |
optional |
QuestionnaireResponseStatusChoices values
QuestionnaireResponse.status (model default "completed").
| Value |
|---|
completed |
entered_in_error |
FormSubmissionStatusChoices values
FormSubmission.status.
| Value |
|---|
draft |
submitted |
entered_in_error |
Question nested shape
Questionnaire.questions is an opaque JSONField, but the API contract is a recursive list of Question (QuestionnaireBaseSpec). Fields, with their real types and validation:
| Field | Type | Req | Default | Notes |
|---|---|---|---|---|
link_id |
str |
yes | — | Human-readable link ID; must be unique across the whole tree |
id |
UUID4 | UUID5 |
no | uuid4() |
Machine ID; must be unique across the whole tree |
code |
Coding bound to value set system-observation |
no | None |
LOINC-bound; required to emit observations |
collect_time |
bool |
no | false |
Collect a per-answer timestamp |
collect_performer |
bool |
no | false |
Collect a Performer reference |
text |
str |
yes | — | Question text |
description |
str | None |
no | None |
|
type |
QuestionType |
yes | — | See values |
structured_type |
str | None |
no | None |
|
enable_when |
list[EnableWhen] | None |
no | None |
Conditional display rules |
enable_behavior |
EnableBehavior | None |
no | None |
|
disabled_display |
DisabledDisplay | None |
no | None |
|
collect_body_site |
bool | None |
no | None |
|
collect_method |
bool | None |
no | None |
|
required |
bool | None |
no | None |
|
repeats |
bool | None |
no | None |
Multi-select / repeating group |
read_only |
bool | None |
no | None |
|
max_length |
int | None |
no | None |
|
answer_constraint |
AnswerConstraint | None |
no | None |
|
answer_option |
list[AnswerOption] | None |
no | None |
Inline choices |
answer_value_set |
str | None |
no | None |
Slug of a ValueSet; validated to exist |
is_observation |
bool | None |
no | None |
Store the answer as an observation |
unit |
Coding bound to value set system-ucum-units |
no | None |
UCUM-bound unit |
questions |
list[Question] |
no | [] |
Recursive children |
formula |
str | None |
no | None |
Client-side calculated field |
styling_metadata |
dict |
no | {} |
|
templates |
list[TemplateConfig] |
no | [] |
|
is_component |
bool |
no | false |
Emit child answers as observation components |
A model_validator (mode after) enforces three rules:
choice/quantitytypes must haveanswer_optionoranswer_value_set.grouptypes must have at least one sub-question.answer_value_set, when set, must reference an existingValueSet.
Sub-specs of Question
| Spec | Shape |
|---|---|
EnableWhen |
{ question: str (link_id), operator: EnableOperator, answer: Any } |
AnswerOption |
{ value: Any (non-blank, stripped), initial_selected: bool = false } |
Performer |
{ performer_type: str, performer_id: str | None, text: str | None } |
TemplateConfig |
{ name: str, content: str, structured_content: dict | None, meta: dict | None } |
Resource specs (API schema)
Every spec builds on EMRResource, which provides serialize / de_serialize. Read specs run perform_extra_serialization; write specs run perform_extra_deserialization.
Questionnaire
| Spec | Role | Key fields / behaviour |
|---|---|---|
QuestionnaireBaseSpec |
shared | __model__ = Questionnaire |
QuestionnaireWriteSpec |
write (base) | version (frozen "1.0"), slug (SlugType), title, description, type ("custom"), status, subject_type, styling_metadata, questions. Validates slug uniqueness and that it doesn't shadow internal types, a non-empty title, and unique link_ids and ids across the whole tree |
QuestionnaireSpec |
write · create | Extends the write spec with organizations: list[UUID4] (min_length=1). perform_extra_deserialization stashes them on obj._organizations; the view then links them through QuestionnaireOrganization, which rebuilds organization_cache |
QuestionnaireUpdateSpec |
write · update | Same as QuestionnaireWriteSpec, minus organizations |
QuestionnaireReadSpec |
read · list/detail | id (= external_id), slug, version, title, description, status, subject_type, styling_metadata, questions (raw list), created_by / updated_by (resolved via serialize_audit_users) |
Value sets bound on questions: code → system-observation (LOINC), unit → system-ucum-units (UCUM). answer_value_set references any ValueSet by slug.
Questionnaire response
| Spec | Role | Key fields / behaviour |
|---|---|---|
EMRQuestionnaireResponseBase |
shared | __model__ = QuestionnaireResponse |
QuestionnaireResponseUpdate |
write · update | status: QuestionnaireResponseStatusChoices (default completed) — the only handle for voiding a response by marking it entered_in_error |
QuestionnaireResponseReadSpec |
read · detail | id, status, questionnaire (nested QuestionnaireReadSpec), subject_id, responses (raw list), encounter (external id or None), structured_responses, structured_response_type, created_by / updated_by (UserSpec), created_date, modified_date |
Submit request schema (not a DB write spec)
The submit endpoint (/questionnaire/<slug>/submit/) takes a plain Pydantic request, which handle_response() in utils.py validates and persists:
| Spec | Shape |
|---|---|
QuestionnaireSubmitRequest |
{ resource_id: UUID4, encounter: UUID4 | None, patient: UUID4, results: list[QuestionnaireSubmitResult], form_submission: UUID4 | None } |
QuestionnaireSubmitResult |
{ question_id: UUID4|UUID5, body_site: Coding | None, method: Coding | None, taken_at: datetime | None, values: list[QuestionnaireSubmitResultValue], note: str | None, sub_results: list[list[QuestionnaireSubmitResult]] } |
QuestionnaireSubmitResultValue |
{ value: str | None, unit: Coding | None, coding: Coding | None } |
Form submission
| Spec | Role | Key fields / behaviour |
|---|---|---|
BaseFormSubmissionSpec |
shared | __model__ = FormSubmission, id: UUID4 | None |
FormSubmissionUpdateSpec |
write · update | status: FormSubmissionStatusChoices, response_dump: dict |
FormSubmissionWriteSpec |
write · create | Adds questionnaire: str (slug), patient: UUID4, encounter: UUID4 | None. perform_extra_deserialization resolves the questionnaire by slug and the patient/encounter by external_id; when an encounter is given it overrides patient with the encounter's patient |
FormSubmissionReadSpec |
read · detail | id, status, response_dump, created_date, modified_date, created_by / updated_by (UserSpec) |
Questionnaire response template
| Spec | Role | Key fields / behaviour |
|---|---|---|
QuestionnaireResponseTemplateBaseSpec |
shared | __model__ = QuestionnaireResponseTemplate, id: UUID4 | None, template_data: TemplateData, name: str, description: str = "" |
QuestionnaireResponseTemplateCreateSpec |
write · create | Adds questionnaire: str | None (slug), facility: UUID4 | None, users: list[str], facility_organizations: list[UUID4]. Requires facility whenever facility_organizations is set. Resolves questionnaire/facility and recomputes available_keys from the non-empty keys of template_data |
QuestionnaireResponseTemplateUpdateSpec |
write · update | users, facility_organizations; recomputes available_keys |
QuestionnaireResponseTemplateReadSpec |
read · list | created_date, modified_date; id = external_id |
QuestionnaireResponseTemplateRetrieveSpec |
read · detail | Extends the read spec with resolved users: list[dict], facility_organizations: list[dict], created_by, updated_by |
template_data (TemplateData) shape
QuestionnaireResponseTemplate.template_data is an opaque JSONField that the spec validates as:
| Field | Type | Notes |
|---|---|---|
medication_request |
list[MedicationRequestTemplateSpec] | None |
Extends the MedicationRequest write spec with requested_product: str | None (validated against ProductKnowledge.slug) |
questionnaire |
list[QuestionnaireAnswer] | None |
QuestionnaireAnswer = { question_id: str, answer: dict, meta: dict } |
activity_definition |
list[ActivityDefinitionTemplateSpec] | None |
{ slug (validated against ActivityDefinition), service_request: ServiceRequestUpdateSpec } |
meta |
dict | None |
available_keys (model ArrayField) is server-maintained: on create/update it is set to the template_data keys whose value is truthy.
Related models
FormSubmission
A submission event linking a questionnaire to a patient and, optionally, an encounter.
questionnaire → FK Questionnaire (CASCADE)
patient → FK emr.Patient (CASCADE)
encounter → FK emr.Encounter (CASCADE, nullable)
status → CharField(255) # FormSubmissionStatusChoices
response_dump → JSONField (default {})
QuestionnaireResponse
The answers for a subject. The questionnaire FK is nullable, so a response can outlive or detach from its definition.
questionnaire → FK Questionnaire (CASCADE, nullable)
subject_id → UUIDField
responses → JSONField (default []) # raw submitted results
structured_responses → JSONField (default {}) # extracted/structured data
structured_response_type → CharField (nullable)
patient → FK emr.Patient (CASCADE)
encounter → FK emr.Encounter (CASCADE, nullable)
form_submission → FK FormSubmission (CASCADE, nullable)
status → CharField(255), default "completed"
QuestionnaireOrganization / QuestionnaireFacilityOrganization
Through-models that scope a questionnaire to organizations — the first to an instance-level Organization, the second to a FacilityOrganization.
questionnaire → FK Questionnaire (CASCADE)
organization → FK Organization | FacilityOrganization (CASCADE)
Each overrides save() to recompute the matching cache on the parent Questionnaire (see Methods & save behaviour). Their specs (questionnaire_organization.py) exclude both FKs from serialization (__exclude__ = ["questionnaire", "organization"]): the write spec accepts organization: UUID4, the read spec returns organization: dict.
QuestionnaireResponseTemplate
A reusable template that prefills questionnaire responses, optionally scoped to a facility and to specific facility organizations or users.
facility → FK facility.Facility (CASCADE, nullable)
name → CharField(255)
description → TextField (default "")
template_data → JSONField (default {}) # TemplateData shape
questionnaire → FK Questionnaire (CASCADE, nullable, default None)
facility_organizations → ArrayField[int] (default [])
users → ArrayField[int] (default [])
available_keys → ArrayField[CharField(255)] (default []) # server-maintained
Methods & save behaviour
Questionnaire.get_questions_by_id()
Walks the questions tree, recursing into nested questions, and returns a { str(question_id): question } dict. The result is memoized on the instance via _questions_by_id_cache. If questions is not a list, it returns an empty dict.
QuestionnaireResponse.render_responses()
Joins stored responses against the live questionnaire definition. For each answer it looks up the question through questionnaire.get_questions_by_id() and returns a list of { "answer": ..., "question": ... }. It returns an empty list when there are no responses or no linked questionnaire, and skips any answer whose question_id is no longer in the definition.
Submission handling — handle_response()
resources/questionnaire/utils.py runs this server-side on every submission:
- Rejects the submission if the questionnaire
status != "active"(questionnaire_inactive). - Resolves
encounter(required whensubject_type == "encounter") andpatientbyexternal_id. - Rejects empty submissions (
questionnaire_empty). - Prunes the question tree by
enable_whenrules; answers to disabled questions raiseenable_when_failed. - Validates each answer against its
type(type checks per QuestionType),required,answer_value_set(the coding must belong to the value set), andquantityunits. Any failure aborts with anerrorspayload. - Builds
ObservationSpecobjects for coded/group questions and creates aQuestionnaireResponse(rawresponses= the dumped results). - When an encounter is present, bulk-creates the derived Observations, linked back to the response.
Organization cache sync
QuestionnaireOrganization.save() and QuestionnaireFacilityOrganization.save() call super().save(), then sync_questionnaire_cache(), which:
- Loads all through-rows for the parent questionnaire.
- Collects each linked organization's
idplus itsparent_cache(ancestor chain). - De-duplicates the IDs.
- Writes them back via
questionnaire.save(update_fields=[...])—organization_cachefor instance orgs,internal_organization_cachefor facility orgs.
Saving any organization link triggers a second write to the Questionnaire row.
API integration notes
- Questionnaires expose CRUD plus a submit endpoint (
/questionnaire/<slug>/submit/). Create usesQuestionnaireSpec(requires ≥1organizations); update usesQuestionnaireUpdateSpec; reads useQuestionnaireReadSpec. questionsandstyling_metadataare openJSONFields at the DB layer, but the API validatesquestionsagainst the recursiveQuestionspec — uniquelink_id/id, the choice/quantity/group constraints, and value-set existence.styling_metadatais never validated.organization_cache/internal_organization_cacheandQuestionnaireResponseTemplate.available_keysare platform-maintained. Don't set them from clients; write through the through-models ortemplate_data.- Submitting answers validates type/required/value-set server-side and, for clinical questions, materializes Observations. To void a response, set its status to
entered_in_errorviaQuestionnaireResponseUpdate. FormSubmissionWriteSpecresolves the questionnaire by slug and, when an encounter is supplied, forces the submission's patient to the encounter's patient.
Related
- Source: questionnaire.py on GitHub
- Spec: questionnaire/spec.py · response spec · submit utils · form_submission spec · response template spec
- Reference: Base model
- Reference: Questionnaire Response — submitted answers (
QuestionnaireResponse/FormSubmission) - Reference: Questionnaire Response Template — reusable pre-fill templates
- Reference: ValueSet
- Reference: Patient
- Reference: Encounter
- Reference: Observation
- Reference: Organization
- Reference: MedicationRequest
- Reference: ServiceRequest
- Reference: ActivityDefinition
Source evidence
References are pinned to the private MANSOQ source repository. Repository access is required to open these links.