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>&nbsp;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>&nbsp;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>&nbsp;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 %}