Field Mapping System
Field Mapping System
The field mapping system controls how ODK submission data is displayed in DigitVA:
labels, category groupings, choice translations, flip colours, info flags, and PII
handling. All configuration is stored in the database under the mas_* table family
and managed through the admin UI at /admin/?panel=/admin/panels/field-mapping.
Data Model
Six tables form the core of the system. All are scoped to a form_type_id so
multiple form types can coexist independently.
mas_form_types
└─► mas_category_order (legacy compatibility copy)
└─► mas_category_display_config (category labels, icons, render mode, role visibility)
└─► mas_subcategory_order (sub-groupings within categories)
└─► mas_field_display_config (per-field labels and flags)
└─► mas_choice_mappings (choice value → label translations)
mas_form_types
Registry of supported VA form types.
| Column | Type | Notes |
|---|---|---|
form_type_id |
UUID PK | |
form_type_code |
VARCHAR(32) UNIQUE | Uppercase, e.g. WHO_2022_VA |
form_type_name |
VARCHAR(128) | Human-readable display name |
form_type_description |
TEXT | Optional free text |
base_template_path |
VARCHAR(256) | Optional path to base XLSForm |
mapping_version |
INTEGER | Incremented on major remaps |
is_active |
BOOLEAN | Soft-delete flag |
created_at / updated_at |
TIMESTAMP | UTC |
mas_category_order
Legacy compatibility table for category identity and order within a form type.
Runtime and admin rendering now treat mas_category_display_config as the
authoritative category source.
| Column | Type | Notes |
|---|---|---|
category_order_id |
UUID PK | |
form_type_id |
UUID FK | |
category_code |
VARCHAR(64) | e.g. A, B, Demographics |
category_name |
VARCHAR(128) | Display label |
display_order |
INTEGER | Ascending sort |
is_active |
BOOLEAN |
Unique constraint: (form_type_id, category_code).
mas_category_display_config
Category-level display metadata per form type. This is now the authoritative category table for runtime rendering and admin category management.
| Column | Type | Notes |
|---|---|---|
category_display_config_id |
UUID PK | |
form_type_id |
UUID FK | |
category_code |
VARCHAR(64) | Must match a category row for the same form type |
display_label |
VARCHAR(128) | Full heading used in the content panel |
nav_label |
VARCHAR(128) | Left-nav label |
icon_name |
VARCHAR(64) | Font Awesome icon class suffix |
display_order |
INTEGER | Authoritative category/nav order |
render_mode |
VARCHAR(32) | e.g. table_sections, health_history_summary, attachments |
show_to_coder |
BOOLEAN | Role visibility flag |
show_to_reviewer |
BOOLEAN | Role visibility flag |
show_to_site_pi |
BOOLEAN | Role visibility flag |
always_include |
BOOLEAN | Category is present even when no mapped field survives |
is_default_start |
BOOLEAN | Preferred initial category for the coding screen |
is_active |
BOOLEAN | |
created_at / updated_at |
TIMESTAMP | UTC |
Unique constraint: (form_type_id, category_code).
mas_subcategory_order
Controls the display order of sub-categories within a category.
| Column | Type | Notes |
|---|---|---|
subcategory_order_id |
UUID PK | |
form_type_id |
UUID FK | |
category_code |
VARCHAR(64) | Parent category |
subcategory_code |
VARCHAR(64) | e.g. A1, A2 |
subcategory_name |
VARCHAR(128) | Display label |
display_order |
INTEGER | |
render_mode |
VARCHAR(32) | default, media_gallery, or future subcategory-level display mode |
is_active |
BOOLEAN |
Unique constraint: (form_type_id, category_code, subcategory_code).
Important design note:
- subcategory render behavior is now configurable
render_mode = media_galleryis currently used by the attachments renderer to show image carousels/thumbnails for sections such asmedical_documentsanddeath_documents- subcategories left as
defaultrender as normal query/response sections, even if some individual fields happen to contain image or audio attachments
mas_field_display_config
One row per field per form type. Controls every display aspect of a field.
| Column | Type | Notes |
|---|---|---|
config_id |
UUID PK | |
form_type_id |
UUID FK | |
field_id |
VARCHAR(64) | ODK variable name, e.g. Id10007 |
category_code |
VARCHAR(64) | Display category assignment |
subcategory_code |
VARCHAR(64) | Display sub-category assignment |
odk_label |
TEXT | Synced from XLSForm survey sheet |
short_label |
VARCHAR(256) | Concise label for coding screens |
full_label |
TEXT | Expanded label for detail views |
summary_label |
VARCHAR(256) | Label in the case summary panel |
field_type |
VARCHAR(32) | select_one, integer, text, etc. |
age_group |
VARCHAR(16) | neonate, child, adult, or blank |
flip_color |
BOOLEAN | Invert colour coding in the UI |
is_info |
BOOLEAN | Informational only — not coded |
summary_include |
BOOLEAN | Include in the live Symptoms on VA Interview summary panel |
is_pii |
BOOLEAN | Field contains PII |
pii_type |
VARCHAR(32) | name, dob, address, etc. |
display_order |
NUMERIC(10,2) | Order within sub-category; decimal values allowed for rapid inserts |
is_active |
BOOLEAN | |
is_custom |
BOOLEAN | True for app-added fields not in ODK |
created_at / updated_at |
TIMESTAMP | UTC |
Unique constraint: (form_type_id, field_id).
mas_choice_mappings
One row per field-choice combination. Translates ODK coded values to human-readable labels.
Important design note:
- XLSForm choice lists are reusable by
list_name, so multiple ODK fields may point to the same source choice set - DigitVA intentionally stores and renders choice mappings per field, not as a shared list-name master table
- the effective application lookup key is
(form_type_id, field_id, choice_value) - this means identical ODK choice sets may appear duplicated across fields in DigitVA by design
- the Choices admin panel follows that application model to avoid confusion about what the renderer actually uses at runtime
| Column | Type | Notes |
|---|---|---|
choice_id |
UUID PK | |
form_type_id |
UUID FK | |
field_id |
VARCHAR(64) | ODK variable name |
choice_value |
VARCHAR(128) | Coded value as stored in submission |
choice_label |
VARCHAR(256) | Display label |
display_order |
INTEGER | Ascending sort |
is_active |
BOOLEAN | |
synced_at |
TIMESTAMP | Last synced from ODK Central |
Unique constraint: (form_type_id, field_id, choice_value).
Admin UI
The Field Mapping panel lives at /admin/?panel=/admin/panels/field-mapping
and is restricted to admin users.
Form Type Management
The panel header shows a count of registered form types and two action buttons: Import and New Form Type.
Each registered form type is shown as a card with:
- Code, name, and description
- Stats: category count, field count, choice count
- Action buttons: Fields · Categories · Choices · Sync ODK · Duplicate · Export
Create a blank form type
New Form Type button → modal:
| Field | Validation |
|---|---|
| Form Type Code | Uppercase, [A-Z0-9_], max 32 chars, must be unique |
| Form Type Name | Free text, max 128 chars, required |
| Description | Optional free text |
Route: POST /admin/api/form-types
Body: { "form_type_code": "...", "form_type_name": "...", "description": "..." }
Response 201: { "form_type_code": "...", "form_type_name": "..." }
Response 409: { "error": "Form type already exists: ..." }
Duplicate an existing form type
Duplicate button on any card → same modal (labelled for duplicate, shows source info banner).
Route: POST /admin/api/form-types/<source_code>/duplicate
Body: { "new_code": "...", "new_name": "...", "description": "..." }
The service copies all child records under a new form_type_id with fresh UUIDs:
- All
MasCategoryDisplayConfigrows - All
MasSubcategoryOrderrows - All
MasFieldDisplayConfigrows (labels, flags, PII settings, ODK labels) - All
MasChoiceMappingsrows
The duplicate is fully independent. Changes to it do not affect the source.
Category display metadata is duplicated as well:
MasCategoryDisplayConfigrows are copied with fresh UUIDs- current
display_order,render_mode, labels, icons, role visibility, and always-include flags are preserved
Export a form type
Export button on any card → browser downloads form_type_<code>.json.
Route: GET /admin/api/form-types/<code>/export
Field Editing
The field edit modal is the authoritative admin UI for field-scoped choice labels.
Field Typeis shown read-only from synced ODK metadata- choice rows are shown per field, not per shared XLSForm
list_name - each row shows the ODK
choice_valueand an editable DigitVAchoice_label - the saved
choice_labelis what coding templates render for that specific field - editing a choice label here does not affect other fields, even if the source XLSForm reused the same choice list
- choice lookups at render time now normalize numeric JSON values like
3.0to match string-coded choice values like3, which is important for some SOCIAL form select fields synced from ODK
Categories Panel
The Categories subpanel is a three-panel browser:
CategoriesSubcategoriesFields
Current special behaviors in that panel:
- the header includes a
Special Categoriestooltip note vahealthhistorydetails / medical_historyshows a contextual note because fields assigned there feed theDISEASE / CO-MORBIDITYsummary grouping- subcategory add/edit modals now expose
Render Mode - subcategories with
render_mode = media_galleryare marked with aGallerybadge Response: JSON file attachment.
Export bundle format:
{
"schema_version": 1,
"exported_at": "2026-03-10T12:00:00+00:00",
"form_type": {
"form_type_code": "WHO_2022_VA",
"form_type_name": "WHO 2022 VA",
"form_type_description": "...",
"base_template_path": null,
"mapping_version": 1
},
"categories": [
{ "category_code": "A", "category_name": "Identification", "display_order": 1, "is_active": true }
],
"category_display_configs": [
{
"category_code": "vademographicdetails",
"display_label": "Demographic / Risk Factors",
"nav_label": "Demographic / Risk Factors",
"icon_name": "fa-user",
"display_order": 2,
"render_mode": "table_sections",
"show_to_coder": true,
"show_to_reviewer": true,
"show_to_site_pi": true,
"always_include": false,
"is_default_start": true,
"is_active": true
}
],
"subcategories": [
{ "category_code": "A", "subcategory_code": "A1", "subcategory_name": "...", "display_order": 1, "is_active": true }
],
"fields": [
{
"field_id": "Id10007", "category_code": "A", "subcategory_code": "A1",
"odk_label": "Date of Interview", "short_label": "Interview Date",
"full_label": null, "summary_label": null,
"field_type": "date", "age_group": null,
"flip_color": false, "is_info": false, "summary_include": true,
"is_pii": false, "pii_type": null,
"display_order": 1, "is_active": true, "is_custom": false
}
],
"choices": [
{ "field_id": "Id10019", "choice_value": "1", "choice_label": "Male", "display_order": 1, "is_active": true }
]
}
The file is safe to commit to version control and share between environments.
Import a form type
Import button in the panel header → modal with file picker.
On file selection a preview strip shows the embedded code, name, and record counts.
Route: POST /admin/api/form-types/import (multipart, enctype="multipart/form-data")
Form fields:
| Field | Notes |
|---|---|
file |
Required. The .json export file. Max 10 MB. |
override_code |
Optional. Use a different form type code on import. |
override_name |
Optional. Use a different form type name on import. |
override_description |
Optional. Override description. |
If the code in the file already exists in the database the import fails with a 409 conflict.
Use the "Import with a different code / name" toggle to resolve this.
Response 201:
{
"form_type_code": "WHO_2022_VA_COPY",
"form_type_name": "WHO 2022 VA Copy",
"categories_created": 14,
"subcategories_created": 42,
"fields_created": 410,
"choices_created": 1203
}
Import always creates a new form type. It does not update or merge into an existing one.
Deterministic Category Display Seed
Migration c7f1d2e3a4b5 adds mas_category_display_config and seeds the current
display metadata deterministically for:
WHO_2022_VAWHO_2022_VA_SOCIAL
The seeded snapshot is intentionally checked into the migration rather than derived from whatever happens to be in the target DB at runtime. As of March 12, 2026:
WHO_2022_VAseeds 14 category display rowsWHO_2022_VA_SOCIALseeds 15 category display rowssocial_autopsyis seeded at display order15forWHO_2022_VA_SOCIALvahealthhistorydetailsseedsrender_mode = health_history_summaryvanarrationanddocumentsseedsrender_mode = attachmentsandalways_include = true
The WHO Excel migrator now also upserts MasCategoryDisplayConfig rows from the same
deterministic defaults when Who2022VaMigrator().run() is used in seed/test paths.
Current runtime use:
CategoryRenderingServicenow readsmas_category_display_configfor:- left-nav category order
- role visibility
- always-include behavior
- default initial category selection
- previous/next traversal
- coder/reviewer render data still starts from the legacy static coder mapping, but
now bridges in DB-backed category mappings for role-visible categories that the
static dict does not define, such as
social_autopsy table_sectionscategories now render through the generic template stack:health_history_summarycategories now render throughcategory_health_history_summary.htmlattachmentscategories now render throughcategory_attachments.htmlplus attachment-specific includes:- the attachments render mode is intentionally hybrid: submission-data sections are generic, while SmartVA / QA / assessment workflow panels remain explicit inside the workflow include
Field Management
Fields button on any card → opens the field list sub-panel below the cards.
Route: GET /admin/panels/field-mapping/fields?form_type=<code>
The table shows all fields for the selected form type:
| Column | Description |
|---|---|
| Field ID | ODK variable name — read-only |
| ODK Label | Synced from XLSForm (read-only reference) |
| Short Label | App display label for coding screens |
| Category / Sub | Display grouping |
| Type | ODK field type |
| Flip | Colour inversion flag |
| Info | Informational-only flag |
| Summary | Included in case summary |
| PII | Contains personally identifiable information |
| Source | ODK (synced) or App (custom) |
Editing a field
Pencil icon on any row → edit modal (GET /admin/panels/field-mapping/field/<code>/<field_id>).
Editable fields in the modal:
| Field | Notes |
|---|---|
| Short Label | Coding screen display label |
| Full Label | Expanded label for detail/tooltip views |
| Summary Label | Label in the case summary panel |
| Category | Select from registered categories |
| Sub-category | Select from registered sub-categories |
| Age Group | neonate / child / adult / blank |
| Field Type | Override the ODK-sourced type |
| Flip Color | Toggle |
| Is Info | Toggle |
| Summary Include | Toggle |
| Is PII | Toggle |
| PII Type | Free text when Is PII is checked |
ODK Label and Field ID are shown read-only for reference. Hover tooltips on each label explain the field's purpose in the app.
On submit → POST /admin/panels/field-mapping/field/<code>/<field_id>
On success the table row updates in-place via HTMX (outerHTML swap) and the modal closes.
No full-page reload is needed.
ODK Sync
Sync ODK button on any card → opens the sync sub-panel.
Route: GET /admin/panels/field-mapping/sync?form_type=<code>
Selecting the ODK source
Three cascading dropdowns load progressively:
- ODK Connection — active connections from
MasOdkConnections; auto-selects if only one - ODK Project — loaded from
GET /admin/api/odk-connections/<id>/odk-projects - ODK Form — loaded from
GET /admin/api/odk-connections/<id>/odk-projects/<pid>/forms
Preview Changes and Run Sync buttons are disabled until a form is selected.
Preview changes (dry run)
Route: POST /admin/panels/field-mapping/sync/preview
Body: { "form_type_code", "connection_id", "odk_project_id", "odk_form_id" }
Downloads the XLSForm XLSX and computes a diff without writing to the database.
Response shows three change lists:
| Key | Meaning |
|---|---|
label_changes |
Fields whose ODK label would be updated; [{field_id, current_label, new_label}] |
new_choices |
Choices in ODK not yet in DB; [{field_id, value, label}] |
updated_choices |
Existing choices with changed labels; [{field_id, value, old_label, new_label}] |
If no changes are pending, a "Database is already up to date" message is shown.
Running a sync
Route: POST /admin/panels/field-mapping/sync
Body: same as preview.
The sync service:
- Downloads the XLSForm XLSX from ODK Central via pyODK
- Parses the
surveysheet: readsname,type, and the firstlabel*column - Parses the
choicessheet: readslist_name,name, and the firstlabel*column - Joins choices to fields on
list_nameextracted from type strings likeselect_one <list_name> - For each field in
MasFieldDisplayConfig: updatesodk_labelif changed - For each choice from the XLSForm: upserts into
MasChoiceMappings- New → insert
- Changed label → update
- Unchanged → skip (not counted as updated)
- Absent from XLSForm → left untouched (sync is additive only)
Result table:
| Stat | Description |
|---|---|
fields_processed |
Count of ODK fields matched in MasFieldDisplayConfig |
labels_updated |
Count of odk_label values changed |
choices_added |
New choice rows inserted |
choices_updated |
Existing choice labels changed |
Why sync is additive-only: The WHO VA XLSForm is a universal template covering all
deployment sites. A site-specific ODK project only contains choices for that site.
Deactivating choices absent from one site's form would silently remove choices needed
by other sites. Choices seeded from resource/mapping/mapping_choices.xlsx are the
canonical source; ODK sync augments them.
Label column discovery
XLSForm label columns vary by form: label, label::English (en), label::English,
etc. The sync service picks the first column whose name starts with label.
Field Mapping Service (Runtime)
At runtime, the FieldMappingService (singleton) serves field and choice data to
rendering code. It caches queries per form type.
from app.services.field_mapping_service import get_mapping_service
svc = get_mapping_service()
config = svc.get_field_config(form_type_id, field_id) # MasFieldDisplayConfig | None
choices = svc.get_choices(form_type_id, field_id) # list[MasChoiceMappings]
label = svc.get_choice_label(form_type_id, field_id, value) # str | None
The cache is invalidated after any field edit via svc.clear_cache().
Ordering behavior in get_fieldsitepi(form_type_code):
- categories are ordered by
MasCategoryDisplayConfig.display_order - subcategories within a category are ordered by
MasSubcategoryOrder.display_order - fields within a subcategory are ordered by
MasFieldDisplayConfig.display_order
If a field-bearing subcategory does not yet have a MasSubcategoryOrder row, it still
renders after the explicitly ordered subcategories for that category.
FormTypeService (Admin)
The FormTypeService singleton handles all admin operations.
from app.services.form_type_service import get_form_type_service
svc = get_form_type_service()
# Register a blank form type
ft = svc.register_form_type("MY_FORM", "My Form", description="...")
# List all active form types
types = svc.list_form_types()
# Stats for one form type
stats = svc.get_form_type_stats("WHO_2022_VA")
# {"form_type_code", "form_type_name", "form_count", "category_count", "field_count", "choice_count"}
# Duplicate (copies all categories, subcategories, fields, choices)
new_ft = svc.duplicate_form_type("WHO_2022_VA", "WHO_2022_VA_V2", "WHO 2022 VA v2")
# Export to dict (JSON-serializable)
data = svc.export_form_type("WHO_2022_VA")
# Import from dict
new_ft, stats = svc.import_form_type(data, override_code="NEW_CODE", override_name="New Name")
# Soft-delete (raises ValueError if forms are still linked)
svc.deactivate_form_type("OLD_FORM")
Bootstrap Note
If the admin field-mapping panel shows no registered form types, the default
WHO 2022 form type and mapping set have not been seeded into mas_form_types
and related mas_* mapping tables yet.
The standard bootstrap command is:
docker compose exec minerva_app_service uv run flask seed run
That command:
- registers
WHO_2022_VAif it is missing - runs
Who2022VaMigrator - loads field definitions from
resource/mapping/mapping_labels.xlsx - loads choice mappings from
resource/mapping/mapping_choices.xlsx
Languages being present in the admin UI does not imply form types are present.
The language seed and the field-mapping seed are separate steps within
flask seed run.
Field Display Flags Reference
| Flag | Column | Effect in coding screen |
|---|---|---|
| Flip Color | flip_color |
Inverts the colour coding so "No" appears positive and "Yes" negative |
| Is Info | is_info |
Field is shown for reference only; it is not included in VA coding |
| Summary Include | summary_include |
Field appears in the live Symptoms on VA Interview summary panel |
| Is PII | is_pii |
Field carries personal data — redacted from the data-manager CSV export. No effect on the coding screen (see below) |
What is_pii actually does
One consumer: FieldMappingService.get_pii_field_ids() feeds
data_management_service._filter_export_payload(), which drops those payload
keys from the data-manager CSV export, on top of the hardcoded
CSV_EXPORT_OMIT_PAYLOAD_FIELDS list. It does not mask anything on the
coding screen — the coding screen renders only fields that have a
non-NULL subcategory_code, so identifier questions are excluded by never
being mapped rather than by being flagged.
That distinction is what makes app/services/pii_field_registry.py work. It
records which payload fields carry personal data and flags them, creating a row
with category_code and subcategory_code left NULL for fields that have no
mapping (most identifiers, plus web intake's abha_number / abha_address).
Such a row is visible to export redaction and invisible to the coding screen.
The registry is applied by migration b8e3d1f7a2c4 and re-applied by
flask seed run, because the Excel mapping source has no is_pii column: flags
set by hand in the admin panel do not survive a mapping reseed and are not
reproducible on a fresh install. Applying it never replaces a pii_type an
operator already chose — the registry decides whether a field is PII, not how
it is labelled.
The PII set must be confirmed before anything is exported
Because the registry applies a WHO-keyed field list to every form type, a form
type always ends up with some is_pii rows — including a questionnaire that
has none of those field ids. "Has is_pii rows" therefore says nothing. The set
counts as confirmed only when at least one field the form type owns is
flagged: a row with a subcategory_code (mapped), an odk_label (synced from
ODK), or is_custom = false. FieldMappingService.get_pii_set_status()
derives this on every read; nothing is stored.
While a form type is unconfirmed the system fails closed — a viewer subject to
PII redaction sees no payload on the submission page, and the submissions CSV
export withholds every payload value for that form (column shape unchanged).
The SmartVA input export is exempt — _filter_export_payload is called there
with withhold_unconfirmed=False, so it strips the flagged fields and nothing
more, because it is an admin/data_manager processing feed and withholding
would make SmartVA unusable on a new questionnaire. It logs
pii set unconfirmed | <form_type_code> | smartva input export not withheld
once per form instead.
The field-mapping panel shows a warning on that form type's card,
GET /admin/api/form-types returns pii_set_confirmed: false, and
flask form-types stats --code=<code> prints it. An admin clears it by
flagging one owned field Is PII. Form type creation and activation are not
blocked, since a new form type has no fields until ODK sync runs.
The PII set is also the one mapping cache that is not
clear_cache()-dependent: it re-checks (count(*), max(updated_at)) over the
form type's mas_field_display_config rows on every call, so a flag change
made in another worker (admin edit, Celery-run sync) takes effect without a
restart.
Labels hierarchy (first non-null wins for display):
short_label → full_label → odk_label → field_id
API Reference
| Method | Route | Description |
|---|---|---|
GET |
/admin/panels/field-mapping |
Main panel (HTMX partial) |
GET |
/admin/panels/field-mapping/fields?form_type=<code> |
Field list sub-panel |
GET |
/admin/panels/field-mapping/field/<code>/<field_id> |
Field edit modal |
POST |
/admin/panels/field-mapping/field/<code>/<field_id> |
Save field edits |
GET |
/admin/panels/field-mapping/sync?form_type=<code> |
Sync sub-panel |
POST |
/admin/panels/field-mapping/sync/preview |
Dry-run sync diff |
POST |
/admin/panels/field-mapping/sync |
Run sync |
POST |
/admin/api/form-types |
Create blank form type |
POST |
/admin/api/form-types/<code>/duplicate |
Duplicate form type |
GET |
/admin/api/form-types/<code>/export |
Download JSON bundle |
POST |
/admin/api/form-types/import |
Import from JSON bundle |
All state-changing routes require X-CSRFToken header and admin role.
Related Files
| Path | Purpose |
|---|---|
app/models/va_field_mapping.py |
SQLAlchemy models for all mas_* tables |
app/services/form_type_service.py |
CRUD, duplicate, export, import |
app/services/odk_schema_sync_service.py |
XLSForm parsing and DB upsert |
app/services/field_mapping_service.py |
Runtime rendering cache |
app/routes/admin.py |
All admin routes (~line 1089 onward) |
app/templates/admin/panels/field_mapping.html |
Main panel — cards, import/export modals |
app/templates/admin/panels/field_mapping_fields.html |
Field list sub-panel |
app/templates/admin/panels/field_mapping_field_edit.html |
Field edit modal |
app/templates/admin/panels/field_mapping_field_row.html |
Table row partial (returned on save) |
app/templates/admin/panels/field_mapping_sync.html |
ODK sync sub-panel |
tests/services/test_odk_schema_sync.py |
10 unit tests for sync service |
resource/mapping/mapping_labels.xlsx |
Source field definitions (seed data) |
resource/mapping/mapping_choices.xlsx |
Source choice definitions (seed data) |
Related Documentation
title: Field Mapping System doc_type: current-state status: active owner: engineering