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_idstryes—Human-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
textstryes—Question text
descriptionstr | NonenoNone
typeQuestionTypeyes—See 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: code → system-observation (LOINC), unit → system-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.