diff --git a/CHANGELOG.md b/CHANGELOG.md index bee6bfd345..9df5f60622 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ About changelog [here](https://keepachangelog.com/en/1.0.0/) - Rework rank model file loading, allowing full URI (#6090) - Refactored `scout delete` commands and relative tests into separate modules (#6102) - The gens extension now generates version appropriate links (#6105) -- Refactored snv variants code to share same routines as other variant categories (#6116) +- Refactored snv and cancer snv variants code to share same routines as other variant categories (#6116 and #6128) - On managed variants page, print maintainers names instead of emails/ids (#6121) - Enable ClinVar test API URL by default on Scout demo config settings (#6129) - ClinVar settings (API and list of users allowed to send API submissions) are no longer shown only to admin users (#6134) diff --git a/scout/server/blueprints/variants/controllers.py b/scout/server/blueprints/variants/controllers.py index 278e78e1e8..ddb88825d9 100644 --- a/scout/server/blueprints/variants/controllers.py +++ b/scout/server/blueprints/variants/controllers.py @@ -61,6 +61,7 @@ from .forms import ( FILTERSFORMCLASS, + CancerFiltersForm, CancerSvFiltersForm, FiltersForm, FusionFiltersForm, @@ -72,12 +73,14 @@ SV_VARIANT_PAGE = "variant.sv_variant" VARIANT_PAGE = "variant.variant" +CANCER_VARIANT_PAGE = "variant.cancer_variant" DISMISS_VARIANT_LINK = { "sv": SV_VARIANT_PAGE, "cancer_sv": SV_VARIANT_PAGE, "mei": SV_VARIANT_PAGE, "str": VARIANT_PAGE, "snv": VARIANT_PAGE, + "cancer": CANCER_VARIANT_PAGE, } LOG = logging.getLogger(__name__) @@ -301,14 +304,16 @@ def render_variants_page( return data_exporter(store, case_obj, variants_query) args = [store, institute_obj, case_obj, variants_query, page] - if category in ["snv", "snv_research"]: + if category == "snv": args.append(request.form) + elif category == "cancer": + args.append(form) data = decorator(*args) dismiss_variant_options = ( {**DISMISS_VARIANT_OPTIONS, **CANCER_SPECIFIC_VARIANT_DISMISS_OPTIONS} - if category == "cancer_sv" + if category in ["cancer_sv", "cancer"] else DISMISS_VARIANT_OPTIONS ) @@ -1499,23 +1504,31 @@ def get_variant_info(genes): return data -def cancer_variants(store, institute_id, case_name, variants_query, form, page=1): +def cancer_variants( + store: MongoAdapter, + institute_obj: dict, + case_obj: dict, + variants_query: dict, + page: int, + form: CancerFiltersForm, +): """Fetch data related to cancer variants for a case. For each variant, if one or more gene panels are selected, assign the gene present in the panel as the second representative gene. If no gene panel is selected don't assign such a gene. """ - institute_obj, case_obj = institute_and_case(store, institute_id, case_name) case_dismissed_vars = store.case_dismissed_variants(institute_obj, case_obj) per_page = 50 skip_count = per_page * max(page - 1, 0) variant_res = variants_query.skip(skip_count).limit(per_page) - gene_panel_lookup = store.gene_to_panels(case_obj) # build variant object + gene_panel_lookup = store.gene_to_panels(case_obj) variants_list = [] case_affected_inds: list[str] = store._find_affected(case_obj) + selected_panels = set(form.gene_panels.data) + for variant in variant_res: variant_obj = parse_variant( store, @@ -1525,46 +1538,43 @@ def cancer_variants(store, institute_id, case_name, variants_query, form, page=1 update=True, case_dismissed_vars=case_dismissed_vars, ) - secondary_gene = None - if ( - "first_rep_gene" in variant_obj - and variant_obj["first_rep_gene"] is not None - and variant_obj["first_rep_gene"].get("hgnc_id") not in gene_panel_lookup - ): - for gene in variant_obj["genes"]: - in_panels = set(gene_panel_lookup.get(gene["hgnc_id"], [])) + secondary_gene = None + first_gene = variant_obj.get("first_rep_gene") + + if first_gene and first_gene.get("hgnc_id") not in gene_panel_lookup: + secondary_gene = next( + ( + gene + for gene in variant_obj["genes"] + if set(gene_panel_lookup.get(gene["hgnc_id"], [])) & selected_panels + ), + None, + ) - if len(in_panels & set(form.gene_panels.data)) > 0: - secondary_gene = gene variant_obj["second_rep_gene"] = secondary_gene variant_obj["clinical_assessments"] = get_manual_assessments(variant_obj) - set_overlapping_variants(variant_obj=variant_obj, limit_samples=case_affected_inds) + set_overlapping_variants( + variant_obj=variant_obj, + limit_samples=case_affected_inds, + ) evaluations = [] # Get previous ClinGen-CGC-VIGG evaluations of the variant from other cases for evaluation_obj in store.get_ccv_evaluations(variant_obj): - if evaluation_obj["case_id"] == case_obj["_id"]: - continue - - ccv_classification = evaluation_obj["ccv_classification"] + if evaluation_obj["case_id"] != case_obj["_id"]: + ccv_classification = evaluation_obj["ccv_classification"] + evaluation_obj["ccv_classification"] = CCV_COMPLETE_MAP.get(ccv_classification) + evaluations.append(evaluation_obj) - evaluation_obj["ccv_classification"] = CCV_COMPLETE_MAP.get(ccv_classification) - evaluations.append(evaluation_obj) variant_obj["ccv_evaluations"] = evaluations - variants_list.append(variant_obj) data = dict( - page=page, - institute=institute_obj, - case=case_obj, variants=variants_list, - manual_rank_options=MANUAL_RANK_OPTIONS, cancer_tier_options=CANCER_TIER_OPTIONS, escat_tier_options=ESCAT_TIER_OPTIONS, - form=form, ) return data @@ -1852,7 +1862,7 @@ def _populate_form_genes_from_file( form.hgnc_symbols.data = hgnc_symbols_set -def populate_snv_sv_mei_str_filters_form( +def populate_variants_filters_form( store: MongoAdapter, institute_obj: dict, case_obj: dict, category: str, request_obj: LocalProxy ) -> Union[StrFiltersForm, SvFiltersForm, CancerSvFiltersForm, MeiFiltersForm]: """Populate a filters form for SVs, cancer SVs, MEIs and STRs pages.""" diff --git a/scout/server/blueprints/variants/views.py b/scout/server/blueprints/variants/views.py index 846a4d7fd5..4feb837ff9 100644 --- a/scout/server/blueprints/variants/views.py +++ b/scout/server/blueprints/variants/views.py @@ -8,7 +8,6 @@ from markupsafe import Markup from scout.constants import ( - CANCER_SPECIFIC_VARIANT_DISMISS_OPTIONS, CANCER_TIER_OPTIONS, DISMISS_VARIANT_OPTIONS, ESCAT_TIER_OPTIONS, @@ -25,7 +24,6 @@ from . import controllers from .forms import ( - CancerFiltersForm, FiltersForm, FusionFiltersForm, SvFiltersForm, @@ -52,20 +50,23 @@ def reset_dismissed(institute_id, case_name): return safe_redirect_back(request) +def form_builder(store, inst, case, cat, vtype): + """Builds the variants filters form according to variant category.""" + return controllers.populate_variants_filters_form( + store=store, institute_obj=inst, case_obj=case, category=cat, request_obj=request + ) + + +def data_exporter(store, case, variants_query): + """Calls the variants exporter.""" + return controllers.download_variants(store, case, variants_query) + + @variants_bp.route("///variants", methods=["GET", "POST"]) @templated("variants/variants.html") def variants(institute_id, case_name): """Display a list of SNV variants.""" - def form_builder(store, inst, case, cat, vtype): - """Builds the SNVs filters form.""" - return controllers.populate_snv_sv_mei_str_filters_form( - store=store, institute_obj=inst, case_obj=case, category=cat, request_obj=request - ) - - def data_exporter(store, case, variants_query): - return controllers.download_variants(store, case, variants_query) - def decorator(store, institute, case, variants_query, page, query_form): return controllers.variants( store=store, @@ -91,15 +92,6 @@ def decorator(store, institute, case, variants_query, page, query_form): def str_variants(institute_id: str, case_name: str): """Display a list of STR variants (STRs).""" - def form_builder(store, inst, case, cat, vtype): - """Builds the STRs filters form.""" - return controllers.populate_snv_sv_mei_str_filters_form( - store=store, institute_obj=inst, case_obj=case, category=cat, request_obj=request - ) - - def data_exporter(_, case, variants_query): - return controllers.download_str_variants(_, case, variants_query) - def decorator(store, institute, case, variants_query, page): return controllers.str_variants( store=store, @@ -124,15 +116,6 @@ def decorator(store, institute, case, variants_query, page): def sv_variants(institute_id: str, case_name: str): """Display a list of structural variants (SV).""" - def form_builder(store, inst, case, cat, vtype): - """Builds the SV filters form.""" - return controllers.populate_snv_sv_mei_str_filters_form( - store=store, institute_obj=inst, case_obj=case, category=cat, request_obj=request - ) - - def data_exporter(store, case, variants_query): - return controllers.download_variants(store, case, variants_query) - def decorator(store, institute, case, variants_query, page): return controllers.sv_mei_variants( store=store, @@ -157,15 +140,6 @@ def decorator(store, institute, case, variants_query, page): def cancer_sv_variants(institute_id: str, case_name: str): """Display a list of cancer structural variants.""" - def form_builder(store, inst, case, cat, vtype): - """Builds the cancer SV filters form.""" - return controllers.populate_snv_sv_mei_str_filters_form( - store=store, institute_obj=inst, case_obj=case, category=cat, request_obj=request - ) - - def data_exporter(store, case, variants_query): - return controllers.download_variants(store, case, variants_query) - def decorator(store, institute, case, variants_query, page): return controllers.sv_mei_variants(store, institute, case, variants_query, page) @@ -184,15 +158,6 @@ def decorator(store, institute, case, variants_query, page): def mei_variants(institute_id: str, case_name: str): """Display a list of MEI variants.""" - def form_builder(store, inst, case, cat, vtype): - """Builds the cancer SV filters form.""" - return controllers.populate_snv_sv_mei_str_filters_form( - store=store, institute_obj=inst, case_obj=case, category=cat, request_obj=request - ) - - def data_exporter(store, case, variants_query): - return controllers.download_variants(store, case, variants_query) - def decorator(store, institute, case, variants_query, page): return controllers.sv_mei_variants(store, institute, case, variants_query, page) @@ -211,110 +176,23 @@ def decorator(store, institute, case, variants_query, page): def cancer_variants(institute_id, case_name): """Show cancer variants overview.""" - page = controllers.get_variants_page(request.form) - - category = "cancer" - institute_obj, case_obj = institute_and_case(store, institute_id, case_name) - variant_type = Markup.escape( - request.args.get("variant_type", request.form.get("variant_type", "clinical")) - ) - if variant_type not in ["clinical", "research"]: - variant_type = "clinical" - variants_stats = store.case_variants_count(case_obj["_id"], institute_id, variant_type, False) - - user_obj = store.user(current_user.email) - if request.method == "POST": - if "dismiss_submit" in request.form: # dismiss a list of variants - controllers.dismiss_variant_list( - store, - institute_obj, - case_obj, - VARIANT_PAGE, - request.form.getlist("dismiss"), - request.form.getlist("dismiss_choices"), - ) - - form = controllers.populate_filters_form( - store, institute_obj, case_obj, user_obj, category, request.form + def decorator(store, institute, case, variants_query, page, form): + return controllers.cancer_variants( + store=store, + institute_obj=institute, + case_obj=case, + variants_query=variants_query, + page=page, + form=form, ) - # if user is not loading an existing filter, check filter form - if ( - request.form.get("load_filter") is None - and request.form.get("audit_filter") is None - and form.validate_on_submit() is False - ): - # Flash a message with errors - for field, err_list in form.errors.items(): - for err in err_list: - flash( - f"Content of field '{field}' does not have a valid format", - "warning", - ) - # And do not submit the form - return redirect( - url_for(".cancer_variants", institute_id=institute_id, case_name=case_name) - ) - else: - form = CancerFiltersForm(request.args) - # set chromosome to all chromosomes - form.chrom.data = request.args.get("chrom", "") - if form.gene_panels.data == []: - form.gene_panels.data = controllers.case_default_panels(case_obj) - - controllers.populate_force_show_unaffected_vars(institute_obj, form) - - # update status of case if visited for the first time - controllers.activate_case(store, institute_obj, case_obj, current_user) - - # Populate chromosome select choices - controllers.populate_chrom_choices(form, case_obj) - - # Populate custom soft filters - controllers.populate_institute_soft_filters(form=form, institute_obj=institute_obj) - - form.gene_panels.choices = controllers.gene_panel_choices(store, institute_obj, case_obj) - - genome_build = get_case_genome_build(case_obj) - cytobands = store.cytoband_by_chrom(genome_build) - - controllers.update_form_hgnc_symbols(store, case_obj, form) - - variants_query = store.variants( - case_obj["_id"], category="cancer", query=form.data, build=genome_build - ) - result_size = store.count_variants( - case_obj["_id"], form.data, None, category, build=genome_build - ) - - if request.form.get("export"): - return controllers.download_variants(store, case_obj, variants_query) - - data = controllers.cancer_variants( - store, - institute_id, - case_name, - variants_query, - form, - page=page, - ) - - return dict( - cytobands=cytobands, - dismiss_variant_options={ - **DISMISS_VARIANT_OPTIONS, - **CANCER_SPECIFIC_VARIANT_DISMISS_OPTIONS, - }, - scroll_pos=int(float(request.values.get("scroll_pos", 0) or 0)), - expand_search=controllers.get_expand_search(request.form), - filters=controllers.populate_persistent_filters_choices( - institute_id=institute_id, category=category, form=form - ), - result_size=result_size, - show_dismiss_block=controllers.get_show_dismiss_block(), - total_variants=variants_stats.get(variant_type, {}).get(category, "NA"), - variant_type=variant_type, - **data, + return controllers.render_variants_page( + category="cancer", + institute_id=institute_id, + case_name=case_name, + form_builder=form_builder, + data_exporter=data_exporter, + decorator=decorator, ) diff --git a/tests/server/blueprints/variants/test_variants_views.py b/tests/server/blueprints/variants/test_variants_views.py index ce5c2f116c..37ff7f06ac 100644 --- a/tests/server/blueprints/variants/test_variants_views.py +++ b/tests/server/blueprints/variants/test_variants_views.py @@ -401,30 +401,6 @@ def test_cancer_variants(app, institute_obj, case_obj): assert resp.status_code == 200 -def test_filter_cancer_variants_wrong_params(app, institute_obj, case_obj): - """test filter cancer SNV variants with filter form filled with parameters having the wrong format""" - - # GIVEN an initialized app - with app.test_client() as client: - # GIVEN that the user could be logged in - resp = client.get(url_for("auto_login")) - - # When a POST request with filter containing wrongly formatted parameters is sent - form_data = { - "control_frequency": "not a number!", - } - resp = client.post( - url_for( - "variants.cancer_variants", - institute_id=institute_obj["internal_id"], - case_name=case_obj["display_name"], - ), - data=form_data, - ) - # THEN it should return a redirected page - assert resp.status_code == 302 - - def test_filter_cancer_variants_by_vaf(app, institute_obj, cancer_case_obj, cancer_variant_obj): """Tests the cancer form filter by VAF"""