Skip to content

Sample-bound filter nodes can apply to a patient #1855 - #1857

Merged
davmlaw merged 3 commits into
masterfrom
1855_filter_node_patient
Sep 10, 2026
Merged

Sample-bound filter nodes can apply to a patient #1855#1857
davmlaw merged 3 commits into
masterfrom
1855_filter_node_patient

Conversation

@davmlaw

@davmlaw davmlaw commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Issue #1855. Plan: claude/plans/1855_filter_node_patient_scope_plan.md.

Why

Zygosity, Allele Frequency, Mode of Inheritance and Gene List (sample QC panel) hold one sample FK, auto-set from the ancestors' proband. Under a group level SampleNode with two callers on the one extraction (TSO 500 small variants + CNV) the proband sample is ambiguous, so the node stayed unset. Picking one caller by hand was worse: the filter is keyed on that caller's genotype column, which is NULL on every other caller's row, so the node silently dropped them.

What

Each of the four nodes applies to either one sample or one patient. In patient mode the filter applies to every ancestor sample of that patient, ORed as one pk__in subquery per sample (the same shape the group SampleNode produces, and for the same reason: an OR across two genotype aliases has nowhere to hang in annotate_and_filter_queryset). The proband patient auto-sets it where the proband sample is ambiguous. Single-sample analyses keep setting the sample and produce the query they always did.

  • patient FK on the four nodes (migration 0144). Exactly one of sample / patient is set.
  • AncestorSampleMixin owns the choice (_set_sample / _set_patient, get_filter_samples, get_filter_patient, _get_filter_samples_arg_q_dict). SampleNode's per-sample subquery builder moves to cohort_mixin.get_sample_pk_in_q.
  • A sample with no GT (Zygosity, MOI) or no AF column (Allele Frequency) passes through unfiltered, as SampleNode already does; the method summary lists which samples were filtered and which passed through.
  • Gene List's sample QC panel unions the patient's samples' active gene lists, resolved when asked rather than stored.
  • One "Applies to" picker in the four editors (sample:<pk> / patient:<pk>), mapping a template AnalysisVariable to whichever FK is set.
  • The MOI editor's "From Patient" panel now receives every picker choice's gene/disease data at render. This replaces the sample_patient_gene_disease AJAX URL, whose view required an argument the URL never passed.
  • Deleting a sample bumps patient mode nodes of that patient.

Tests

FilterNodePatientScopeTest (16 tests) in analysis/tests/test_sample_node_levels.py on the TSO 500 patient fixture. Full suite: 3230 tests OK.

A tag placed under a patient / specimen / extraction level node had no proband
sample - two DNA callers on the one extraction resolve, so SampleNode gives up -
and the tagging was then about nobody. It now records the patient the same way it
records the sample: set at tag time, part of the tagging's identity, backfilled.

- VariantTag.patient, with the unique constraint becoming
  varianttag_one_per_person_in_analysis (variant, tag, analysis, user, sample,
  patient), so two patient level nodes in one analysis get a tagging each
- AnalysisNode.get_proband() resolves sample and patient in one ancestor walk
  (NodeProband); get_proband_sample / get_proband_patient are wrappers and
  get_proband_by_node_id answers for a whole graph in one load
- one_off_backfill_variant_tag_patient, registered as a ManualOperation
- the classify form offers only that patient's samples: a restricted queryset
  only validates the POST, so the patient is forwarded to SampleAutocompleteView
- Classify & Report lists a patient tagging on the patient's tab and on each of
  their sample tabs, and another patient's classification no longer resolves it
- the grid pill's person marker says who the tagging is for
Zygosity, Allele Frequency, Mode of Inheritance and Gene List (sample QC
panel) hung their filter off one sample. Under a group level SampleNode with
two callers on the one extraction the proband sample is ambiguous, so the node
stayed unset - and picking one caller keyed the filter on that caller's
genotype column, which is NULL on every other caller's row.

Each of the four now applies to either one sample or one patient. In patient
mode the filter applies to every ancestor sample of that patient, ORed as one
pk__in subquery per sample (the group SampleNode's shape - an OR across two
genotype aliases has nowhere to hang in annotate_and_filter_queryset). The
proband patient auto-sets it where the proband sample is ambiguous; single
sample analyses keep setting the sample and their query is unchanged.

- patient FK on the four nodes (migration 0144); one of sample/patient is set
- AncestorSampleMixin owns the choice: _set_sample/_set_patient,
  get_filter_samples, get_filter_patient, _get_filter_samples_arg_q_dict;
  SampleNode's per sample subquery moves to cohort_mixin.get_sample_pk_in_q
- a sample with no GT (Zygosity, MOI) or no AF column (Allele Frequency)
  passes through unfiltered, as SampleNode already does, and the method
  summary says so
- Gene List's sample QC panel unions the patient's samples' active lists,
  resolved when asked rather than stored
- one "Applies to" picker in the four editors ("sample:<pk>" / "patient:<pk>"),
  mapping a template AnalysisVariable to whichever FK is set; the MOI editor's
  "From Patient" panel gets every choice's gene/disease data at render, in
  place of the sample_patient_gene_disease AJAX URL whose view took an
  argument the URL never passed
- sample deletion bumps patient mode nodes of that patient
@davmlaw

davmlaw commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

🤖 Written by Claude

Open question from #1857: a bare node.sample read is a foot gun.

Zygosity, Allele Frequency, MOI and Gene List nodes now apply to either one sample or one patient (exactly one FK set). In patient mode sample is None, so code that reads it directly doesn't crash - it reads as "unset" and the filter silently doesn't apply. Everything on the branch goes through AncestorSampleMixin.get_filter_samples() / get_filter_patient(), so it's a risk for the next feature, not today's code.

Proposal, to decide later rather than block the PR: rename the two FKs to _sample / _patient, keeping db_column so the migration is metadata only.

  • The underscore says "storage, not for you"; pylint flags node._sample outside the class as protected access, and Django templates refuse underscore lookups, so a new bare read is caught by lint or at render rather than by a reviewer.
  • Django allows a leading underscore (it only rejects trailing _, __ and pk).
  • Cost: eight RenameFields with no SQL, a data step rewriting AnalysisVariable.field for existing template variables on these four node types, the delete-signal lookups (zygositynode___sample - readable enough if each subclass is filtered directly) and a pylint disable on the few form/mixin lines that legitimately touch the fields.

The cheaper alternative is a docstring note above the fields plus is_set on the mixin so nothing outside it has a reason to read them; that removes the temptation but doesn't enforce anything.

The form mixin named the UI label rather than what it does: work out the samples a
filter node reads, directly or via their patient. AppliesToMixin ->
AncestorSampleSourceMixin, applies_to -> sample_source (label unchanged), so the
form shares vocabulary with AncestorSampleMixin on the model.

Its choices now come from get_ancestor_samples() - the set
_get_configuration_errors validates a picked sample against - rather than
get_samples(), which included the node's own. MOINodeView's per-choice
gene/disease data follows the same set, so it can't offer a choice it has no
entry for.
@davmlaw
davmlaw merged commit 7f066b5 into master Sep 10, 2026
3 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant