🤖 Written by Claude
Goal
Make it possible to export a VariantGrid analysis-style grid (CSV, with all the configured annotation columns and identical formatting) for an arbitrary list/queryset of variants, without creating an Analysis or AnalysisNode.
Today the only way to produce a fully-formatted variant grid export is through the analysis grid machinery, which is hard-bound to an AnalysisNode. To export "a dozen variants" outside an analysis (e.g. from search results, a variant-tag list, a gene page) you currently have to spin up a throwaway Analysis + AllVariantsNode, restrict its queryset, run the export, then delete it. That works but has real costs: DB writes (Analysis + AnalysisNode + NodeVersion rows per export), a required user, audit-log noise, and fragility (it relies on AllVariantsNode being queryable while unloaded).
We want a clean, node-free path that produces output byte-identical to the existing analysis CSV export for the same set of columns.
Background — why this is feasible
The coupling to Analysis/AnalysisNode is almost entirely incidental. The actual export pipeline has no intrinsic need for a node — VariantGrid just happens to read all its config off node.analysis.
The core pieces are already node-free:
- Columns + overrides:
snpdb/grid_columns/custom_columns.py::get_custom_column_fields_override_and_sample_position(ccc, annotation_version, analysis_tags=...) — pure function of (CustomColumnsCollection, AnnotationVersion).
- Base queryset:
annotation/annotation_version_querysets.py::get_variant_queryset_for_annotation_version(annotation_version) — installs the SQL partition transformer so variantannotation__* joins resolve to the correct partition. (This is not an ordinary FK filter — you MUST start from this queryset or annotation columns will not join correctly.)
- Extra annotations:
snpdb/grid_columns/custom_columns.py::get_variantgrid_extra_annotate(user) — needs a user, not a node (provides tags_global, internally_classified, etc.).
- colmodels / server-side formatters: built by the jqgrid base class from
self.fields + self._overrides — driven by data the grid already holds, not by the node.
- CSV writing:
library/django_utils/jqgrid_view.py::grid_export_csv(colmodels, items) — already node-free.
- Export-only row fixups:
analysis/grid_export.py::format_items_iterator(sample_ids, items, variant_tags_dict) — only needs sample_ids (which is [] when there are no samples), plus each row's tags_global and id.
Current coupling to untangle
In analysis/grids.py:
VariantGrid.__init__(self, user, node, extra_filters=None, af_show_in_percent=None) (line ~52) reads from node.analysis: genome_build, custom_columns_collection, annotation_version, default_sort_by_column, grid_sample_label_template, plus node.get_cohorts_and_sample_visibility(), node.get_extra_columns(), node.get_extra_grid_config(), NodeCount.load_for_node(...).
VariantGrid._get_base_queryset() (line ~83) returns self.node.get_queryset().
VariantGrid._get_fields_and_overrides(node, af_show_in_percent) (line ~154) — already mostly delegates to get_custom_column_fields_override_and_sample_position(...); the node-specific parts are the extra columns and sample/genotype columns.
VariantGrid.get_colmodels(...) (line ~101) injects {"analysisNode": {"visible": node.visible}} into every colmodel — analysis-only.
VariantGrid._get_q() (line ~109) — NodeCount/extra-filters (diff-to-parent) logic — analysis-only.
ExportVariantGrid (line ~306) — paginates contig-at-a-time via node.analysis.genome_build; otherwise generic.
In analysis/grid_export.py:
node_grid_get_export_iterator(request, node, export_type, ...) (line ~23) hardcodes ExportVariantGrid(request.user, node, ...) and pulls node.get_sample_ids() / node.analysis.genome_build.
Classification of each coupling
Incidental (drop / default for the node-free path):
get_colmodels analysisNode injection
_get_q / NodeCount extra-filters
default_sort_by_column / grid_sample_label_template (these originate from UserSettings via the analysis — read them from UserSettings.get_for_user(user) directly)
get_extra_columns() / get_extra_grid_config() (default empty)
node.get_queryset() DAG filtering (replaced by an explicit base queryset / pk__in)
Genuinely context-bound — but to a cohort/sample, NOT an analysis:
- Sample/genotype columns (zygosity, AD/AF/DP/GQ/PL/FT,
CohortGenotype packed-data columns + per-sample visibility), built in VariantGrid.get_grid_genotype_columns_and_overrides(...). This needs a cohort context, which a variant-level export does not have.
Proposed implementation
Scope (first cut)
Variant-level columns only. No sample/genotype columns in this issue — that's the one genuinely context-bound piece and untangling it is out of scope here. Get the node-free path shipped for the clean 90%; sample support can be added later by passing cohorts into the context explicitly.
Approach
-
Introduce a small context object (e.g. VariantGridExportConfig) carrying everything the grid needs, independent of a node:
genome_build: GenomeBuild
annotation_version: AnnotationVersion (the umbrella AnnotationVersion, which has .variant_annotation_version — NOT a bare VariantAnnotationVersion)
custom_columns_collection: CustomColumnsCollection
user: User
base_queryset: QuerySet[Variant] (already built via get_variant_queryset_for_annotation_version, optionally pre-filtered)
af_show_in_percent: bool (default from settings.VARIANT_ALLELE_FREQUENCY_CLIENT_SIDE_PERCENT)
order_by: Optional[list[str]]
- (later) optional
cohorts / visibility for sample columns
-
Refactor a node-free base (e.g. BaseVariantGrid) that depends on the context object instead of node. Move the node-free logic (_get_fields_and_overrides minus extra/sample columns, base queryset, extra annotate kwargs, colmodels) onto it.
-
Make the existing VariantGrid a thin adapter that builds the context from a node and adds the node-specific bits (extra columns, sample/genotype columns, analysisNode colmodel injection, _get_q). The existing analysis export must be unchanged.
-
Provide a node-free entry point, e.g.:
analysis/grid_export.py::variant_list_grid_export(variants_or_qs, annotation_version, ccc, user, export_type='csv') -> tuple[str, Iterator[str]]
mirroring node_grid_get_export_iterator but driven by the context object. user should default to library.guardian_utils.admin_bot() (note: tag/classification columns are permission-filtered per user).
-
Parameterize node_grid_get_export_iterator / format_items_iterator off the context where they currently reach for node (e.g. sample_ids becomes [] for the variant-list case).
Reference prototype
export_analysis.py - issue comment - already contains a working fake-analysis implementation (get_annotated_variants_csv, get_annotated_variant_rows) that produces correctly-formatted output by creating a throwaway Analysis + AllVariantsNode. Use it as the behavioural reference / oracle for what correct output looks like — the new node-free path should produce identical output. It is NOT the desired end state (the whole point is to remove the fake analysis); treat it as a spec, then delete it once the real path lands.
Acceptance criteria
- A public function exists that takes a list (or queryset) of
Variant, an AnnotationVersion, a CustomColumnsCollection, and a user, and returns formatted CSV lines (header first) — with no Analysis/AnalysisNode/NodeVersion rows created or deleted, and no FakeRequest/grid-handler request needed.
- For a shared variant-level column set, the output is byte-identical to the existing analysis grid CSV export over the same variants (header row + data rows + server-side formatting + the
-1 → "." and tag-summarising fixups from format_items_iterator).
- The existing analysis grid export (the node path) is unchanged — verified by existing tests still passing.
- A new test asserts the node-free export matches the analysis export byte-for-byte on a shared column set, using
annotation/tests/test_data_fake_genes.py / analysis/tests/utils.py::AnalysisSetupMixin style fixtures. This test is required — the two paths will silently drift without it.
Out of scope (note as follow-ups)
- Sample/genotype columns in the node-free path (needs cohort context passed explicitly).
- VCF export via the node-free path (CSV first; VCF can follow the same context pattern afterward).
Key files
analysis/grids.py — VariantGrid, ExportVariantGrid (the refactor target)
analysis/grid_export.py — node_grid_get_export_iterator, format_items_iterator, grid_export_csv usage
snpdb/grids.py — AbstractVariantGrid (base get_queryset/field-name logic, ~line 499)
snpdb/grid_columns/custom_columns.py — get_custom_column_fields_override_and_sample_position, get_variantgrid_extra_annotate
annotation/annotation_version_querysets.py — get_variant_queryset_for_annotation_version (partition transformer)
library/django_utils/jqgrid_view.py — grid_export_csv
library/jqgrid/jqgrid.py — base get_items / iter_format_items / get_colmodels
🤖 Written by Claude
Goal
Make it possible to export a VariantGrid analysis-style grid (CSV, with all the configured annotation columns and identical formatting) for an arbitrary list/queryset of variants, without creating an
AnalysisorAnalysisNode.Today the only way to produce a fully-formatted variant grid export is through the analysis grid machinery, which is hard-bound to an
AnalysisNode. To export "a dozen variants" outside an analysis (e.g. from search results, a variant-tag list, a gene page) you currently have to spin up a throwawayAnalysis+AllVariantsNode, restrict its queryset, run the export, then delete it. That works but has real costs: DB writes (Analysis+AnalysisNode+NodeVersionrows per export), a required user, audit-log noise, and fragility (it relies onAllVariantsNodebeing queryable while unloaded).We want a clean, node-free path that produces output byte-identical to the existing analysis CSV export for the same set of columns.
Background — why this is feasible
The coupling to
Analysis/AnalysisNodeis almost entirely incidental. The actual export pipeline has no intrinsic need for a node —VariantGridjust happens to read all its config offnode.analysis.The core pieces are already node-free:
snpdb/grid_columns/custom_columns.py::get_custom_column_fields_override_and_sample_position(ccc, annotation_version, analysis_tags=...)— pure function of(CustomColumnsCollection, AnnotationVersion).annotation/annotation_version_querysets.py::get_variant_queryset_for_annotation_version(annotation_version)— installs the SQL partition transformer sovariantannotation__*joins resolve to the correct partition. (This is not an ordinary FK filter — you MUST start from this queryset or annotation columns will not join correctly.)snpdb/grid_columns/custom_columns.py::get_variantgrid_extra_annotate(user)— needs auser, not a node (providestags_global,internally_classified, etc.).self.fields+self._overrides— driven by data the grid already holds, not by the node.library/django_utils/jqgrid_view.py::grid_export_csv(colmodels, items)— already node-free.analysis/grid_export.py::format_items_iterator(sample_ids, items, variant_tags_dict)— only needssample_ids(which is[]when there are no samples), plus each row'stags_globalandid.Current coupling to untangle
In
analysis/grids.py:VariantGrid.__init__(self, user, node, extra_filters=None, af_show_in_percent=None)(line ~52) reads fromnode.analysis:genome_build,custom_columns_collection,annotation_version,default_sort_by_column,grid_sample_label_template, plusnode.get_cohorts_and_sample_visibility(),node.get_extra_columns(),node.get_extra_grid_config(),NodeCount.load_for_node(...).VariantGrid._get_base_queryset()(line ~83) returnsself.node.get_queryset().VariantGrid._get_fields_and_overrides(node, af_show_in_percent)(line ~154) — already mostly delegates toget_custom_column_fields_override_and_sample_position(...); the node-specific parts are the extra columns and sample/genotype columns.VariantGrid.get_colmodels(...)(line ~101) injects{"analysisNode": {"visible": node.visible}}into every colmodel — analysis-only.VariantGrid._get_q()(line ~109) —NodeCount/extra-filters (diff-to-parent) logic — analysis-only.ExportVariantGrid(line ~306) — paginates contig-at-a-time vianode.analysis.genome_build; otherwise generic.In
analysis/grid_export.py:node_grid_get_export_iterator(request, node, export_type, ...)(line ~23) hardcodesExportVariantGrid(request.user, node, ...)and pullsnode.get_sample_ids()/node.analysis.genome_build.Classification of each coupling
Incidental (drop / default for the node-free path):
get_colmodelsanalysisNodeinjection_get_q/NodeCountextra-filtersdefault_sort_by_column/grid_sample_label_template(these originate fromUserSettingsvia the analysis — read them fromUserSettings.get_for_user(user)directly)get_extra_columns()/get_extra_grid_config()(default empty)node.get_queryset()DAG filtering (replaced by an explicit base queryset /pk__in)Genuinely context-bound — but to a cohort/sample, NOT an analysis:
CohortGenotypepacked-data columns + per-sample visibility), built inVariantGrid.get_grid_genotype_columns_and_overrides(...). This needs a cohort context, which a variant-level export does not have.Proposed implementation
Scope (first cut)
Variant-level columns only. No sample/genotype columns in this issue — that's the one genuinely context-bound piece and untangling it is out of scope here. Get the node-free path shipped for the clean 90%; sample support can be added later by passing cohorts into the context explicitly.
Approach
Introduce a small context object (e.g.
VariantGridExportConfig) carrying everything the grid needs, independent of a node:genome_build: GenomeBuildannotation_version: AnnotationVersion(the umbrellaAnnotationVersion, which has.variant_annotation_version— NOT a bareVariantAnnotationVersion)custom_columns_collection: CustomColumnsCollectionuser: Userbase_queryset: QuerySet[Variant](already built viaget_variant_queryset_for_annotation_version, optionally pre-filtered)af_show_in_percent: bool(default fromsettings.VARIANT_ALLELE_FREQUENCY_CLIENT_SIDE_PERCENT)order_by: Optional[list[str]]cohorts/visibilityfor sample columnsRefactor a node-free base (e.g.
BaseVariantGrid) that depends on the context object instead ofnode. Move the node-free logic (_get_fields_and_overridesminus extra/sample columns, base queryset, extra annotate kwargs, colmodels) onto it.Make the existing
VariantGrida thin adapter that builds the context from a node and adds the node-specific bits (extra columns, sample/genotype columns,analysisNodecolmodel injection,_get_q). The existing analysis export must be unchanged.Provide a node-free entry point, e.g.:
analysis/grid_export.py::variant_list_grid_export(variants_or_qs, annotation_version, ccc, user, export_type='csv') -> tuple[str, Iterator[str]]mirroring
node_grid_get_export_iteratorbut driven by the context object.usershould default tolibrary.guardian_utils.admin_bot()(note: tag/classification columns are permission-filtered per user).Parameterize
node_grid_get_export_iterator/format_items_iteratoroff the context where they currently reach fornode(e.g.sample_idsbecomes[]for the variant-list case).Reference prototype
export_analysis.py- issue comment - already contains a working fake-analysis implementation (get_annotated_variants_csv,get_annotated_variant_rows) that produces correctly-formatted output by creating a throwawayAnalysis+AllVariantsNode. Use it as the behavioural reference / oracle for what correct output looks like — the new node-free path should produce identical output. It is NOT the desired end state (the whole point is to remove the fake analysis); treat it as a spec, then delete it once the real path lands.Acceptance criteria
Variant, anAnnotationVersion, aCustomColumnsCollection, and a user, and returns formatted CSV lines (header first) — with noAnalysis/AnalysisNode/NodeVersionrows created or deleted, and noFakeRequest/grid-handler request needed.-1 → "."and tag-summarising fixups fromformat_items_iterator).annotation/tests/test_data_fake_genes.py/analysis/tests/utils.py::AnalysisSetupMixinstyle fixtures. This test is required — the two paths will silently drift without it.Out of scope (note as follow-ups)
Key files
analysis/grids.py—VariantGrid,ExportVariantGrid(the refactor target)analysis/grid_export.py—node_grid_get_export_iterator,format_items_iterator,grid_export_csvusagesnpdb/grids.py—AbstractVariantGrid(baseget_queryset/field-name logic, ~line 499)snpdb/grid_columns/custom_columns.py—get_custom_column_fields_override_and_sample_position,get_variantgrid_extra_annotateannotation/annotation_version_querysets.py—get_variant_queryset_for_annotation_version(partition transformer)library/django_utils/jqgrid_view.py—grid_export_csvlibrary/jqgrid/jqgrid.py— baseget_items/iter_format_items/get_colmodels