Skip to main content
Version: 3.1

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 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.

ModelPurpose
QuestionnaireA versioned form definition: questions, styling, subject type, and status
FormSubmissionA submission of a questionnaire against a patient (and optional encounter)
QuestionnaireResponseA stored set of answers for a subject, optionally tied to a submission
QuestionnaireOrganizationScopes a questionnaire to an instance-level Organization
QuestionnaireFacilityOrganizationScopes a questionnaire to a FacilityOrganization
QuestionnaireResponseTemplateReusable prefill template for questionnaire responses

All extend EMRBaseModel, which supplies external_id, audit fields, and soft-delete semantics.

Questionnaire fields

Definition

FieldTypeReqNotes
versionCharField(255)yesVersion label. The write spec freezes it to "1.0"
slugCharField(255)yesunique; defaults to a generated uuid4. The write spec validates SlugType: 5–50 chars, URL-safe, and may not shadow an internal questionnaire type
titleCharField(255)yesDisplay title. The write spec strips it and rejects blank/whitespace
descriptionTextFieldnoDefaults to ""
subject_typeCharField(255)yesSubjectType enum — see values
statusCharField(255)yesQuestionnaireStatus enum — see values
styling_metadataJSONFieldnoDefaults to {}. Opaque UI/layout hints; never validated
questionsJSONFieldyesDefaults to {} in the DB, but is a list[Question] tree in practice — see Question shape
organization_cacheArrayField[int]Denormalized cache, server-maintained (see cache sync)
internal_organization_cacheArrayField[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.

FieldMaintained by
organization_cacheQuestionnaireOrganization.sync_questionnaire_cache() — instance Organization IDs plus each org's parent_cache
internal_organization_cacheQuestionnaireFacilityOrganization.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.

ValueNotes
groupContainer; must have ≥1 sub-question
booleanValidated against true/false/1/0 on submit
decimal
integer
string
textLength capped by settings.MAX_QUESTIONNAIRE_TEXT_RESPONSE_SIZE on submit
displayDisplay-only, no answer
dateISO date
dateTimeMust include timezone on submit
time%H:%M:%S
choiceRequires answer_option or answer_value_set
urlMust have scheme + netloc
quantityRequires answer_option or answer_value_set; answers need a unit
structuredSkipped 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).

ValueMeaning
allEnable only if all conditions pass (default on submit)
anyEnable 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:

FieldTypeReqDefaultNotes
link_idstryesHuman-readable link ID; must be unique across the whole tree
idUUID4 | UUID5nouuid4()Machine ID; must be unique across the whole tree
codeCoding bound to value set system-observationnoNoneLOINC-bound; required to emit observations
collect_timeboolnofalseCollect a per-answer timestamp
collect_performerboolnofalseCollect a Performer reference
textstryesQuestion text
descriptionstr | NonenoNone
typeQuestionTypeyesSee values
structured_typestr | NonenoNone
enable_whenlist[EnableWhen] | NonenoNoneConditional display rules
enable_behaviorEnableBehavior | NonenoNone
disabled_displayDisabledDisplay | NonenoNone
collect_body_sitebool | NonenoNone
collect_methodbool | NonenoNone
requiredbool | NonenoNone
repeatsbool | NonenoNoneMulti-select / repeating group
read_onlybool | NonenoNone
max_lengthint | NonenoNone
answer_constraintAnswerConstraint | NonenoNone
answer_optionlist[AnswerOption] | NonenoNoneInline choices
answer_value_setstr | NonenoNoneSlug of a ValueSet; validated to exist
is_observationbool | NonenoNoneStore the answer as an observation
unitCoding bound to value set system-ucum-unitsnoNoneUCUM-bound unit
questionslist[Question]no[]Recursive children
formulastr | NonenoNoneClient-side calculated field
styling_metadatadictno{}
templateslist[TemplateConfig]no[]
is_componentboolnofalseEmit child answers as observation components

A model_validator (mode after) enforces three rules:

  • choice / quantity types must have answer_option or answer_value_set.
  • group types must have at least one sub-question.
  • answer_value_set, when set, must reference an existing ValueSet.

Sub-specs of Question

SpecShape
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

SpecRoleKey fields / behaviour
QuestionnaireBaseSpecshared__model__ = Questionnaire
QuestionnaireWriteSpecwrite (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
QuestionnaireSpecwrite · createExtends 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
QuestionnaireUpdateSpecwrite · updateSame as QuestionnaireWriteSpec, minus organizations
QuestionnaireReadSpecread · list/detailid (= 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: codesystem-observation (LOINC), unitsystem-ucum-units (UCUM). answer_value_set references any ValueSet by slug.

Questionnaire response

SpecRoleKey fields / behaviour
EMRQuestionnaireResponseBaseshared__model__ = QuestionnaireResponse
QuestionnaireResponseUpdatewrite · updatestatus: QuestionnaireResponseStatusChoices (default completed) — the only handle for voiding a response by marking it entered_in_error
QuestionnaireResponseReadSpecread · detailid, 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:

SpecShape
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

SpecRoleKey fields / behaviour
BaseFormSubmissionSpecshared__model__ = FormSubmission, id: UUID4 | None
FormSubmissionUpdateSpecwrite · updatestatus: FormSubmissionStatusChoices, response_dump: dict
FormSubmissionWriteSpecwrite · createAdds 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
FormSubmissionReadSpecread · detailid, status, response_dump, created_date, modified_date, created_by / updated_by (UserSpec)

Questionnaire response template

SpecRoleKey fields / behaviour
QuestionnaireResponseTemplateBaseSpecshared__model__ = QuestionnaireResponseTemplate, id: UUID4 | None, template_data: TemplateData, name: str, description: str = ""
QuestionnaireResponseTemplateCreateSpecwrite · createAdds 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
QuestionnaireResponseTemplateUpdateSpecwrite · updateusers, facility_organizations; recomputes available_keys
QuestionnaireResponseTemplateReadSpecread · listcreated_date, modified_date; id = external_id
QuestionnaireResponseTemplateRetrieveSpecread · detailExtends 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:

FieldTypeNotes
medication_requestlist[MedicationRequestTemplateSpec] | NoneExtends the MedicationRequest write spec with requested_product: str | None (validated against ProductKnowledge.slug)
questionnairelist[QuestionnaireAnswer] | NoneQuestionnaireAnswer = { question_id: str, answer: dict, meta: dict }
activity_definitionlist[ActivityDefinitionTemplateSpec] | None{ slug (validated against ActivityDefinition), service_request: ServiceRequestUpdateSpec }
metadict | None

available_keys (model ArrayField) is server-maintained: on create/update it is set to the template_data keys whose value is truthy.

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:

  1. Rejects the submission if the questionnaire status != "active" (questionnaire_inactive).
  2. Resolves encounter (required when subject_type == "encounter") and patient by external_id.
  3. Rejects empty submissions (questionnaire_empty).
  4. Prunes the question tree by enable_when rules; answers to disabled questions raise enable_when_failed.
  5. Validates each answer against its type (type checks per QuestionType), required, answer_value_set (the coding must belong to the value set), and quantity units. Any failure aborts with an errors payload.
  6. Builds ObservationSpec objects for coded/group questions and creates a QuestionnaireResponse (raw responses = the dumped results).
  7. 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:

  1. Loads all through-rows for the parent questionnaire.
  2. Collects each linked organization's id plus its parent_cache (ancestor chain).
  3. De-duplicates the IDs.
  4. Writes them back via questionnaire.save(update_fields=[...])organization_cache for instance orgs, internal_organization_cache for 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 uses QuestionnaireSpec (requires ≥1 organizations); update uses QuestionnaireUpdateSpec; reads use QuestionnaireReadSpec.
  • questions and styling_metadata are open JSONFields at the DB layer, but the API validates questions against the recursive Question spec — unique link_id/id, the choice/quantity/group constraints, and value-set existence. styling_metadata is never validated.
  • organization_cache / internal_organization_cache and QuestionnaireResponseTemplate.available_keys are platform-maintained. Don't set them from clients; write through the through-models or template_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_error via QuestionnaireResponseUpdate.
  • FormSubmissionWriteSpec resolves the questionnaire by slug and, when an encounter is supplied, forces the submission's patient to the encounter's patient.