diff --git a/HISTORY.rst b/HISTORY.rst index 313cdc44a..0173e947f 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -9,6 +9,8 @@ HEAD (unreleased) End-User Summary ================ +- Allowing to download all users annotation for whole project in one Excel/TSV file. +- Improving variant annotation overview per case/project and allowing download. - Adding "not hom. alt." filter setting. - Allowing users to easily copy case UUID by icon in case heading. - Fixing bug that made the user icon top right disappear. @@ -16,6 +18,9 @@ End-User Summary Full Change List ================ +- Allowing to download all users annotation for whole project in one Excel/TSV file. +- Using SQL Alchemy query instrastructure for per-case/project annotation feature. +- Removing vendored JS/CSS, using CDN for development and download on Docker build instead. - Adding "not hom. alt." filter setting. - Improving admin configuration documentation. - Extending admin tuning documentation. diff --git a/beaconsite/tests/test_permissions_ajax.py b/beaconsite/tests/test_permissions_ajax.py index 87f0e10f6..90833dfe3 100644 --- a/beaconsite/tests/test_permissions_ajax.py +++ b/beaconsite/tests/test_permissions_ajax.py @@ -57,7 +57,6 @@ def test_get(self, r_mock): class TestBeaconQueryAjaxView(TestProjectAPIPermissionBase): - @skip(reason="missing urlescape in sodar_core, fixed in 0.9.1") @requests_mock.Mocker() def test_get(self, r_mock): _local_site = SiteFactory(role=Site.LOCAL) diff --git a/clinvar_export/tests/test_permissions_ajax.py b/clinvar_export/tests/test_permissions_ajax.py index 900573b90..8b5233542 100644 --- a/clinvar_export/tests/test_permissions_ajax.py +++ b/clinvar_export/tests/test_permissions_ajax.py @@ -635,7 +635,6 @@ def test_delete(self): class TestQueryOmimAjaxViews(TestProjectAPIPermissionBase): """Permission tests for OMIM term AJAX views""" - @skip(reason="missing urlescape in sodar_core, fixed in 0.9.1") def test(self): hpo_record = HpoFactory() url = ( @@ -649,8 +648,9 @@ def test(self): self.delegate_as.user, self.contributor_as.user, self.guest_as.user, + self.user_no_roles, ] - bad_users = [self.anonymous, self.user_no_roles] + bad_users = [self.anonymous] self.assert_response(url, good_users, 200, method="GET") self.assert_response(url, bad_users, 302, method="GET") # redirect to login @@ -658,7 +658,6 @@ def test(self): class TestQueryHpoAjaxViews(TestProjectAPIPermissionBase): """Permission tests for the AJAX views for querying for HPO terms.""" - @skip(reason="missing urlescape in sodar_core, fixed in 0.9.1") def test(self): hpo_record = HpoNameFactory() url = ( @@ -672,8 +671,9 @@ def test(self): self.delegate_as.user, self.contributor_as.user, self.guest_as.user, + self.user_no_roles, ] - bad_users = [self.anonymous, self.user_no_roles] + bad_users = [self.anonymous] self.assert_response(url, good_users, 200, method="GET") self.assert_response(url, bad_users, 302, method="GET") # redirect to login diff --git a/clinvar_export/tests/test_views_ajax.py b/clinvar_export/tests/test_views_ajax.py index 136f17964..deadb0239 100644 --- a/clinvar_export/tests/test_views_ajax.py +++ b/clinvar_export/tests/test_views_ajax.py @@ -833,13 +833,14 @@ def test_query(self): response = self.client.get(url) self.assertEqual(response.status_code, 200) res_json = response.json() + lst = [x.strip() for x in hpo_record.name.split(";") if x.strip()] expected = jsonmatch.compile( { "query": hpo_record.database_id, "result": [ - {"term_id": "OMIM:0", "term_name": "Alternative Description"}, - {"term_id": "OMIM:0", "term_name": "Disease 0"}, - {"term_id": "OMIM:0", "term_name": "Gene Symbol"}, + {"term_id": hpo_record.database_id, "term_name": lst[2]}, + {"term_id": hpo_record.database_id, "term_name": lst[0]}, + {"term_id": hpo_record.database_id, "term_name": lst[1]}, ], } ) @@ -863,7 +864,7 @@ def test_query(self): expected = jsonmatch.compile( { "query": hpo_name_record.hpo_id, - "result": [{"term_id": "HP:0000000", "term_name": "Phenotype 0"}], + "result": [{"term_id": hpo_name_record.hpo_id, "term_name": hpo_name_record.name}], } ) expected.assert_matches(res_json) diff --git a/clinvar_export/views_ajax.py b/clinvar_export/views_ajax.py index 35a87298b..573350c46 100644 --- a/clinvar_export/views_ajax.py +++ b/clinvar_export/views_ajax.py @@ -6,7 +6,6 @@ import pathlib import re -import requests from django.contrib.postgres.aggregates import ArrayAgg from django.db.models import Q from django.http import JsonResponse, HttpResponse diff --git a/geneinfo/models.py b/geneinfo/models.py index 6ddb17bb1..36c230534 100644 --- a/geneinfo/models.py +++ b/geneinfo/models.py @@ -122,6 +122,15 @@ class Meta: ] +def build_entrez_id_to_symbol(entrez_ids): + """Helper function that builds a map from Entrez ID to gene symbol for the given Entrez gene IDs.""" + entrez_ids = list(sorted(set(filter(bool, entrez_ids)))) + result = {x: x for x in entrez_ids} + for hgnc in Hgnc.objects.filter(entrez_id__in=entrez_ids).order_by("entrez_id"): + result[hgnc.entrez_id] = hgnc.symbol + return result + + class Mim2geneMedgen(models.Model): """Information to translate Entrez ID int OMIM ID.""" diff --git a/variants/models.py b/variants/models.py index 549e62f6b..7054e7aee 100644 --- a/variants/models.py +++ b/variants/models.py @@ -334,6 +334,11 @@ def get_description(self): map(str, (self.release, self.chromosome, self.start, self.reference, self.alternative)) ) + def human_readable(self): + return "{}:{}-{:,}-{}-{}".format( + self.release, self.chromosome, self.start, self.reference, self.alternative + ) + def __repr__(self): return "-".join( map( diff --git a/variants/templates/variants/case/detail_annotation.html b/variants/templates/variants/case/detail_annotation.html index b829d7841..1ec549d9d 100644 --- a/variants/templates/variants/case/detail_annotation.html +++ b/variants/templates/variants/case/detail_annotation.html @@ -11,13 +11,32 @@

