From a98c3ceed8148413f3a2f575b21cfee19bf6905c Mon Sep 17 00:00:00 2001 From: Sandy Rogers <sandyr@ebi.ac.uk> Date: Tue, 2 Jul 2024 14:42:15 +0100 Subject: [PATCH] MAG <- Sample containment mapping (#11) * [wip] adds containment relationship between MAGs and Samples * adds tests for genome-sample mapping --- holofood/admin.py | 15 +- holofood/api.py | 28 ++++ holofood/export.py | 20 ++- holofood/filters.py | 32 +++- .../commands/import_mag_catalogue.py | 2 +- .../commands/import_mag_sample_mapping.py | 103 ++++++++++++ .../0037_genomesamplecontainment.py | 47 ++++++ holofood/models.py | 46 ++++++ holofood/tests/conftest.py | 20 ++- .../static_fixtures/mag-sample-mapping.tsv | 2 + holofood/tests/test_api.py | 24 +++ holofood/tests/test_import_comands.py | 28 +++- holofood/tests/test_website.py | 39 ++++- holofood/urls.py | 6 + holofood/views.py | 26 +++ static/scss/site.scss | 6 + templates/base.html | 3 - .../pages/genome_catalogue_detail.html | 7 +- templates/holofood/pages/genome_detail.html | 153 ++++++++++++++++++ 19 files changed, 589 insertions(+), 18 deletions(-) create mode 100644 holofood/management/commands/import_mag_sample_mapping.py create mode 100644 holofood/migrations/0037_genomesamplecontainment.py create mode 100644 holofood/tests/static_fixtures/mag-sample-mapping.tsv create mode 100644 templates/holofood/pages/genome_detail.html diff --git a/holofood/admin.py b/holofood/admin.py index 4f55284..0b51f51 100644 --- a/holofood/admin.py +++ b/holofood/admin.py @@ -7,7 +7,6 @@ from holofood.models import ( Sample, - SampleMetadataMarker, SampleStructuredDatum, AnalysisSummary, GenomeCatalogue, @@ -16,6 +15,7 @@ ViralCatalogue, Animal, AnimalStructuredDatum, + GenomeSampleContainment, ) @@ -106,6 +106,19 @@ class GenomeCatalogueAdmin(ModelAdmin): inlines = [GenomeInline] +class GenomeSampleContainmentInline(TabularInlinePaginated): + model = GenomeSampleContainment + per_page = 5 + can_delete = True + show_change_link = True + show_full_result_count = True + + +@admin.register(Genome) +class GenomeAdmin(ModelAdmin): + inlines = [GenomeSampleContainmentInline] + + class ViralFragmentInline(TabularInlinePaginated): model = ViralFragment fields = ["id", "cluster_representative", "viral_type"] diff --git a/holofood/api.py b/holofood/api.py index 397424d..9299f53 100644 --- a/holofood/api.py +++ b/holofood/api.py @@ -22,6 +22,7 @@ ViralFragment, Animal, AnimalStructuredDatum, + GenomeSampleContainment, ) from holofood.utils import holofood_config @@ -194,6 +195,16 @@ class Config: model_fields = ["accession", "cluster_representative", "taxonomy", "metadata"] +class GenomeSampleContainmentSchema(ModelSchema): + class Config: + model = GenomeSampleContainment + model_fields = ["sample", "containment"] + + +class GenomeWithContainingSamplesSchema(GenomeSchema): + samples_containing: List[GenomeSampleContainmentSchema] + + class ViralCatalogueSchema(ModelSchema): related_genome_catalogue: GenomeCatalogueSchema analysis_summaries: List[RelatedAnalysisSummarySchema] @@ -453,6 +464,23 @@ def list_genome_catalogue_genomes(request, catalogue_id: str): return catalogue.genomes.all() +@api.get( + "/genome-catalogues/{genome_catalogue_id}/genomes/{genome_id}", + response=GenomeWithContainingSamplesSchema, + summary="Fetch the detail of a Genome", + description="A Genomes is a Metagenomic Assembled Genome (MAG)." + "Each MAG originates from HoloFood samples." + "Each MAG has also been clustered with MAGs from other projects." + "Each HoloFood MAG references the best representative of these clusters, in MGnify." + "Each MAG has also been searched in all of the project samples, to find samples which contain the kmers of genome.", + tags=[GENOMES], + url_name="get_genome", +) +def get_genome(request, genome_catalogue_id: str, genome_id: str): + genome = get_object_or_404(Genome, accession=genome_id) + return genome + + @api.get( "/viral-catalogues", response=List[ViralCatalogueSchema], diff --git a/holofood/export.py b/holofood/export.py index 4d19528..9b44055 100644 --- a/holofood/export.py +++ b/holofood/export.py @@ -13,8 +13,9 @@ ViralFragmentSchema, AnimalSlimSchema, AnimalStructuredDatumSchema, + GenomeSampleContainmentSchema, ) -from holofood.models import Sample, GenomeCatalogue, ViralCatalogue, Animal +from holofood.models import Sample, GenomeCatalogue, ViralCatalogue, Animal, Genome class CSVRenderer(BaseRenderer): @@ -114,6 +115,23 @@ def list_genome_catalogue_genomes(request, catalogue_id: str): return catalogue.genomes.all() +@export_api.get( + "/genome-catalogues/{genome_catalogue_id}/genomes/{genome_id}/samples_containing", + response=List[GenomeSampleContainmentSchema], + summary="Fetch the list of Samples contained by a Genome, as a TSV", + description="A Genomes is a Metagenomic Assembled Genome (MAG)." + "Each MAG originates from HoloFood samples." + "Each MAG has also been clustered with MAGs from other projects." + "Each HoloFood MAG references the best representative of these clusters, in MGnify." + "Each species representative MAG has also been searched in all of the project samples, " + "to find samples which contain the kmers of genome.", + url_name="get_samples_containing_genome", +) +def get_genome(request, genome_catalogue_id: str, genome_id: str): + genome = get_object_or_404(Genome, accession=genome_id) + return genome.samples_containing + + @export_api.get( "/viral-catalogues/{catalogue_id}/fragments", response=List[ViralFragmentSchema], diff --git a/holofood/filters.py b/holofood/filters.py index a2f8b97..4ed07d2 100644 --- a/holofood/filters.py +++ b/holofood/filters.py @@ -13,9 +13,17 @@ OuterRef, Func, ) +from django.forms import NumberInput from django.utils.safestring import mark_safe -from holofood.models import Sample, Genome, ViralFragment, Animal, AnimalStructuredDatum +from holofood.models import ( + Sample, + Genome, + ViralFragment, + Animal, + AnimalStructuredDatum, + GenomeSampleContainment, +) from holofood.utils import holofood_config @@ -123,6 +131,28 @@ class Meta: } +class GenomeSampleContainmentFilter(django_filters.FilterSet): + minimum_containment = django_filters.NumberFilter( + field_name="containment", + label="Minimum containment", + lookup_expr="gte", + min_value=0.0, + max_value=1.0, + help_text=mark_safe("Fraction of MAG kmers present in samples"), + widget=NumberInput( + attrs={"type": "range", "min": "0.2", "max": "1.0", "step": "0.05"} + ), + ) + + class Meta: + model = GenomeSampleContainment + + fields = { + "sample__accession": ["icontains"], + "sample__animal__accession": ["icontains"], + } + + class ViralFragmentFilter(django_filters.FilterSet): ALL = "Include species-cluster members" REPS = "Species-cluster representatives only" diff --git a/holofood/management/commands/import_mag_catalogue.py b/holofood/management/commands/import_mag_catalogue.py index a0caff7..ea28dd9 100644 --- a/holofood/management/commands/import_mag_catalogue.py +++ b/holofood/management/commands/import_mag_catalogue.py @@ -20,7 +20,7 @@ def add_arguments(self, parser): parser.add_argument( "catalogue_file", type=argparse.FileType("r"), - help="Path to the TSV file listing viral sequences", + help="Path to the TSV file listing MAGs.", ) parser.add_argument( "title", diff --git a/holofood/management/commands/import_mag_sample_mapping.py b/holofood/management/commands/import_mag_sample_mapping.py new file mode 100644 index 0000000..e6349d1 --- /dev/null +++ b/holofood/management/commands/import_mag_sample_mapping.py @@ -0,0 +1,103 @@ +import argparse +import logging +from csv import DictReader + +from django.core.management.base import BaseCommand, CommandError + +from holofood.models import GenomeSampleContainment, Genome, Sample + + +class Command(BaseCommand): + help = ( + "Import mappings between MAGs and samples, from a TSV file. " + "Needs columns of at least `mgyg`, `sample_accession`, `containment`." + ) + + def add_arguments(self, parser): + parser.add_argument( + "mapping_file", + type=argparse.FileType("r"), + help="Path to the TSV file listing MAG – Sample pairs.", + ) + parser.add_argument( + "--catalogue_id_to_preclear", + type=str, + help="(Optional) ID of a MAG catalogue, in slug form, " + "to clear all sample maps from prior to inserting new ones.", + default=None, + ) + + def handle(self, *args, **options): + tsv_file = options["mapping_file"] + catalogue_id_to_preclear = options["catalogue_id_to_preclear"] + + if catalogue_id_to_preclear: + existing_containments = GenomeSampleContainment.objects.filter( + genome__catalogue_id=catalogue_id_to_preclear + ) + logging.info( + f"Deleting {existing_containments.count()} existing containments from genomes in {catalogue_id_to_preclear}" + ) + existing_containments.delete() + + reader = DictReader(tsv_file, delimiter="\t") + + column_mapping = { + "mgyg": "genome_id", + "containment": "containment", + "sample_accession": "sample_id", + } + + missing = set(column_mapping.keys()).difference(reader.fieldnames) + if missing: + raise CommandError( + f"Not all expected columns were found in the TSV. {missing=}" + ) + + for mapping in reader: + logging.info( + f"Importing mapping for {mapping['mgyg']} to sample {mapping['sample_accession']}" + ) + try: + sample = Sample.objects.get(accession=mapping["sample_accession"]) + except Sample.DoesNotExist: + self.stdout.write( + self.style.WARNING( + f"Sample {mapping['sample_accession']} does not exist." + ) + ) + continue + + genomes = Genome.objects.filter(cluster_representative=mapping["mgyg"]) + if not genomes.exists(): + self.stdout.write( + self.style.WARNING( + f"Genomes with cluster rep {mapping['mgyg']} do not exist." + ) + ) + else: + logging.debug(f"Found {genomes.count()} Genomes") + for genome in genomes: + ( + genome_sample_containment, + created, + ) = GenomeSampleContainment.objects.get_or_create( + genome=genome, + sample=sample, + defaults={"containment": mapping["containment"]}, + ) + if created: + logging.debug( + f"Created genome-sample-containment {genome_sample_containment}" + ) + else: + containment = float(mapping["containment"]) + if containment > genome_sample_containment.containment: + logging.info( + f"Genome-sample-containment {genome_sample_containment} already exists, but updating " + ) + logging.debug( + f"Updated genome-sample-containment {genome_sample_containment}" + ) + tsv_file.close() + self.stdout.write(self.style.SUCCESS(f"Done")) diff --git a/holofood/migrations/0037_genomesamplecontainment.py b/holofood/migrations/0037_genomesamplecontainment.py new file mode 100644 index 0000000..2a6378b --- /dev/null +++ b/holofood/migrations/0037_genomesamplecontainment.py @@ -0,0 +1,47 @@ +# Generated by Django 4.2 on 2024-06-20 10:54 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("holofood", "0036_alter_animalstructureddatum_measurement_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="GenomeSampleContainment", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("containment", models.FloatField(default=0)), + ( + "genome", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="samples_containing", + to="holofood.genome", + ), + ), + ( + "sample", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="genomes_contained", + to="holofood.sample", + ), + ), + ], + options={ + "ordering": ("genome", "-containment"), + }, + ), + ] diff --git a/holofood/models.py b/holofood/models.py index de1a242..699a45f 100644 --- a/holofood/models.py +++ b/holofood/models.py @@ -461,6 +461,52 @@ class Meta: ordering = ("accession",) +class GenomeSampleContainmentManager(models.Manager): + def get_queryset(self): + prefetchable_markers = ( + holofood_config.tables.animals_list.default_metadata_marker_columns + ) + primary_markers = AnimalStructuredDatum.objects.filter( + marker__name__in=prefetchable_markers + ) + return ( + super() + .get_queryset() + .select_related("sample") + .select_related("genome") + .select_related("sample__animal") + .prefetch_related( + Prefetch( + "sample__animal__structured_metadata", + queryset=primary_markers, + to_attr="primary_metadata", + ) + ) + ) + + +class GenomeSampleContainment(models.Model): + """ + An instance of a genome being present ("contained") within a metagenomic sample. + """ + + sample = models.ForeignKey( + Sample, on_delete=models.CASCADE, related_name="genomes_contained" + ) + genome = models.ForeignKey( + Genome, on_delete=models.CASCADE, related_name="samples_containing" + ) + containment = models.FloatField(default=0) + + objects = GenomeSampleContainmentManager() + + class Meta: + ordering = ("genome", "-containment") + + def __str__(self): + return f"Containment of {self.genome} in {self.sample}" + + class ViralCatalogue(models.Model): """ A collection of (probable) viral fragments detected in the metagenomic reads. diff --git a/holofood/tests/conftest.py b/holofood/tests/conftest.py index 367ccde..b723ee2 100644 --- a/holofood/tests/conftest.py +++ b/holofood/tests/conftest.py @@ -14,6 +14,7 @@ SampleStructuredDatum, Animal, AnimalStructuredDatum, + GenomeSampleContainment, ) @@ -947,7 +948,7 @@ def salmon_submitted_checklist(salmon_animal): """ -def create_genome_objects() -> GenomeCatalogue: +def create_genome_objects(sample: Sample) -> GenomeCatalogue: catalogue, _ = GenomeCatalogue.objects.get_or_create( id="hf-mag-cat-v1", defaults={ @@ -958,7 +959,7 @@ def create_genome_objects() -> GenomeCatalogue: }, ) - Genome.objects.get_or_create( + genome, _ = Genome.objects.get_or_create( accession="MGYG999", defaults={ "cluster_representative": "MGYG001", @@ -967,6 +968,11 @@ def create_genome_objects() -> GenomeCatalogue: "metadata": {}, }, ) + + GenomeSampleContainment.objects.get_or_create( + genome=genome, sample=sample, defaults={"containment": 0.7} + ) + return catalogue @@ -1098,7 +1104,7 @@ def create_viral_objects() -> ViralCatalogue: id="hf-donut-vir-cat-v1", title="HoloFood Donut Viral Catalogue v1", biome="Donut Surface", - related_genome_catalogue=create_genome_objects(), + related_genome_catalogue=create_genome_objects(Sample.objects.first()), system="chicken", ) rep = ViralFragment.objects.create( @@ -1186,12 +1192,12 @@ def set_metabolights_project_for_sample(sample: Sample, mtbls: str = "MTBLSDONUT @pytest.fixture() -def chicken_mag_catalogue(): - return create_genome_objects() +def chicken_mag_catalogue(chicken_metagenomic_sample): + return create_genome_objects(chicken_metagenomic_sample) @pytest.fixture() -def chicken_viral_catalogue(): +def chicken_viral_catalogue(chicken_metagenomic_sample): return create_viral_objects() @@ -1229,7 +1235,7 @@ class Fixtures: set_metabolights_project_for_sample(Fixtures.samples[0]) - Fixtures.genome_catalogues = [create_genome_objects()] + Fixtures.genome_catalogues = [create_genome_objects(Sample.objects.first())] Fixtures.viral_catalogues = [create_viral_objects()] # set a class attribute on the invoking test context diff --git a/holofood/tests/static_fixtures/mag-sample-mapping.tsv b/holofood/tests/static_fixtures/mag-sample-mapping.tsv new file mode 100644 index 0000000..a233ff9 --- /dev/null +++ b/holofood/tests/static_fixtures/mag-sample-mapping.tsv @@ -0,0 +1,2 @@ + SRA accession containment study_accession sample_accession mgyg +0 ERR1 0.93 PRJEB1 SAMEA00000006 MGYG001 \ No newline at end of file diff --git a/holofood/tests/test_api.py b/holofood/tests/test_api.py index 0b7216c..6e33020 100644 --- a/holofood/tests/test_api.py +++ b/holofood/tests/test_api.py @@ -376,6 +376,19 @@ def test_mag_catalogues(client, chicken_mag_catalogue): == chicken_mag_catalogue.genomes.first().accession ) + response = client.get( + f"/api/genome-catalogues/{chicken_mag_catalogue.id}/genomes/MGYG999" + ) + assert response.status_code == 200 + data = response.json() + assert data.get("cluster_representative") == "MGYG001" + assert data.get("taxonomy") == "Root > Foods > Donuts > Sugar Monster" + assert len(data.get("samples_containing")) == 1 + assert data.get("samples_containing")[0] == { + "sample": "SAMEA00000006", + "containment": 0.7, + } + @pytest.mark.django_db def test_mag_catalogues_export(client, chicken_mag_catalogue): @@ -388,6 +401,17 @@ def test_mag_catalogues_export(client, chicken_mag_catalogue): assert chicken_mag_catalogue.genomes.first().accession in data +@pytest.mark.django_db +def test_mag_containment_export(client, chicken_mag_catalogue): + response = client.get( + f"/export/genome-catalogues/{chicken_mag_catalogue.id}/genomes/MGYG999/samples_containing" + ) + assert response.status_code == 200 + data = response.content.decode() + assert "sample" in data + assert "SAMEA00000006" in data + + @pytest.mark.django_db def test_viral_catalogues(client, chicken_viral_catalogue): response = client.get("/api/viral-catalogues") diff --git a/holofood/tests/test_import_comands.py b/holofood/tests/test_import_comands.py index 36dbe61..a07f575 100644 --- a/holofood/tests/test_import_comands.py +++ b/holofood/tests/test_import_comands.py @@ -8,7 +8,14 @@ from holofood.external_apis.biosamples.api import API_ROOT as BSAPIROOT -from holofood.models import Sample, ViralCatalogue, GenomeCatalogue, Animal +from holofood.models import ( + Sample, + ViralCatalogue, + GenomeCatalogue, + Animal, + GenomeSampleContainment, + Genome, +) from holofood.utils import holofood_config MGAPIROOT = holofood_config.mgnify.api_root.rstrip("/") @@ -169,3 +176,22 @@ def test_import_mag_catalogue(): created_catalogue.genomes.order_by("-accession").first().taxonomy == "Bacteria > Firmicutes_A > Clostridia > Oscillospirales > Acutalibacteraceae > RUG420 > RUG420 sp900317985" ) + + +@pytest.mark.django_db +def test_import_mag_sample_mapping(chicken_mag_catalogue, chicken_metagenomic_sample): + tests_path = os.path.dirname(__file__) + out = _call_command( + "import_mag_sample_mapping", + f"{tests_path}/static_fixtures/mag-sample-mapping.tsv", + f"--catalogue_id_to_preclear={chicken_mag_catalogue.id}", + ) + logging.info(out) + + assert GenomeSampleContainment.objects.count() == 1 + mag = Genome.objects.first() + assert mag.samples_containing.count() == 1 + + containment = GenomeSampleContainment.objects.first() + assert containment.sample.accession == chicken_metagenomic_sample.accession + assert containment.containment == 0.93 diff --git a/holofood/tests/test_website.py b/holofood/tests/test_website.py index 21f55a5..b15e640 100644 --- a/holofood/tests/test_website.py +++ b/holofood/tests/test_website.py @@ -358,12 +358,47 @@ def test_web(self, m): "metagenomics/genome-catalogues", mgnify_link.get_attribute("href") ) - species_rep_link = self.selenium.find_element( + species_rep_links = self.selenium.find_elements( by=By.PARTIAL_LINK_TEXT, value="MGYG" ) + # first link is the genome detail + self.assertEqual(species_rep_links[0].text, catalogue.genomes.first().accession) + + # second link is the cluster rep + self.assertEqual( + species_rep_links[1].text, catalogue.genomes.first().cluster_representative + ) + + # GENOME DETAIL PAGE + species_rep_links[0].click() self.assertEqual( - species_rep_link.text, catalogue.genomes.first().cluster_representative + self.selenium.current_url, + f"{self.live_server_url}/genome-catalogue/{catalogue.id}/{catalogue.genomes.first().accession}", + ) + + export_link = self.selenium.find_element( + by=By.PARTIAL_LINK_TEXT, value="Download all as TSV" + ) + self.assertIn("export", export_link.get_attribute("href")) + + # should be one sample containing this MAG + table = self.selenium.find_element(by=By.TAG_NAME, value="tbody") + self.assertEqual(len(table.find_elements(by=By.TAG_NAME, value="tr")), 1) + + # change containment to very high, so MAG is contained sufficiently in NO samples + slider = self.selenium.find_element(by=By.NAME, value="minimum_containment") + for i in range(8): + # increase slider by 0.05 * 8 = 0.4. So ends at 0.9. + slider.send_keys(Keys.RIGHT) + self.selenium.find_element( + by=By.XPATH, value=("//input[@type='submit' and @value='Apply']") + ).click() + self.assertIn( + "minimum_containment=0.9", + self.selenium.current_url, ) + table = self.selenium.find_element(by=By.TAG_NAME, value="tbody") + self.assertEqual(table.size["height"], 0) # ---- Viral catalogues ---- # catalogue: ViralCatalogue = self.hf_fixtures.viral_catalogues[0] diff --git a/holofood/urls.py b/holofood/urls.py index 88cfaca..e6dfb3b 100644 --- a/holofood/urls.py +++ b/holofood/urls.py @@ -52,6 +52,7 @@ AnimalListView, AnimalDetailView, ViralCataloguesEmptyStateView, + GenomeDetailView, ) admin.site.site_header = "HoloFood Data Portal Admin" @@ -81,6 +82,11 @@ name="genome_catalogue", ), path("genome-catalogues", GenomeCataloguesView.as_view(), name="genome_catalogues"), + path( + "genome-catalogue/<str:catalogue_pk>/<str:pk>", + GenomeDetailView.as_view(), + name="genome_detail", + ), path( "viral-catalogue/<str:pk>", ViralCatalogueView.as_view(), diff --git a/holofood/views.py b/holofood/views.py index bb29124..f0c05c1 100644 --- a/holofood/views.py +++ b/holofood/views.py @@ -25,6 +25,7 @@ GenomeFilter, ViralFragmentFilter, AnimalFilter, + GenomeSampleContainmentFilter, ) from holofood.models import ( Sample, @@ -303,6 +304,31 @@ def get_redirect_url(self, *args, **kwargs): return reverse("genome_catalogue", kwargs={"pk": catalogue.id}) +class GenomeDetailView(SignpostedDetailView, DetailViewWithPaginatedRelatedList): + model = Genome + context_object_name = "genome" + paginate_by = 10 + template_name = "holofood/pages/genome_detail.html" + filterset_class = GenomeSampleContainmentFilter + + related_name = "samples_containing" + related_ordering = "-containment" + + api_url_name = "api:get_genome" + api_url_args_from_context_path = { + "genome_id": "object.pk", + "genome_catalogue_id": "object.catalogue_id", + } + api_list_url_name = "api:list_genome_catalogues" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["catalogue"] = get_object_or_404( + GenomeCatalogue, id=self.kwargs.get("catalogue_pk") + ) + return context + + class ViralCatalogueView(DetailViewWithPaginatedRelatedList): model = ViralCatalogue context_object_name = "catalogue" diff --git a/static/scss/site.scss b/static/scss/site.scss index f8bdd1c..dd62286 100644 --- a/static/scss/site.scss +++ b/static/scss/site.scss @@ -211,4 +211,10 @@ img.hf-hero-logo { .martor-wrapper > * img { width: 80%; +} + +.vf-form__item { + input[type="range"] { + width: 100%; + } } \ No newline at end of file diff --git a/templates/base.html b/templates/base.html index 65e2c5e..1ba98aa 100644 --- a/templates/base.html +++ b/templates/base.html @@ -207,9 +207,6 @@ <h4 class="vf-links__heading">Data repositories</h4> <li class="vf-list__item"> <a class="vf-list__link" href="https://workflowhub.eu/programmes/28">WorkflowHub</a> </li> - <li class="vf-list__item"> - <a class="vf-list__link" href="https://workflowhub.eu/programmes/28">WorkflowHub</a> - </li> <li class="vf-list__item"> <a class="vf-list__link" href="https://ftp.ebi.ac.uk/pub/databases/metagenomics/holofood_data/">Data portal DB snapshots</a> </li> diff --git a/templates/holofood/pages/genome_catalogue_detail.html b/templates/holofood/pages/genome_catalogue_detail.html index b2acf08..7e84425 100644 --- a/templates/holofood/pages/genome_catalogue_detail.html +++ b/templates/holofood/pages/genome_catalogue_detail.html @@ -39,7 +39,12 @@ <h2>{{ catalogue.title }} MAG catalogue</h2> <tbody class="vf-table__body"> {% for genome in genomes %} <tr class="vf-table__row"> - <td class="vf-table__cell">{{ genome.accession }}</td> + <td class="vf-table__cell"> + <a class="vf-link" + href="{% url 'genome_detail' catalogue_pk=catalogue.id pk=genome.accession %}"> + {{ genome.accession }} + </a> + </td> <td class="vf-table__cell"> <a class="vf-link" href="{{ MGNIFY_WEB_URL }}/genomes/{{ genome.cluster_representative }}">{{ genome.cluster_representative }}</a> diff --git a/templates/holofood/pages/genome_detail.html b/templates/holofood/pages/genome_detail.html new file mode 100644 index 0000000..73bf6d9 --- /dev/null +++ b/templates/holofood/pages/genome_detail.html @@ -0,0 +1,153 @@ +{% extends "base.html" %} +{% load string_utils %} +{% load taxonomy %} +{% load static %} +{% load sample_metadata %} +{% block content %} + <nav class="vf-breadcrumbs" aria-label="Breadcrumb"> + <ul class="vf-breadcrumbs__list | vf-list vf-list--inline"> + <li class="vf-breadcrumbs__item"> + <a href="{% url 'home' %}" class="vf-breadcrumbs__link">Home</a> + </li> + + <li class="vf-breadcrumbs__item"> + <a href="{% url 'genome_catalogues' %}" class="vf-breadcrumbs__link">Genomes</a> + </li> + + <li class="vf-breadcrumbs__item" aria-current="location"> + {{ genome.accession }} + </li> + </ul> + + <span class="vf-breadcrumbs__heading">Related:</span> + + <ul class="vf-breadcrumbs__list vf-breadcrumbs__list--related | vf-list vf-list--inline"> + <li class="vf-breadcrumbs__item"> + <a href="{% url 'genome_catalogue' pk=catalogue.id %}" class="vf-breadcrumbs__link">{{ catalogue.title }} catalogue</a> + </li> + </ul> + + </nav> + <h1>{{ genome.accession }}</h1> + <p class="vf-lede"> + Details for {{ genome.accession }}, a genome assembled from HoloFood {{ catalogue.system }} metagenomes + </p> + <br/> + + <details class="vf-details" open> + <summary class="vf-details--summary"> + Genome details + </summary> + <dl class="vf-list vf-list--definition"> + <dt class="vf-list__item vf-list--definition__term"> + Taxonomy + </dt> + <dd class="vf-list__item vf-list--definition__details"> + {% taxonomy_tooltip taxonomy=genome.taxonomy %} + </dd> + <dt class="vf-list__item vf-list--definition__term"> + Cluster representative + </dt> + <dd class="vf-list__item vf-list--definition__details"> + <p class="vf-text-body--2"> + {{ genome.cluster_representative }} + </p> + <p class="vf-text-body--5"> + Genomes in MGnify Genome Catalogues are clustered by sequence similarity, at the species level. + A cluster representative genome is chosen as the best quality genome for each cluster. + This representative may or may not be from HoloFood data. + </p> + <a class="vf-button vf-button--link vf-button--sm inline" + href="{{ MGNIFY_WEB_URL }}/genomes/{{ genome.cluster_representative }}"><i class="icon icon-common icon-external-link-square-alt"></i> View {{ genome.cluster_representative }} on MGnify</a> + <br/> + <a class="vf-button vf-button--link vf-button--sm inline" + href="{% url 'genome_catalogue' pk=genome.catalogue_id %}?cluster_representative__icontains={{ genome.cluster_representative }}"><i class="icon icon-common icon-search"></i> Show other genomes in cluster</a> + + </dd> + <dt class="vf-list__item vf-list--definition__term"> + API endpoint + </dt> + {% url 'api:get_genome' genome_catalogue_id=genome.catalogue_id genome_id=genome.accession as endpoint %} + <dd class="vf-list__item vf-list--definition__details"> + <span class="api-endpoint"> + <a class="vf-link" href="{{ endpoint }}">{{ endpoint }}</a> + <span class="tooltiped"> + <button class="vf-button vf-button--link vf-button--sm inline" + onclick="copyApiEndpoint()"> + <img src="{% static 'img/icons/copy.svg' %}" height="16px" alt="Copy"/> + </button> + <span id="endpoint-copy-tooltip" class="tooltip-text">Copy API Endpoint</span> + </span> + + </span> + </dd> + </dl> + </details> + <script> + function copyApiEndpoint(e) { + navigator.clipboard.writeText('{{ endpoint }}'); + window.document.getElementById('endpoint-copy-tooltip').textContent = "Copied!"; + } + </script> + <details class="vf-details" open> + <summary class="vf-details--summary"> + Samples containing this genome + </summary> + <p class="vf-text-body--5"> + {{ genome.accession }}’s cluster representative ({{ genome.cluster_representative }}) + has been searched for in all HoloFood samples, using a <a href="https://sourmash.bio">sourmash</a>-based tool. + These samples contain some or all of the kmers in {{ genome.cluster_representative }}’s sequence. + Because {{ genome.accession }} has been clustered with {{ genome.cluster_representative }} + at 95% sequence similarity, this indicates that these samples are likely to contain {{ genome.accession }}. + </p> + <div class="vf-sidebar vf-sidebar--start vf-sidebar--800"> + <div class="vf-sidebar__inner"> + <div> + <h2>Filter containing samples</h2> + {% include "holofood/components/form.html" with form=filterset.form method="get" submit="Apply" id="genome_sample_containments_filters" only %} + </div> + <div style="overflow-x: scroll"> + <h2>Samples containing cluster representative {{ genome.cluster_representative }}</h2> + <a class="vf-button vf-button--link vf-button--sm" + href="{% url 'export:get_samples_containing_genome' genome_catalogue_id=genome.catalogue_id genome_id=genome.pk %}"><i class="icon icon-common icon-download"></i> Download all as TSV</a> + <table class="vf-table scrollable-table"> + <thead class="vf-table__header"> + <tr class="vf-table__row"> + <th class="vf-table__heading" scope="col">Containment</th> + <th class="vf-table__heading" scope="col">Sample accession</th> + <th class="vf-table__heading" scope="col">Animal accession</th> + <th class="vf-table__heading" scope="col">Treatment</th> + </tr> + </thead> + <tbody class="vf-table__body"> + {% for genome_sample_containment in samples_containing %} + <tr class="vf-table__row"> + <td class="vf-table__cell"> + {{ genome_sample_containment.containment | floatformat:2 }} + </td> + <td class="vf-table__cell"> + <a class="vf-link" + href="{% url 'sample_detail' pk=genome_sample_containment.sample_id %}">{{ genome_sample_containment.sample_id }}</a> + </td> + <td class="vf-table__cell"> + {% include "holofood/components/atoms/animal_accession.html" with animal=genome_sample_containment.sample.animal only %} + </td> + <td class="vf-table__cell"> + {{ genome_sample_containment.sample.animal|animal_metadatum:"Treatment name||Treatment description"|default:"—" }} + </td> + </tr> + {% endfor %} + </tbody> + <tfoot class="vf-table__footer"> + <tr class="vf-table__row"> + <td class="vf-table__cell" colspan="100">{% include "holofood/components/pagination.html" %}</td> + </tr> + </tfoot> + </table> + </div> + </div> + </div> + + </details> + +{% endblock content %}