Submission Analytics
Submission Analytics Materialized Views
Purpose
DigitVA now provides three focused PostgreSQL materialized views:
va_submission_analytics_core_mvva_submission_analytics_demographics_mvva_submission_cod_detail_mv
It also now provides one export/reporting snapshot MV:
va_submission_cod_snapshot_mv
Together they support analytics, trend charts, and reporting queries without running repeated live joins across operational workflow and coding tables.
The materialized-view layer is:
- one row per
va_sid - additive to the current operational schema
- read-only from application code
- refreshed asynchronously
The COD snapshot MV is also:
- one row per
va_sid - active/current-state only
- intended for export/reporting rather than KPI counting
Source Tables
The materialized view reads from:
va_submissionsva_formsva_submission_workflowva_initial_assessmentsva_final_assessmentsva_reviewer_final_assessmentsva_final_cod_authorityva_smartva_results
The COD snapshot MV additionally reads from active coding/reporting artifacts, including:
va_narrative_assessmentsva_social_autopsy_analysesva_social_autopsy_analysis_optionsva_allocationsmap_icd_cod_bucketsmap_icd10_legacy_reporting_aliasesmas_cod_bucket_nodesmas_cod_bucket_schemesva_submission_payload_versions
Included Dimensions
Project attribution
project_id in every analytics MV is the form's own va_forms.project_id.
A form is keyed to exactly one (project_id, site_id) pair, while a site may
belong to several active projects (va_project_sites is unique on the pair,
not on site_id), so a submission is never attributed to "the site's
project". Whether a submission is visible is decided by the scope filters,
which compare (project_id, site_id) against currently active
va_project_sites pairs (submission_analytics_mv._mv_scope_filter).
Submissions of a form whose pair has been deactivated therefore drop out of
scoped analytics rather than being re-attributed to another project. A unit
grant (data manager or In-charge) is held to the same rule: a submission
routed into its subtree counts only while its form's pair is active
(authz.active_pair), as on the data-management grid. The DM KPI cards
(DmScope.sql) and the COD bucket report apply it too.
Across the three views, DigitVA stores:
- project, site, and form identifiers
- submission timestamps and day/week/month buckets
- workflow state
- ODK review state
- ODK sync issue state
- normalized demographic fields
- human coding outputs
- SmartVA outputs
Core MV — ODK sync state
va_submission_analytics_core_mv carries three sync columns:
odk_sync_issue_code— the rawva_submissions.va_sync_issue_codehas_sync_issue— true when any sync issue code is setodk_missing— true when the submission is retired from ODK, i.e. its code ismissing_in_odk
odk_missing is never NULL: it is built with IS NOT DISTINCT FROM, so a
submission with no sync issue is false and odk_missing = false keeps it.
Retired rows stay in the MV. Every consumer — the data-manager KPIs and list,
the analytics endpoints, COD bucket reporting and the exports — filters
odk_missing = false by default, and only the explicit "Missing in ODK"
count and the odk_sync filter values missing_in_odk / all include them.
See ODK Retired Submissions Policy.
Demographics MV
va_submission_analytics_demographics_mv includes:
va_sidva_narration_languagesexanalytics_age_normalized_daysanalytics_age_bandhas_smartvahas_human_initial_codhas_human_final_cod
It derives analytics age from sync-time normalized fields already stored on
va_submissions:
va_deceased_age_normalized_daysva_deceased_age_normalized_yearsva_deceased_age_source
The derivation rules follow the policy in WHO 2022 Age Derivation Policy.
Important current behavior:
analytics_age_normalized_daysis sourced directly fromva_submissions.va_deceased_age_normalized_days- the winning WHO age source is stored on
va_submissions.va_deceased_age_source - child and adult ages are normalized using source precedence, not additive combination
- raw
age_groupfrom the XLSForm is not treated as the final analytics age band
Human COD And SmartVA
The view includes:
- latest active initial COD fields
- authoritative final human COD
- parsed human ICD prefixes from stored COD strings
- active SmartVA outputs and ICD codes
Final human COD resolution follows the current authority model:
- reviewer final COD pointed to by
va_final_cod_authoritywhen present - otherwise coder final COD pointed to by
va_final_cod_authority - fallback to latest active reviewer final COD, then latest active coder final assessment, if no authority row is present
Refresh Model
The materialized view is refreshed by Celery.
Current refresh behavior:
- hourly Celery Beat refresh task
- task name:
app.tasks.sync_tasks.refresh_submission_analytics_mv_task - tracked in
va_sync_runswithtriggered_by = "analytics_mv"
The refresh helpers are implemented in submission_analytics_mv.py.
Every refresh runs with SET LOCAL jit = off in its own transaction: JIT
compilation costs more than it saves on these one-shot refreshes, and
SET LOCAL ends with the transaction, so no pooled connection keeps it off.
Indexes
The materialized views have indexes for common analytics filters, including:
va_sidsubmission_date(project_id, site_id)workflow_stateodk_review_stateanalytics_age_bandsexfinal_icdsmartva_cause1_icd
Current Intended Consumers
The materialized view is intended for:
- reporting endpoints
- dashboard analytics APIs
- future project/site trend visualizations
- SmartVA versus human-COD comparison analysis
Area dashboard
GET /area/ (page) and GET /api/v1/area/projects,
/api/v1/area/summary?project=&unit=&site=&period=&format= and
/api/v1/area/staff (JSON) show deaths, interviews and coding for the part
of a project a user holds a grant for (docs/policy/area-dashboard.md,
beads digitva-stc, digitva-qfh8). Code:
app/services/area_dashboard_service.py, app/routes/area.py,
app/routes/api/area.py.
- Gated by
login_required: any active grant of any role opens an area; no grant gives an empty project list. The navbar link shows for any user with an active grant (VaUsers.has_any_active_grant). Expo web opens the same page ("Statistics", Mentoring screen). - Scope is viewing scope, a union over all roles, per project. A project with
an active organization level is a tree project and uses
authz.reachable_unit_ids(whole tree for admin, the project's PI, or any project- or site-scoped grant; otherwise unit-grant subtrees), shared with the organization API's unit picker. A project with no tree is shown per site: all active sites for admin, PI or a project grant, else the granted sites. A project, unit or site outside scope is a 404. periodis30d,90d,12morall(default); anything else is a 400.period_startis UTC midnight that many days back, so it equals the data manager'sdate_from. It filters deaths reported (casecreated_at), submitted (submission_at) and causes (date of death); nothing else.- Rows are the grant roots (or the project's top-level units when the scope
is project-wide, plus an "Unrouted" row), or the direct children of a
selected unit. Each row is its whole subtree, counted once. Every row and
the card carry
COUNT_KEYS, from four grouped queries per level:- snapshot (
get_dm_org_unit_stats_from_mv(include_breakdown=True, since=...), sitesget_area_site_stats_from_mv, unroutedget_area_unrouted_stats_from_mv):submittedin the period and theawaiting_codingandnot_codeablecoding buckets; - case counts over
va_death_register(_case_count_columns, duplicates and cancelled cases out):deaths_reportedin the period,deaths_7_days,deaths_30_days,interviews_pending(status inuser_home_service.OPEN_CASE_STATES) andpending_over_7_days/_30_daysbycreated_at; - live counts over one
_live_flagssubquery ofva_submissions(retired, confirmed-duplicate and inactive-pair submissions out):final_cod(an authority row naming a coder or reviewer assessment),smartva_failed(activeva_smartva_resultsrow with outcomefailed, viaix_va_smartva_results_sid_status_covering) andvisit_notes. Unit rows join on ltree<@; there is no query per unit.
- snapshot (
- Card (
card, every level): the selected unit's own subtree row (the unit rides along in its children's grouped queries), else the sum of the non-overlapping rows. Plusmedians(one query over the core MV joined tova_death_registeronva_sid, indexix_va_death_register_va_sid, andva_final_cod_authority;percentile_cont, all time) andcauses(cod_bucket_mapping_service.count_coded_submissions_by_causeon the default reporting scheme, thensuppress_causes: hidden under 20 coded, causes under 5 and past the top ten folded into "Other (suppressed)"). project_card(project root, project-wide scope only):stationsfromproject_stations, active devices with a station config, their latest bundle (va_bundle_ingests), never-synced count. One query.format=csv:summary_csv, long format (section, area code, area name, measure, value), aggregates only; text cells starting= + - @get a leading'.- Freshness: the snapshot label is the
finished_atof the latest successfulanalytics_mvrun inva_sync_runs. Ad-hoc refreshes from the data manager and analytics screens are not recorded there, so the label is a lower bound. Both labels are formatted in the user's timezone. - Links (
linkson the card, sites-mode rows and unit rows' visit notes, count key -> URL):link_screensoffers a screen only when the user holds that role on the project and passes the target route's own role gate. Data manager, collaborator or collaborator_pii or admin:/data-management/with every URL filter it reads, blank unless set (project;site,org_unitordate_fromwhere the count is exactly that filter; no data-manager link at a root of several grant roots). Coder:/coding/on awaiting coding. Interviewer (without data manager):/intake/on submitted. - Staff view (
GET /api/v1/area/staff?project=&unit=&site=,area_dashboard_service.area_staff, beaddigitva-stc.2): interviewers only since 2026-10-22 (the per-coder table was cut). Area as the summary's (_in_area); a root that is not project-wide, and anything outside scope, is a 404. One query overva_death_register(registered, started), one overva_web_intake_draftsby owner (submitted, in progress), one overmap_case_contact_attemptsin the last 30 days. Confirmed duplicates excluded. At most 500 rows (truncated). Whenshould_redact_piihides staff identity the queries do not run and the response isstaff_identity_redacted: truewith no rows.
Not all existing dashboard endpoints have been migrated to use the view yet. Some current operational dashboard queries still read directly from live tables. The analytics MV itself is now reviewer-authority-aware; remaining legacy reporting cleanup is outside the view.
COD Snapshot MV
va_submission_cod_snapshot_mv centralizes the active COD-facing reporting
state for a submission. It includes:
- latest coder Step 1 data preserved across final COD submission
- latest active coder final COD data
- latest active reviewer final COD data
- authoritative final COD data
- active SmartVA causes and ICDs
WHO_2022_VA_2026bucket mapping for coder, reviewer, authoritative, and SmartVA CODs (migrationd1a6e3b7c2f4; before it,WHO_2022_VA, ICD-10 only)- each value is bucketed through the rows of its own classification, which
its code shape decides: ICD-10 values through the
icd10rows, ICD-11 values through theicd11rows (exact code, then the nearest ancestor on themas_icd11_mmsparent chain; a post-coordinated value by its first stem). SmartVA ICDs are ICD-10. There is no crosswalk - each bucket has a
*_who_bucket_provenancecolumn:icd10,icd11_native,unmapped(a value with no bucket), or NULL (no value) - ICD-10 lookup is alias-aware for historical ICDs through
map_icd10_legacy_reporting_aliases - the MV keeps the raw ICD fields unchanged and uses the alias only for
reporting-bucket assignment.
*_icdcolumns stay ICD-10 only; an ICD-11 code is in the*_cod_textcolumn build_submission_cod_snapshot_mv_sql()still returns the old SQL by default, for the historical migrations that call it;icd11_buckets=Truereturns the current SQL- the current SQL reads the 34 payload fields (
unique_id,survey_block, the narrative keys, thesa*answers) from onejsonb_to_recorddecode in a lateral join (migrationb7e2d4f6a8c1). A->>per field re-detoasts the whole TOASTed payload each time, which dominated the refresh; the rows are identical
- each value is bucketed through the rows of its own classification, which
its code shape decides: ICD-10 values through the
- active NQA projection, including individual NQA question columns
- active Social Autopsy projection, including analysis summary fields and raw
active-payload
sa*questionnaire fields - active coder/reviewer assignment names where available
The snapshot MV is intended for the coded COD export surface and similar submission-level reporting workloads. It is not a history store.
Verification
The materialized-view behavior is covered by focused tests in:
The tested cases include:
- same-day neonatal deaths with hour-level age
- child normalization from
ageInDays - authoritative final human COD selection
- reviewer final COD precedence over coder final COD in analytics output
- ICD parsing for human and SmartVA outputs