Annotated Variants + +
+ +

- + @@ -33,74 +52,77 @@

- {% for variant, data in commentsflags.items %} - + {% for data in commentsflags.values %} + - {% if data|keyvalue:"flags" %} + {% if data.flags %} {% else %} {% endif %}
Variant
Gene(s)
Gene(s) / Effect(s)
ACMG Rating Flags Comments
- chr{{ variant.0 }}:{{ variant.1|intcomma }}-{{ variant.2 }}-{{ variant.3 }} + {{ data.variants.0.human_readable }}
- {{ data|keyvalue:"genes"|join:", "|default:"-" }} + {% for variant in data.variants %} + {{ gene_id_to_symbol|keyvalue:variant.refseq_gene_id }}:{{ variant.refseq_hgvs_p|default:variant.refseq_hgvs_c|default:"-" }}{% if not forloop.last %},{% endif %} + {% endfor %} - {% if data|keyvalue:"acmg_rating" %} - - {{ data|keyvalue:"acmg_rating"|keyvalue:"class" }} + {% if data.acmg_rating %} + + {{ data.acmg_rating|acmg_classification2 }} {% else %}

No ACMG rating.

{% endif %}
- - - - - - - + + + + + + + - + - + - + - + - + No flags.
    - {% for comment in data|keyvalue:"comments" %} -
  • -
    + {% for comment in data.comments %} + {{ comment }} +
  • +
    - {{ comment|keyvalue:"username" }} - {{ comment|keyvalue:"date_created"|date:"Y/m/d H:i" }}: + {{ comment.user.username }} + {{ comment.date_created|date:"Y/m/d H:i" }}: - {{ comment|keyvalue:"text" }} - {% if comment|keyvalue:"user" == request.user or request.user.is_superuser %} + {{ comment.text }} + {% if comment.user == request.user or request.user.is_superuser %} {% endif %}
    -
  • {% endfor %}
- +
IGV diff --git a/variants/templates/variants/case_list/annotation.html b/variants/templates/variants/case_list/annotation.html index 4f56d6dfb..c0e808014 100644 --- a/variants/templates/variants/case_list/annotation.html +++ b/variants/templates/variants/case_list/annotation.html @@ -11,6 +11,25 @@

Annotated Variants + +

diff --git a/variants/tests/test_views.py b/variants/tests/test_views.py index d6e9deccf..cf04c3ede 100644 --- a/variants/tests/test_views.py +++ b/variants/tests/test_views.py @@ -225,6 +225,74 @@ def test_render_with_variant_stats(self): self.assertEqual(response.context["object"].name, self.case.name) +class CaseDownloadAnnotationsView(ViewTestBase): + """Smoke test for downloading all user annotations for one case.""" + + def setUp(self): + super().setUp() + self.case, self.variant_set, _ = CaseWithVariantSetFactory.get("small") + + def test_render_empty_tsv(self): + with self.login(self.user): + response = self.client.get( + reverse( + "variants:case-download-annotations", + kwargs={"project": self.case.project.sodar_uuid, "case": self.case.sodar_uuid}, + ) + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response["content-type"], "text/tsv") + + def test_render_empty_xlsx(self): + with self.login(self.user): + response = self.client.get( + reverse( + "variants:case-download-annotations", + kwargs={"project": self.case.project.sodar_uuid, "case": self.case.sodar_uuid}, + ) + + "?format=xlsx" + ) + self.assertEqual(response.status_code, 200) + self.assertEqual( + response["content-type"], + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + + +class ProjectDownloadAnnotationsView(ViewTestBase): + """Smoke test for downloading all user annotations for one project.""" + + def setUp(self): + super().setUp() + self.case, self.variant_set, _ = CaseWithVariantSetFactory.get("small") + + def test_render_empty_tsv(self): + with self.login(self.user): + response = self.client.get( + reverse( + "variants:project-download-annotations", + kwargs={"project": self.case.project.sodar_uuid,}, + ) + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response["content-type"], "text/tsv") + + def test_render_empty_xlsx(self): + with self.login(self.user): + response = self.client.get( + reverse( + "variants:project-download-annotations", + kwargs={"project": self.case.project.sodar_uuid}, + ) + + "?format=xlsx" + ) + self.assertEqual(response.status_code, 200) + self.assertEqual( + response["content-type"], + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + + class TestCaseUpdateView(ViewTestBase): def setUp(self): super().setUp() diff --git a/variants/urls.py b/variants/urls.py index d11fa3e0a..cff28f746 100644 --- a/variants/urls.py +++ b/variants/urls.py @@ -51,6 +51,16 @@ view=views.CaseDeleteView.as_view(), name="case-delete", ), + url( + regex=r"^(?P[0-9a-f-]+)/case/download-annotations/(?P[0-9a-f-]+)/$", + view=views.CaseDownloadAnnotationsView.as_view(), + name="case-download-annotations", + ), + url( + regex=r"^(?P[0-9a-f-]+)/case/download-annotations/$", + view=views.ProjectDownloadAnnotationsView.as_view(), + name="project-download-annotations", + ), url( regex=r"^(?P[0-9a-f-]+)/case-delete-job/detail/(?P[0-9a-f-]+)/$", view=views.CaseDeleteJobDetailView.as_view(), diff --git a/variants/views.py b/variants/views.py index 512651286..e16ed915b 100644 --- a/variants/views.py +++ b/variants/views.py @@ -26,6 +26,7 @@ from django.utils import timezone from django.views.generic import DetailView, FormView, ListView, View, RedirectView, UpdateView from django.views.generic.detail import SingleObjectMixin, SingleObjectTemplateResponseMixin +import xlsxwriter import simplejson as json from django.views.generic.edit import FormMixin @@ -41,7 +42,15 @@ from extra_annos.views import ExtraAnnosMixin from frequencies.models import MT_DB_INFO from geneinfo.views import get_gene_infos -from geneinfo.models import NcbiGeneInfo, NcbiGeneRif, HpoName, Hpo, EnsemblToGeneSymbol, Hgnc +from geneinfo.models import ( + NcbiGeneInfo, + NcbiGeneRif, + HpoName, + Hpo, + EnsemblToGeneSymbol, + Hgnc, + build_entrez_id_to_symbol, +) from frequencies.views import FrequencyMixin from projectroles.app_settings import AppSettingAPI from projectroles.views import ( @@ -61,6 +70,7 @@ KnownGeneAAQuery, DeleteStructuralVariantsQuery, DeleteSmallVariantsQuery, + SmallVariantUserAnnotationQuery, ) from .models import ( only_source_name, @@ -847,6 +857,47 @@ def get(self, *args, **kwargs): return JsonResponse({"count": self.get_object().case_comments.count()}) +def get_annotations_by_variant(case=None, cases=None, project=None): + """Helper function to get all annotations by case and variant. + + The result is a dict. First level of keys is case SODAR UUID, second is variant description, then + "variants", "flags", "comments", "acmg_rating". + """ + annotated_small_vars = SmallVariantUserAnnotationQuery(SQLALCHEMY_ENGINE).run( + case=case, cases=cases, project=project + ) + + case_ids = list(sorted({x.case_id for x in annotated_small_vars.small_variants})) + case_id_to_uuid = {} + for case in Case.objects.filter(id__in=case_ids).order_by("name"): + case_id_to_uuid[case.id] = case.sodar_uuid + + result = {} + + # Ensure that at least one entry is there if exactly one case is to be queried for. + if case: + result.setdefault(case.sodar_uuid, {}) + + for small_var in annotated_small_vars.small_variants: + case_uuid = case_id_to_uuid[small_var.case_id] + result.setdefault(case_uuid, {}) + result[case_uuid].setdefault( + small_var.get_description(), + {"variants": [], "flags": None, "comments": [], "acmg_rating": None,}, + ) + result[case_uuid][small_var.get_description()]["variants"].append(small_var) + for flags in annotated_small_vars.small_variant_flags: + case_uuid = case_id_to_uuid[flags.case_id] + result[case_uuid][flags.get_variant_description()]["flags"] = flags + for comments in annotated_small_vars.small_variant_comments: + case_uuid = case_id_to_uuid[flags.case_id] + result[case_uuid][comments.get_variant_description()]["comments"].append(comments) + for rating in annotated_small_vars.acmg_criteria_rating: + case_uuid = case_id_to_uuid[flags.case_id] + result[case_uuid][rating.get_variant_description()]["acmg_rating"] = rating + return result + + class CaseDetailView( LoginRequiredMixin, LoggedInPermissionMixin, @@ -877,6 +928,13 @@ def get_context_data(self, *args, **kwargs): result["dps"] = {sample: {} for sample in result["samples"]} result["casecommentsform"] = CaseCommentsForm() result["commentsflags"] = self.join_small_var_comments_and_flags() + result["gene_id_to_symbol"] = build_entrez_id_to_symbol( + [ + v.refseq_gene_id + for entry in result["commentsflags"].values() + for v in entry["variants"] + ] + ) result["sv_commentsflags"] = self.join_sv_comments_and_flags() result["acmg_summary"] = { "count": case.acmg_ratings.count(), @@ -1088,65 +1146,7 @@ def get_effect_content(self): def join_small_var_comments_and_flags(self): case = self.get_object() - flags = case.small_variant_flags.all() - comments = case.small_variant_comments.all() - acmg_ratings = case.acmg_ratings.all() - result = defaultdict(lambda: dict(flags=None, comments=[], genes=set(), acmg_rating=None)) - - def get_gene_symbol(release, chromosome, start, end): - bins = binning.containing_bins(start - 1, end) - gene_intervals = list( - GeneInterval.objects.filter( - database="ensembl", - release=release, - chromosome=chromosome, - bin__in=bins, - start__lte=end, - end__gte=start, - ) - ) - gene_ids = [itv.gene_id for itv in gene_intervals] - symbols1 = { - o.gene_symbol - for o in EnsemblToGeneSymbol.objects.filter(ensembl_gene_id__in=gene_ids) - } - symbols2 = {o.symbol for o in Hgnc.objects.filter(ensembl_gene_id__in=gene_ids)} - return symbols1 | symbols2 - - for record in flags: - result[(record.chromosome, record.start, record.reference, record.alternative)][ - "flags" - ] = model_to_dict(record) - result[(record.chromosome, record.start, record.reference, record.alternative)][ - "genes" - ] |= get_gene_symbol(record.release, record.chromosome, record.start, record.end) - - for record in comments: - result[(record.chromosome, record.start, record.reference, record.alternative)][ - "comments" - ].append( - { - **model_to_dict(record), - "date_created": record.date_created, - "user": record.user, - "username": record.user.username, - } - ) - result[(record.chromosome, record.start, record.reference, record.alternative)][ - "genes" - ] |= get_gene_symbol(record.release, record.chromosome, record.start, record.end) - - for record in acmg_ratings: - result[(record.chromosome, record.start, record.reference, record.alternative)][ - "acmg_rating" - ] = {"data": record, "class": record.acmg_class} - result[(record.chromosome, record.start, record.reference, record.alternative)][ - "genes" - ] |= get_gene_symbol(record.release, record.chromosome, record.start, record.end) - - for var in result: - result[var]["genes"] = sorted(result[var]["genes"]) - return dict(result) + return get_annotations_by_variant(case=case)[case.sodar_uuid] def join_sv_comments_and_flags(self): case = self.get_object() @@ -1446,6 +1446,188 @@ def get(self, *args, **kwargs): return redirect(delete_job.get_absolute_url()) +#: Header for for table when downloading annotation data. +ANNOTATION_DOWNLOAD_HEADER = [ + "case", + "genome_release", + "chromosome", + "position", + "reference", + "alternative", + "refseq_genes", + "refseq_transcripts", + "refseq_hgvs", + "refseq_effects", + "acmg_rating", + "flag_bookmarked", + "flag_candidate", + "flag_final_causative", + "flag_for_validation", + "flag_no_disease_association", + "flag_segregates", + "flag_doesnt_segregate", + "flag_visual", + "flag_molecular", + "flag_validation", + "flag_phenotype_match", + "flag_summary", + "comments", +] + + +class BaseDownloadAnnotationsView( + LoginRequiredMixin, + LoggedInPermissionMixin, + ProjectPermissionMixin, + ProjectContextMixin, + DetailView, +): + """Download case user annotations as Excel file.""" + + permission_required = "variants.view_data" + slug_field = "sodar_uuid" + + def get_impl(self, case=None, cases=None, project=None): + with tempfile.NamedTemporaryFile("w+b") as f: + # Write output to temporary file. + if self.request.GET.get("format", "tsv") == "xlsx": + content_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + file_ext = "xlsx" + self.write_xlsx(case=case, cases=cases, project=project, output_f=f) + else: + content_type = "text/tsv" + file_ext = "tsv" + self.write_tsv(case=case, cases=cases, project=project, output_f=f) + # Build HTTP response. + f.flush() + f.seek(0) + if case: + identifier = case.name + elif cases: + identifier = "-".join(cases[:5].name) + else: + identifier = project.title.replace(" ", "-") + response = HttpResponse(f.read(), content_type=content_type,) + response["Content-Disposition"] = "attachment; filename=case-annotations-%s.%s" % ( + identifier, + file_ext, + ) + return response + + def write_xlsx(self, *, case, cases, project, output_f): + """Write output XLSX file.""" + workbook = xlsxwriter.Workbook(output_f.name, {"remove_timezone": True}) + header_format = workbook.add_format({"bold": True}) + sheet = workbook.add_worksheet("Small Variants") + for rowno, row in enumerate(self.yield_rows(case=case, cases=cases, project=project)): + if rowno == 0: + sheet.write_row(0, 0, row, header_format) + else: + sheet.write_row(rowno, 0, row) + workbook.close() + + def write_tsv(self, *, case, cases, project, output_f): + """Write output TSV file.""" + for row in self.yield_rows(case=case, cases=cases, project=project): + output_f.write(("\t".join(map(str, row)) + "\n").encode()) + + def yield_rows(self, *, case, cases, project): + yield ANNOTATION_DOWNLOAD_HEADER + + res = get_annotations_by_variant(case=case, cases=cases, project=project) + case_uuid_to_name = { + c.sodar_uuid: c.name for c in Case.objects.filter(sodar_uuid__in=res.keys()) + } + + # import pdb; pdb.set_trace() + for case_uuid, annos in res.items(): + for anno in annos.values(): + comments = [ + "%s @%s: %s" + % ( + comment.user.username, + comment.date_modified.strftime("%Y-%m-%d %H:%M"), + comment.text.replace("\t", " ").replace("\n", " "), + ) + for comment in anno["comments"] + ] + + gene_id_to_symbol = build_entrez_id_to_symbol( + [x.refseq_gene_id for x in anno["variants"]] + ) + genes = ", ".join( + gene_id_to_symbol[x.refseq_gene_id] + for x in anno["variants"] + if x.refseq_gene_id + ) + transcripts = ", ".join( + x.refseq_transcript_id for x in anno["variants"] if x.refseq_gene_id + ) + hgvs = ", ".join( + x.refseq_hgvs_p or x.refseq_hgvs_c for x in anno["variants"] if x.refseq_gene_id + ) + effects = ", ".join( + "&".join(x.refseq_effect) for x in anno["variants"] if x.refseq_gene_id + ) + + variant = anno["variants"][0] + if not anno["flags"]: + row_flags = ["N/A"] * 12 + else: + row_flags = [ + anno["flags"].flag_bookmarked, + anno["flags"].flag_candidate, + anno["flags"].flag_final_causative, + anno["flags"].flag_for_validation, + anno["flags"].flag_no_disease_association, + anno["flags"].flag_segregates, + anno["flags"].flag_doesnt_segregate, + anno["flags"].flag_visual, + anno["flags"].flag_molecular, + anno["flags"].flag_validation, + anno["flags"].flag_phenotype_match, + anno["flags"].flag_summary, + ] + row = ( + [ + case_uuid_to_name[case_uuid], + variant.release, + variant.chromosome, + variant.start, + variant.reference, + variant.alternative, + genes, + transcripts, + hgvs, + effects, + anno["acmg_rating"].acmg_class if anno["acmg_rating"] else "N/A", + ] + + row_flags + + ["|".join(comments),] + ) + yield row + + +class CaseDownloadAnnotationsView(BaseDownloadAnnotationsView): + """Download case user annotations as Excel file.""" + + model = Case + slug_url_kwarg = "case" + + def get(self, *args, **kwargs): + return self.get_impl(case=self.get_object()) + + +class ProjectDownloadAnnotationsView(BaseDownloadAnnotationsView): + """Download project user annotations as Excel file.""" + + model = Project + slug_url_kwarg = "project" + + def get(self, *args, **kwargs): + return self.get_impl(project=self.get_object()) + + class SmallVariantsDeleteView( LoginRequiredMixin, LoggedInPermissionMixin,