From cc02d85de3e98a61babce5d8964f828423259aee Mon Sep 17 00:00:00 2001 From: Tamara El Naboulsi Date: Tue, 29 Sep 2026 14:24:46 +0100 Subject: [PATCH 1/5] Transcriptomic POC - initial commit --- ensembl-track-api.openapi.yaml | 67 +++++++++ .../seed_transcriptomic_configuration.py | 129 +++++++++++++++++ ...04_transcriptomicconfiguration_and_more.py | 70 +++++++++ tracks/models.py | 24 +++ tracks/transcriptomic.py | 82 +++++++++++ tracks/urls.py | 5 + tracks/views.py | 137 ++++++++++++++++-- 7 files changed, 504 insertions(+), 10 deletions(-) create mode 100644 tracks/management/commands/seed_transcriptomic_configuration.py create mode 100644 tracks/migrations/0004_transcriptomicconfiguration_and_more.py create mode 100644 tracks/transcriptomic.py diff --git a/ensembl-track-api.openapi.yaml b/ensembl-track-api.openapi.yaml index 3226cef..fd6b242 100644 --- a/ensembl-track-api.openapi.yaml +++ b/ensembl-track-api.openapi.yaml @@ -26,6 +26,64 @@ servers: - url: http://www.ensembl.org/api/tracks description: Production server paths: + /transcriptomic/{genome_id}/configuration: + get: + summary: Returns prepared transcriptomic selections and static filter counts. + description: > + Resolved through the same dataset/release selection as track_categories, or + pinned with dataset_id. Counts are occurrences across + handover records, grouped case-insensitively, and do not change with + selection. Source metadata is preserved. Each product includes a track_id + referencing the registered track. Rendering settings may not yet be configured. + parameters: + - name: genome_id + in: path + required: true + schema: + type: string + format: uuid + - name: dataset_id + in: query + description: Pin a registered dataset for this genome; mutually exclusive with release. + schema: + type: string + format: uuid + - name: release + in: query + description: Select the catalogue as of this release; defaults to latest when neither parameter is given. + schema: + type: string + format: date + responses: + '400': + description: Invalid dataset UUID or conflicting query parameters. + '200': + description: Prepared coverage catalogue. + content: + application/json: + schema: + type: object + properties: + track_count: + type: integer + filters: + type: object + additionalProperties: + type: array + items: + type: object + properties: + value: + type: string + count: + type: integer + selections: + type: array + items: + type: object + additionalProperties: true + '404': + description: No coverage configuration for the requested genome. /track_categories/{genome_id}: get: summary: Returns all track categories (and tracks) for a given genome at a specific release. @@ -33,6 +91,8 @@ paths: Returns tracks filtered by release date and browser type. Automatically deduplicates tracks with overlapping specifications, keeping only the most recent version of each track type. + Configured transcriptomic categories use the same release selection and + return a dataset-pinned configuration link with an empty track_list. parameters: - name: genome_id in: path @@ -251,6 +311,13 @@ components: TrackCategory: type: object properties: + configuration: + type: object + description: Optional discovery entry point; enables categories with an empty track_list. + properties: + href: + type: string + format: uri track_category_id: type: string example: genes-transcripts diff --git a/tracks/management/commands/seed_transcriptomic_configuration.py b/tracks/management/commands/seed_transcriptomic_configuration.py new file mode 100644 index 0000000..3ea70d2 --- /dev/null +++ b/tracks/management/commands/seed_transcriptomic_configuration.py @@ -0,0 +1,129 @@ +"""Register the coverage pilot and its prepared discovery configuration.""" + +import json +from datetime import datetime +from pathlib import Path +import uuid + +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction + +from tracks.models import Category, DatasetRelease, Specifications, Track, TranscriptomicConfiguration +from tracks.transcriptomic import prepare_configuration + + +class Command(BaseCommand): + help = "Seed run-level coverage tracks and static counts from the Genebuild JSON." + + def add_arguments(self, parser): + parser.add_argument("--genome-id", required=True, type=uuid.UUID) + parser.add_argument("--records", required=True, type=Path) + parser.add_argument("--release", required=True, help="Release label, YYYY-MM-DD") + parser.add_argument("--dataset-id", type=uuid.UUID, + help="Existing dataset for an identical retry; omit to create a new version.") + + def handle(self, *args, **options): + try: + records = json.loads(options["records"].read_text(encoding="utf-8-sig")) + prepared = prepare_configuration(records) + except (OSError, ValueError) as error: + raise CommandError(str(error)) from error + + # Validate options + genome_id = options["genome_id"] + + release = options["release"] + for date_format in ("%Y-%m", "%Y-%m-%d"): + try: + parsed = datetime.strptime(release, date_format) + except ValueError: + continue + + # Require the exact, zero-padded format. + if parsed.strftime(date_format) == release: + break + else: + raise CommandError( + "--release must use YYYY-MM or YYYY-MM-DD." + ) + + dataset_id = options.get("dataset_id") or uuid.uuid4() + for record in records: + supplied_uuid = record["target_genome"].get("genome_uuid") + if supplied_uuid and supplied_uuid != str(genome_id): + raise CommandError("The supplied genome UUID conflicts with the handover.") + + # Seed the configuration and tracks + with transaction.atomic(): + category, _ = Category.objects.get_or_create( + track_category_id="transcriptomic", + defaults={"label": "Transcriptomic data", "type": "Genomic"}, + ) + + spec, _ = Specifications.objects.get_or_create( + name="rnaseq-coverage-genomebrowser", + defaults={ + "label": "RNA-seq coverage", "category": category, + "browser": "GenomeBrowser", "type": "regular", + "discovery_mode": "configured", "files": ["rnaseq_coverage"], + "trigger": [], "settings": {}, "on_by_default": False, + "description": "Run-level RNA-seq coverage supplied by Genebuild.", + }, + ) + if (spec.category_id != category.pk or spec.browser != "GenomeBrowser" + or spec.discovery_mode != "configured" or spec.files != ["rnaseq_coverage"]): + raise CommandError("Existing coverage specification conflicts with this importer.") + + config, _ = TranscriptomicConfiguration.objects.get_or_create( + genome_id=genome_id, dataset_id=dataset_id, defaults={"specification": spec}, + ) + if config.specification_id != spec.pk: + raise CommandError("This genome already has a different configured specification.") + + # The existing selector has no tie-breaker for competing versions in one release. + competing = DatasetRelease.objects.filter( + genome_id=genome_id, release_label=release, + dataset_id__in=Track.objects.filter( + genome_id=genome_id, specifications=spec, + ).values("dataset_id"), + ).exclude(dataset_id=dataset_id) + if competing.exists(): + raise CommandError("A coverage dataset already exists for this release; retry with its --dataset-id.") + + for record in prepared["selections"]: + record["track_products"][0]["track_id"] = str(uuid.uuid5( + config.dataset_id, record["selection_id"] + ":rnaseq_coverage" + )) + + if config.configuration and config.configuration != prepared: + raise CommandError("Dataset content differs. Omit --dataset-id to create a new version at a new release.") + + created_count = 0 + for record in prepared["selections"]: + product = record["track_products"][0] + + # Retained catalogue UUID gives stable track IDs across repeated seeds. + track_id = uuid.uuid5(config.dataset_id, record["selection_id"] + ":rnaseq_coverage") + track, created = Track.objects.update_or_create( + track_id=track_id, + defaults={ + "genome_id": genome_id, "dataset_id": config.dataset_id, + "datafiles": {"rnaseq_coverage": product["track_file"]}, + }, + ) + track.specifications.add(spec) + product["track_id"] = str(track.track_id) + created_count += int(created) + + config.configuration = prepared + config.track_count = prepared["track_count"] + config.save(update_fields=["configuration", "track_count"]) + DatasetRelease.objects.get_or_create( + genome_id=genome_id, dataset_id=config.dataset_id, release_label=release, + ) + + self.stdout.write(self.style.SUCCESS( + f"Seeded {config.track_count} tracks for genome {genome_id} " + f"({created_count} created); dataset {config.dataset_id}; release {release}. " + "File paths are preserved; trigger/settings remain unchanged." + )) diff --git a/tracks/migrations/0004_transcriptomicconfiguration_and_more.py b/tracks/migrations/0004_transcriptomicconfiguration_and_more.py new file mode 100644 index 0000000..5843d45 --- /dev/null +++ b/tracks/migrations/0004_transcriptomicconfiguration_and_more.py @@ -0,0 +1,70 @@ +# Generated by Django 5.2.17 on 2026-09-29 09:26 + +import django.db.models.deletion +import tracks.fields +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tracks', '0003_query_indexes'), + ] + + operations = [ + migrations.CreateModel( + name='TranscriptomicConfiguration', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('genome_id', tracks.fields.HyphenatedUUIDField()), + ('dataset_id', tracks.fields.HyphenatedUUIDField(default=uuid.uuid4, editable=False)), + ('track_count', models.PositiveIntegerField(default=0)), + ('configuration', models.JSONField(default=dict)), + ], + ), + migrations.RenameIndex( + model_name='datasetrelease', + new_name='tracks_data_genome__34d615_idx', + old_name='tracks_release_genome_label_idx', + ), + migrations.RenameIndex( + model_name='track', + new_name='tracks_trac_dataset_95bf62_idx', + old_name='tracks_track_dataset_idx', + ), + migrations.RenameIndex( + model_name='track', + new_name='tracks_trac_genome__f2675d_idx', + old_name='tracks_track_genome_dataset_idx', + ), + migrations.AddField( + model_name='specifications', + name='discovery_mode', + field=models.CharField(choices=[('inline', 'Inline'), ('configured', 'Configured')], default='inline', max_length=20), + ), + migrations.AlterField( + model_name='specifications', + name='browser', + field=models.CharField(choices=[('GenomeBrowser', 'GenomeBrowser'), ('StructuralVariant', 'StructuralVariant')], max_length=20), + ), + migrations.AlterField( + model_name='specifications', + name='strand', + field=models.CharField(blank=True, choices=[('forward', 'forward'), ('reverse', 'reverse')], max_length=20, null=True), + ), + migrations.AlterField( + model_name='specifications', + name='type', + field=models.CharField(choices=[('gene', 'gene'), ('variant', 'variant'), ('regular', 'regular')], max_length=8), + ), + migrations.AddField( + model_name='transcriptomicconfiguration', + name='specification', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='tracks.specifications'), + ), + migrations.AddConstraint( + model_name='transcriptomicconfiguration', + constraint=models.UniqueConstraint(fields=('genome_id', 'dataset_id'), name='unique_transcriptomic_genome_dataset'), + ), + ] diff --git a/tracks/models.py b/tracks/models.py index 5a282fe..1c76028 100644 --- a/tracks/models.py +++ b/tracks/models.py @@ -54,6 +54,10 @@ class BrowserType(models.TextChoices): GENOME_BROWSER = "GenomeBrowser", "GenomeBrowser" STRUCTURAL_VARIANT = "StructuralVariant", "StructuralVariant" + class DiscoveryMode(models.TextChoices): + INLINE = "inline", "Inline" + CONFIGURED = "configured", "Configured" + name = models.CharField(max_length=50, unique=True) label = models.CharField(max_length=50) @@ -89,6 +93,10 @@ class BrowserType(models.TextChoices): max_length=20, ) + discovery_mode = models.CharField( + max_length=20, choices=DiscoveryMode.choices, default=DiscoveryMode.INLINE, + ) + class Track(models.Model): specifications: models.ManyToManyField = models.ManyToManyField( @@ -144,3 +152,19 @@ class Meta: indexes: ClassVar = [ models.Index(fields=["genome_id", "-release_label"]), ] + + +class TranscriptomicConfiguration(models.Model): + genome_id = HyphenatedUUIDField() + dataset_id = HyphenatedUUIDField(default=uuid.uuid4, editable=False) + specification = models.ForeignKey(Specifications, on_delete=models.PROTECT) + track_count = models.PositiveIntegerField(default=0) + configuration = models.JSONField(default=dict) + + class Meta: + constraints: ClassVar = [ + models.UniqueConstraint( + fields=["genome_id", "dataset_id"], + name="unique_transcriptomic_genome_dataset", + ), + ] diff --git a/tracks/transcriptomic.py b/tracks/transcriptomic.py new file mode 100644 index 0000000..669618d --- /dev/null +++ b/tracks/transcriptomic.py @@ -0,0 +1,82 @@ +"""Preparation of the current run-level RNA-seq coverage handover.""" + +from collections import Counter, defaultdict +from copy import deepcopy +import re + + +def prepare_configuration(records): + """Validate the coverage pilot and count records, preserving source metadata. + + This deliberately accepts the current string-valued run-level handover. + New selection levels and metadata shapes require an explicit extension. + """ + if not isinstance(records, list) or not records: + raise ValueError("Expected a non-empty list of selection records.") + + identifiers = set() + assemblies = set() + counts = defaultdict(Counter) + + for index, record in enumerate(records): + if not isinstance(record, dict): + raise ValueError(f"Record {index} must be an object.") + for key in ("selection_id", "parent_sample_id", "display_label"): + if not isinstance(record.get(key), str) or not record[key]: + raise ValueError(f"Record {index}: missing or invalid {key}.") + + selection_id = record["selection_id"] + if selection_id in identifiers: + raise ValueError(f"Duplicate selection_id: {selection_id}") + + identifiers.add(selection_id) + if (record.get("schema_version") != "transcriptomic-selection-0.1" + or record.get("selection_level") != "run" + or record.get("data_type") != "rnaseq"): + raise ValueError(f"{selection_id}: expected a current run-level RNA-seq record.") + + genome = record.get("target_genome") + if not isinstance(genome, dict) or not all( + isinstance(genome.get(key), str) and genome[key] + for key in ("species", "assembly") + ): + raise ValueError(f"{selection_id}: missing target species/assembly.") + + assemblies.add(genome["assembly"]) + provenance = record.get("provenance", {}) + if (not isinstance(provenance, dict) + or provenance.get("biosample_accession") != record["parent_sample_id"] + or provenance.get("run_accessions") != [selection_id]): + raise ValueError(f"{selection_id}: inconsistent sample/run provenance.") + + products = record.get("track_products") + if not isinstance(products, list) or len(products) != 1: + raise ValueError(f"{selection_id}: expected one coverage product.") + product = products[0] + if not isinstance(product, dict) or ( + product.get("track_type"), product.get("format"), product.get("status") + ) != ("rnaseq_coverage", "BigWig", "available"): + raise ValueError(f"{selection_id}: expected an available coverage BigWig.") + if not isinstance(product.get("track_file"), str) or not product["track_file"]: + raise ValueError(f"{selection_id}: missing track_file.") + if not isinstance(product.get("md5"), str) or not re.fullmatch(r"[0-9a-f]{32}", product["md5"]): + raise ValueError(f"{selection_id}: missing or invalid md5.") + + metadata = record.get("metadata") + if not isinstance(metadata, dict) or any(not isinstance(v, str) for v in metadata.values()): + raise ValueError(f"{selection_id}: this importer requires string metadata values.") + + for key, value in metadata.items(): + counts[key][value.casefold()] += 1 + + if len(assemblies) != 1: + raise ValueError("One genome configuration must contain exactly one assembly.") + + return { + "track_count": len(records), + "filters": { + key: [{"value": value, "count": count} for value, count in sorted(values.items())] + for key, values in sorted(counts.items()) + }, + "selections": deepcopy(records), + } diff --git a/tracks/urls.py b/tracks/urls.py index dfea7fe..6b5fa61 100644 --- a/tracks/urls.py +++ b/tracks/urls.py @@ -34,4 +34,9 @@ # New ingress endpoints path("tracks/create", views.CreateTrack.as_view(), name="create_track"), path("tracks/link_type", views.LinkTypeToTrack.as_view(), name="link_type"), + path( + "transcriptomic//configuration", + views.TranscriptomicConfigurationView.as_view(), + name="transcriptomic_configuration", + ), ] diff --git a/tracks/views.py b/tracks/views.py index 9835e78..9ba028c 100644 --- a/tracks/views.py +++ b/tracks/views.py @@ -17,6 +17,9 @@ from collections import defaultdict from typing import TypedDict from uuid import UUID +from urllib.parse import urlencode + +from django.urls import reverse from django.db import IntegrityError from django.db.models import Prefetch @@ -24,7 +27,7 @@ from rest_framework.response import Response from rest_framework.views import APIView -from tracks.models import DatasetRelease, Specifications, Track +from tracks.models import DatasetRelease, Specifications, Track, TranscriptomicConfiguration from tracks.serializers import ( CategorySerializer, CreateTrackSerializer, @@ -239,6 +242,41 @@ def combine_track_and_specification( return data +def get_transcriptomic_category(request, genome_id: str, browser: str, dataset_ids): + if browser != "GenomeBrowser": + return None + + configuration = TranscriptomicConfiguration.objects.filter( + genome_id=genome_id, + dataset_id__in=dataset_ids, + track_count__gt=0, + specification__browser=browser, + specification__discovery_mode="configured", + ).select_related("specification__category").defer("configuration").first() + + if configuration is None or not Track.objects.filter( + genome_id=genome_id, + dataset_id=configuration.dataset_id, + specifications=configuration.specification, + ).exists(): + return None + + configuration_path = reverse( + "tracks:transcriptomic_configuration", + kwargs={"genome_id": genome_id}, + ) + + return { + **CategorySerializer(configuration.specification.category).data, + "track_list": [], + "configuration": { + "href": request.build_absolute_uri(configuration_path) + "?" + urlencode( + {"dataset_id": str(configuration.dataset_id)} + ), + }, + } + + # ── Views ───────────────────────────────────────────────────────────────────── @@ -260,19 +298,20 @@ class GenomeTrackList(APIView): def get(self, request, genome_id): browser = request.query_params.get("browser", "GenomeBrowser") release_param = request.query_params.get("release") + # Validate browser if browser not in ["GenomeBrowser", "StructuralVariant"]: return Response( {"error": "browser must be 'GenomeBrowser' or 'StructuralVariant'"}, status=status.HTTP_400_BAD_REQUEST, ) + try: # Step 1: Determine target release target_release = get_target_release(genome_id, release_param) # Step 2: Get all datasets up to target release datasets = get_datasets_up_to_release(genome_id, target_release) - if not datasets: return Response( {"error": "No datasets found for this genome and release."}, @@ -289,14 +328,14 @@ def get(self, request, genome_id): # Step 5: Select latest dataset from each bin selected_dataset_ids = select_latest_dataset_from_bins(bins) - # Step 6: Get all tracks from selected datasets + # Step 6: Get all inline tracks from selected datasets tracks = Track.objects.filter( genome_id=genome_id, dataset_id__in=selected_dataset_ids ).prefetch_related( Prefetch( "specifications", queryset=Specifications.objects.filter( - browser=browser + browser=browser, discovery_mode="inline" ).select_related("category"), to_attr="browser_specifications", ), @@ -310,7 +349,7 @@ def get(self, request, genome_id): status=status.HTTP_404_NOT_FOUND, ) - # Step 7: For each track, get specification and group by category + # Step 7: For each inline track, get specification and group by category categories = {} for track in tracks: @@ -335,17 +374,32 @@ def get(self, request, genome_id): track_data = combine_track_and_specification(track, spec) categories[category_id]["track_list"].append(track_data) + # Sort track_list by display_order within each category + for cat_data in categories.values(): + cat_data["track_list"].sort(key=lambda x: x["display_order"]) + + # Step 8: Get transcriptomic category + configured_category = get_transcriptomic_category(request, genome_id, browser, selected_dataset_ids) + if configured_category is not None: + existing = next( + ( + category for category in categories.values() + if category["track_category_id"] == configured_category["track_category_id"] + ), + None, + ) + if existing is None: + categories[configured_category["track_category_id"]] = (configured_category) + else: + existing["configuration"] = configured_category["configuration"] + if not categories: return Response( {"error": "No tracks found for this genome."}, status=status.HTTP_404_NOT_FOUND, ) - # Sort track_list by display_order within each category - for cat_data in categories.values(): - cat_data["track_list"].sort(key=lambda x: x["display_order"]) - - # Step 8: Return + # Step 9: Return return Response( {"track_categories": list(categories.values())}, status=status.HTTP_200_OK, @@ -512,3 +566,66 @@ def post(self, request): {"error": "Validation failed", "details": serializer.errors}, status=status.HTTP_400_BAD_REQUEST, ) + + +class TranscriptomicConfigurationView(APIView): + """ + Return the prepared catalogue for a pinned dataset or resolved release. + """ + + http_method_names: list[str] = ["get"] # noqa: RUF012 + + @redis_cache( + "transcriptomic_configuration", + params=(("dataset_id", None), ("release", None)), + ) + def get(self, request, genome_id): + dataset_param = request.query_params.get("dataset_id") + release_param = request.query_params.get("release") + if dataset_param is not None and release_param is not None: + return Response({"error": "Use dataset_id or release, not both."}, status=400) + if dataset_param is not None: + try: + dataset_id = UUID(dataset_param) + except ValueError: + return Response({"error": "dataset_id must be a UUID."}, status=400) + # A pinned link keeps resolving its historical configuration after a new release. + dataset_ids = list(DatasetRelease.objects.filter( + genome_id=genome_id, dataset_id=dataset_id, + ).values_list("dataset_id", flat=True)) + else: + try: + target_release = get_target_release(genome_id, release_param) + except ValueError: + return Response( + {"error": "No releases found for this genome."}, + status=status.HTTP_404_NOT_FOUND, + ) + + datasets = get_datasets_up_to_release(genome_id, target_release) + if not datasets: + return Response( + {"error": "No datasets found for this genome and release."}, + status=status.HTTP_404_NOT_FOUND, + ) + + dataset_specs = get_specifications_for_datasets( + [dataset["dataset_id"] for dataset in datasets], + "GenomeBrowser", + ) + bins = bin_datasets_by_overlapping_specs(datasets, dataset_specs) + dataset_ids = select_latest_dataset_from_bins(bins) + + configuration = ( + TranscriptomicConfiguration.objects + .filter(genome_id=genome_id, dataset_id__in=dataset_ids, track_count__gt=0, + specification__browser="GenomeBrowser", specification__discovery_mode="configured") + .values_list("configuration", flat=True) + .first() + ) + if configuration is None: + return Response( + {"error": "No transcriptomic configuration for this genome and dataset/release."}, + status=status.HTTP_404_NOT_FOUND, + ) + return Response(configuration) From 5df5d13c6e50bb89eea31e66f257bb25e97b2e4c Mon Sep 17 00:00:00 2001 From: Tamara El Naboulsi Date: Tue, 29 Sep 2026 14:41:44 +0100 Subject: [PATCH 2/5] Fix formatting --- .../seed_transcriptomic_configuration.py | 105 +++++++++++------ ...04_transcriptomicconfiguration_and_more.py | 106 ++++++++++++------ tracks/models.py | 4 +- tracks/transcriptomic.py | 41 +++++-- tracks/views.py | 88 ++++++++++----- 5 files changed, 237 insertions(+), 107 deletions(-) diff --git a/tracks/management/commands/seed_transcriptomic_configuration.py b/tracks/management/commands/seed_transcriptomic_configuration.py index 3ea70d2..2707848 100644 --- a/tracks/management/commands/seed_transcriptomic_configuration.py +++ b/tracks/management/commands/seed_transcriptomic_configuration.py @@ -8,7 +8,13 @@ from django.core.management.base import BaseCommand, CommandError from django.db import transaction -from tracks.models import Category, DatasetRelease, Specifications, Track, TranscriptomicConfiguration +from tracks.models import ( + Category, + DatasetRelease, + Specifications, + Track, + TranscriptomicConfiguration, +) from tracks.transcriptomic import prepare_configuration @@ -18,9 +24,14 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument("--genome-id", required=True, type=uuid.UUID) parser.add_argument("--records", required=True, type=Path) - parser.add_argument("--release", required=True, help="Release label, YYYY-MM-DD") - parser.add_argument("--dataset-id", type=uuid.UUID, - help="Existing dataset for an identical retry; omit to create a new version.") + parser.add_argument( + "--release", required=True, help="Release label, YYYY-MM-DD" + ) + parser.add_argument( + "--dataset-id", + type=uuid.UUID, + help="Existing dataset for an identical retry; omit to create a new version.", + ) def handle(self, *args, **options): try: @@ -43,15 +54,15 @@ def handle(self, *args, **options): if parsed.strftime(date_format) == release: break else: - raise CommandError( - "--release must use YYYY-MM or YYYY-MM-DD." - ) + raise CommandError("--release must use YYYY-MM or YYYY-MM-DD.") dataset_id = options.get("dataset_id") or uuid.uuid4() for record in records: supplied_uuid = record["target_genome"].get("genome_uuid") if supplied_uuid and supplied_uuid != str(genome_id): - raise CommandError("The supplied genome UUID conflicts with the handover.") + raise CommandError( + "The supplied genome UUID conflicts with the handover." + ) # Seed the configuration and tracks with transaction.atomic(): @@ -63,51 +74,77 @@ def handle(self, *args, **options): spec, _ = Specifications.objects.get_or_create( name="rnaseq-coverage-genomebrowser", defaults={ - "label": "RNA-seq coverage", "category": category, - "browser": "GenomeBrowser", "type": "regular", - "discovery_mode": "configured", "files": ["rnaseq_coverage"], - "trigger": [], "settings": {}, "on_by_default": False, + "label": "RNA-seq coverage", + "category": category, + "browser": "GenomeBrowser", + "type": "regular", + "discovery_mode": "configured", + "files": ["rnaseq_coverage"], + "trigger": [], + "settings": {}, + "on_by_default": False, "description": "Run-level RNA-seq coverage supplied by Genebuild.", }, ) - if (spec.category_id != category.pk or spec.browser != "GenomeBrowser" - or spec.discovery_mode != "configured" or spec.files != ["rnaseq_coverage"]): - raise CommandError("Existing coverage specification conflicts with this importer.") + if ( + spec.category_id != category.pk + or spec.browser != "GenomeBrowser" + or spec.discovery_mode != "configured" + or spec.files != ["rnaseq_coverage"] + ): + raise CommandError( + "Existing coverage specification conflicts with this importer." + ) config, _ = TranscriptomicConfiguration.objects.get_or_create( - genome_id=genome_id, dataset_id=dataset_id, defaults={"specification": spec}, + genome_id=genome_id, + dataset_id=dataset_id, + defaults={"specification": spec}, ) if config.specification_id != spec.pk: - raise CommandError("This genome already has a different configured specification.") + raise CommandError( + "This genome already has a different configured specification." + ) # The existing selector has no tie-breaker for competing versions in one release. competing = DatasetRelease.objects.filter( - genome_id=genome_id, release_label=release, + genome_id=genome_id, + release_label=release, dataset_id__in=Track.objects.filter( - genome_id=genome_id, specifications=spec, + genome_id=genome_id, + specifications=spec, ).values("dataset_id"), ).exclude(dataset_id=dataset_id) if competing.exists(): - raise CommandError("A coverage dataset already exists for this release; retry with its --dataset-id.") + raise CommandError( + "A coverage dataset already exists for this release; retry with its --dataset-id." + ) for record in prepared["selections"]: - record["track_products"][0]["track_id"] = str(uuid.uuid5( - config.dataset_id, record["selection_id"] + ":rnaseq_coverage" - )) + record["track_products"][0]["track_id"] = str( + uuid.uuid5( + config.dataset_id, record["selection_id"] + ":rnaseq_coverage" + ) + ) if config.configuration and config.configuration != prepared: - raise CommandError("Dataset content differs. Omit --dataset-id to create a new version at a new release.") + raise CommandError( + "Dataset content differs. Omit --dataset-id to create a new version at a new release." + ) created_count = 0 for record in prepared["selections"]: product = record["track_products"][0] # Retained catalogue UUID gives stable track IDs across repeated seeds. - track_id = uuid.uuid5(config.dataset_id, record["selection_id"] + ":rnaseq_coverage") + track_id = uuid.uuid5( + config.dataset_id, record["selection_id"] + ":rnaseq_coverage" + ) track, created = Track.objects.update_or_create( track_id=track_id, defaults={ - "genome_id": genome_id, "dataset_id": config.dataset_id, + "genome_id": genome_id, + "dataset_id": config.dataset_id, "datafiles": {"rnaseq_coverage": product["track_file"]}, }, ) @@ -119,11 +156,15 @@ def handle(self, *args, **options): config.track_count = prepared["track_count"] config.save(update_fields=["configuration", "track_count"]) DatasetRelease.objects.get_or_create( - genome_id=genome_id, dataset_id=config.dataset_id, release_label=release, + genome_id=genome_id, + dataset_id=config.dataset_id, + release_label=release, ) - self.stdout.write(self.style.SUCCESS( - f"Seeded {config.track_count} tracks for genome {genome_id} " - f"({created_count} created); dataset {config.dataset_id}; release {release}. " - "File paths are preserved; trigger/settings remain unchanged." - )) + self.stdout.write( + self.style.SUCCESS( + f"Seeded {config.track_count} tracks for genome {genome_id} " + f"({created_count} created); dataset {config.dataset_id}; release {release}. " + "File paths are preserved; trigger/settings remain unchanged." + ) + ) diff --git a/tracks/migrations/0004_transcriptomicconfiguration_and_more.py b/tracks/migrations/0004_transcriptomicconfiguration_and_more.py index 5843d45..142e10c 100644 --- a/tracks/migrations/0004_transcriptomicconfiguration_and_more.py +++ b/tracks/migrations/0004_transcriptomicconfiguration_and_more.py @@ -9,62 +9,102 @@ class Migration(migrations.Migration): dependencies = [ - ('tracks', '0003_query_indexes'), + ("tracks", "0003_query_indexes"), ] operations = [ migrations.CreateModel( - name='TranscriptomicConfiguration', + name="TranscriptomicConfiguration", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('genome_id', tracks.fields.HyphenatedUUIDField()), - ('dataset_id', tracks.fields.HyphenatedUUIDField(default=uuid.uuid4, editable=False)), - ('track_count', models.PositiveIntegerField(default=0)), - ('configuration', models.JSONField(default=dict)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("genome_id", tracks.fields.HyphenatedUUIDField()), + ( + "dataset_id", + tracks.fields.HyphenatedUUIDField( + default=uuid.uuid4, editable=False + ), + ), + ("track_count", models.PositiveIntegerField(default=0)), + ("configuration", models.JSONField(default=dict)), ], ), migrations.RenameIndex( - model_name='datasetrelease', - new_name='tracks_data_genome__34d615_idx', - old_name='tracks_release_genome_label_idx', + model_name="datasetrelease", + new_name="tracks_data_genome__34d615_idx", + old_name="tracks_release_genome_label_idx", ), migrations.RenameIndex( - model_name='track', - new_name='tracks_trac_dataset_95bf62_idx', - old_name='tracks_track_dataset_idx', + model_name="track", + new_name="tracks_trac_dataset_95bf62_idx", + old_name="tracks_track_dataset_idx", ), migrations.RenameIndex( - model_name='track', - new_name='tracks_trac_genome__f2675d_idx', - old_name='tracks_track_genome_dataset_idx', + model_name="track", + new_name="tracks_trac_genome__f2675d_idx", + old_name="tracks_track_genome_dataset_idx", ), migrations.AddField( - model_name='specifications', - name='discovery_mode', - field=models.CharField(choices=[('inline', 'Inline'), ('configured', 'Configured')], default='inline', max_length=20), + model_name="specifications", + name="discovery_mode", + field=models.CharField( + choices=[("inline", "Inline"), ("configured", "Configured")], + default="inline", + max_length=20, + ), ), migrations.AlterField( - model_name='specifications', - name='browser', - field=models.CharField(choices=[('GenomeBrowser', 'GenomeBrowser'), ('StructuralVariant', 'StructuralVariant')], max_length=20), + model_name="specifications", + name="browser", + field=models.CharField( + choices=[ + ("GenomeBrowser", "GenomeBrowser"), + ("StructuralVariant", "StructuralVariant"), + ], + max_length=20, + ), ), migrations.AlterField( - model_name='specifications', - name='strand', - field=models.CharField(blank=True, choices=[('forward', 'forward'), ('reverse', 'reverse')], max_length=20, null=True), + model_name="specifications", + name="strand", + field=models.CharField( + blank=True, + choices=[("forward", "forward"), ("reverse", "reverse")], + max_length=20, + null=True, + ), ), migrations.AlterField( - model_name='specifications', - name='type', - field=models.CharField(choices=[('gene', 'gene'), ('variant', 'variant'), ('regular', 'regular')], max_length=8), + model_name="specifications", + name="type", + field=models.CharField( + choices=[ + ("gene", "gene"), + ("variant", "variant"), + ("regular", "regular"), + ], + max_length=8, + ), ), migrations.AddField( - model_name='transcriptomicconfiguration', - name='specification', - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='tracks.specifications'), + model_name="transcriptomicconfiguration", + name="specification", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, to="tracks.specifications" + ), ), migrations.AddConstraint( - model_name='transcriptomicconfiguration', - constraint=models.UniqueConstraint(fields=('genome_id', 'dataset_id'), name='unique_transcriptomic_genome_dataset'), + model_name="transcriptomicconfiguration", + constraint=models.UniqueConstraint( + fields=("genome_id", "dataset_id"), + name="unique_transcriptomic_genome_dataset", + ), ), ] diff --git a/tracks/models.py b/tracks/models.py index 1c76028..7a94765 100644 --- a/tracks/models.py +++ b/tracks/models.py @@ -94,7 +94,9 @@ class DiscoveryMode(models.TextChoices): ) discovery_mode = models.CharField( - max_length=20, choices=DiscoveryMode.choices, default=DiscoveryMode.INLINE, + max_length=20, + choices=DiscoveryMode.choices, + default=DiscoveryMode.INLINE, ) diff --git a/tracks/transcriptomic.py b/tracks/transcriptomic.py index 669618d..ab23426 100644 --- a/tracks/transcriptomic.py +++ b/tracks/transcriptomic.py @@ -30,10 +30,14 @@ def prepare_configuration(records): raise ValueError(f"Duplicate selection_id: {selection_id}") identifiers.add(selection_id) - if (record.get("schema_version") != "transcriptomic-selection-0.1" - or record.get("selection_level") != "run" - or record.get("data_type") != "rnaseq"): - raise ValueError(f"{selection_id}: expected a current run-level RNA-seq record.") + if ( + record.get("schema_version") != "transcriptomic-selection-0.1" + or record.get("selection_level") != "run" + or record.get("data_type") != "rnaseq" + ): + raise ValueError( + f"{selection_id}: expected a current run-level RNA-seq record." + ) genome = record.get("target_genome") if not isinstance(genome, dict) or not all( @@ -44,9 +48,11 @@ def prepare_configuration(records): assemblies.add(genome["assembly"]) provenance = record.get("provenance", {}) - if (not isinstance(provenance, dict) - or provenance.get("biosample_accession") != record["parent_sample_id"] - or provenance.get("run_accessions") != [selection_id]): + if ( + not isinstance(provenance, dict) + or provenance.get("biosample_accession") != record["parent_sample_id"] + or provenance.get("run_accessions") != [selection_id] + ): raise ValueError(f"{selection_id}: inconsistent sample/run provenance.") products = record.get("track_products") @@ -54,17 +60,25 @@ def prepare_configuration(records): raise ValueError(f"{selection_id}: expected one coverage product.") product = products[0] if not isinstance(product, dict) or ( - product.get("track_type"), product.get("format"), product.get("status") + product.get("track_type"), + product.get("format"), + product.get("status"), ) != ("rnaseq_coverage", "BigWig", "available"): raise ValueError(f"{selection_id}: expected an available coverage BigWig.") if not isinstance(product.get("track_file"), str) or not product["track_file"]: raise ValueError(f"{selection_id}: missing track_file.") - if not isinstance(product.get("md5"), str) or not re.fullmatch(r"[0-9a-f]{32}", product["md5"]): + if not isinstance(product.get("md5"), str) or not re.fullmatch( + r"[0-9a-f]{32}", product["md5"] + ): raise ValueError(f"{selection_id}: missing or invalid md5.") metadata = record.get("metadata") - if not isinstance(metadata, dict) or any(not isinstance(v, str) for v in metadata.values()): - raise ValueError(f"{selection_id}: this importer requires string metadata values.") + if not isinstance(metadata, dict) or any( + not isinstance(v, str) for v in metadata.values() + ): + raise ValueError( + f"{selection_id}: this importer requires string metadata values." + ) for key, value in metadata.items(): counts[key][value.casefold()] += 1 @@ -75,7 +89,10 @@ def prepare_configuration(records): return { "track_count": len(records), "filters": { - key: [{"value": value, "count": count} for value, count in sorted(values.items())] + key: [ + {"value": value, "count": count} + for value, count in sorted(values.items()) + ] for key, values in sorted(counts.items()) }, "selections": deepcopy(records), diff --git a/tracks/views.py b/tracks/views.py index 9ba028c..f0a4fdd 100644 --- a/tracks/views.py +++ b/tracks/views.py @@ -27,7 +27,12 @@ from rest_framework.response import Response from rest_framework.views import APIView -from tracks.models import DatasetRelease, Specifications, Track, TranscriptomicConfiguration +from tracks.models import ( + DatasetRelease, + Specifications, + Track, + TranscriptomicConfiguration, +) from tracks.serializers import ( CategorySerializer, CreateTrackSerializer, @@ -246,19 +251,27 @@ def get_transcriptomic_category(request, genome_id: str, browser: str, dataset_i if browser != "GenomeBrowser": return None - configuration = TranscriptomicConfiguration.objects.filter( - genome_id=genome_id, - dataset_id__in=dataset_ids, - track_count__gt=0, - specification__browser=browser, - specification__discovery_mode="configured", - ).select_related("specification__category").defer("configuration").first() - - if configuration is None or not Track.objects.filter( - genome_id=genome_id, - dataset_id=configuration.dataset_id, - specifications=configuration.specification, - ).exists(): + configuration = ( + TranscriptomicConfiguration.objects.filter( + genome_id=genome_id, + dataset_id__in=dataset_ids, + track_count__gt=0, + specification__browser=browser, + specification__discovery_mode="configured", + ) + .select_related("specification__category") + .defer("configuration") + .first() + ) + + if ( + configuration is None + or not Track.objects.filter( + genome_id=genome_id, + dataset_id=configuration.dataset_id, + specifications=configuration.specification, + ).exists() + ): return None configuration_path = reverse( @@ -270,9 +283,9 @@ def get_transcriptomic_category(request, genome_id: str, browser: str, dataset_i **CategorySerializer(configuration.specification.category).data, "track_list": [], "configuration": { - "href": request.build_absolute_uri(configuration_path) + "?" + urlencode( - {"dataset_id": str(configuration.dataset_id)} - ), + "href": request.build_absolute_uri(configuration_path) + + "?" + + urlencode({"dataset_id": str(configuration.dataset_id)}), }, } @@ -379,17 +392,23 @@ def get(self, request, genome_id): cat_data["track_list"].sort(key=lambda x: x["display_order"]) # Step 8: Get transcriptomic category - configured_category = get_transcriptomic_category(request, genome_id, browser, selected_dataset_ids) + configured_category = get_transcriptomic_category( + request, genome_id, browser, selected_dataset_ids + ) if configured_category is not None: existing = next( ( - category for category in categories.values() - if category["track_category_id"] == configured_category["track_category_id"] + category + for category in categories.values() + if category["track_category_id"] + == configured_category["track_category_id"] ), None, ) if existing is None: - categories[configured_category["track_category_id"]] = (configured_category) + categories[configured_category["track_category_id"]] = ( + configured_category + ) else: existing["configuration"] = configured_category["configuration"] @@ -583,16 +602,21 @@ def get(self, request, genome_id): dataset_param = request.query_params.get("dataset_id") release_param = request.query_params.get("release") if dataset_param is not None and release_param is not None: - return Response({"error": "Use dataset_id or release, not both."}, status=400) + return Response( + {"error": "Use dataset_id or release, not both."}, status=400 + ) if dataset_param is not None: try: dataset_id = UUID(dataset_param) except ValueError: return Response({"error": "dataset_id must be a UUID."}, status=400) # A pinned link keeps resolving its historical configuration after a new release. - dataset_ids = list(DatasetRelease.objects.filter( - genome_id=genome_id, dataset_id=dataset_id, - ).values_list("dataset_id", flat=True)) + dataset_ids = list( + DatasetRelease.objects.filter( + genome_id=genome_id, + dataset_id=dataset_id, + ).values_list("dataset_id", flat=True) + ) else: try: target_release = get_target_release(genome_id, release_param) @@ -617,15 +641,21 @@ def get(self, request, genome_id): dataset_ids = select_latest_dataset_from_bins(bins) configuration = ( - TranscriptomicConfiguration.objects - .filter(genome_id=genome_id, dataset_id__in=dataset_ids, track_count__gt=0, - specification__browser="GenomeBrowser", specification__discovery_mode="configured") + TranscriptomicConfiguration.objects.filter( + genome_id=genome_id, + dataset_id__in=dataset_ids, + track_count__gt=0, + specification__browser="GenomeBrowser", + specification__discovery_mode="configured", + ) .values_list("configuration", flat=True) .first() ) if configuration is None: return Response( - {"error": "No transcriptomic configuration for this genome and dataset/release."}, + { + "error": "No transcriptomic configuration for this genome and dataset/release." + }, status=status.HTTP_404_NOT_FOUND, ) return Response(configuration) From 93ce2bdf3e5d0ab46128db76982249cdf37b88b4 Mon Sep 17 00:00:00 2001 From: Tamara El Naboulsi Date: Mon, 5 Oct 2026 10:34:11 +0100 Subject: [PATCH 3/5] ruff suggested fixes --- .../seed_transcriptomic_configuration.py | 24 +++++++++---------- ...04_transcriptomicconfiguration_and_more.py | 11 +++++---- tracks/transcriptomic.py | 4 ++-- 3 files changed, 21 insertions(+), 18 deletions(-) diff --git a/tracks/management/commands/seed_transcriptomic_configuration.py b/tracks/management/commands/seed_transcriptomic_configuration.py index 2707848..7a2dbc3 100644 --- a/tracks/management/commands/seed_transcriptomic_configuration.py +++ b/tracks/management/commands/seed_transcriptomic_configuration.py @@ -1,9 +1,10 @@ """Register the coverage pilot and its prepared discovery configuration.""" import json -from datetime import datetime -from pathlib import Path +import re import uuid +from datetime import date +from pathlib import Path from django.core.management.base import BaseCommand, CommandError from django.db import transaction @@ -44,18 +45,17 @@ def handle(self, *args, **options): genome_id = options["genome_id"] release = options["release"] - for date_format in ("%Y-%m", "%Y-%m-%d"): - try: - parsed = datetime.strptime(release, date_format) - except ValueError: - continue - - # Require the exact, zero-padded format. - if parsed.strftime(date_format) == release: - break - else: + if not re.fullmatch(r"[0-9]{4}-[0-9]{2}(?:-[0-9]{2})?", release): raise CommandError("--release must use YYYY-MM or YYYY-MM-DD.") + try: + # Add a day only for calendar validation; preserve the release label. + date.fromisoformat(release if len(release) == 10 else f"{release}-01") + except ValueError as error: + raise CommandError( + "--release must contain a valid year, month and optional day." + ) from error + dataset_id = options.get("dataset_id") or uuid.uuid4() for record in records: supplied_uuid = record["target_genome"].get("genome_uuid") diff --git a/tracks/migrations/0004_transcriptomicconfiguration_and_more.py b/tracks/migrations/0004_transcriptomicconfiguration_and_more.py index 142e10c..1d0138e 100644 --- a/tracks/migrations/0004_transcriptomicconfiguration_and_more.py +++ b/tracks/migrations/0004_transcriptomicconfiguration_and_more.py @@ -1,18 +1,21 @@ # Generated by Django 5.2.17 on 2026-09-29 09:26 -import django.db.models.deletion -import tracks.fields import uuid +from typing import ClassVar + +import django.db.models.deletion from django.db import migrations, models +import tracks.fields + class Migration(migrations.Migration): - dependencies = [ + dependencies: ClassVar = [ ("tracks", "0003_query_indexes"), ] - operations = [ + operations: ClassVar = [ migrations.CreateModel( name="TranscriptomicConfiguration", fields=[ diff --git a/tracks/transcriptomic.py b/tracks/transcriptomic.py index ab23426..103a846 100644 --- a/tracks/transcriptomic.py +++ b/tracks/transcriptomic.py @@ -1,8 +1,8 @@ """Preparation of the current run-level RNA-seq coverage handover.""" +import re from collections import Counter, defaultdict from copy import deepcopy -import re def prepare_configuration(records): @@ -20,7 +20,7 @@ def prepare_configuration(records): for index, record in enumerate(records): if not isinstance(record, dict): - raise ValueError(f"Record {index} must be an object.") + raise TypeError(f"Record {index} must be an object.") for key in ("selection_id", "parent_sample_id", "display_label"): if not isinstance(record.get(key), str) or not record[key]: raise ValueError(f"Record {index}: missing or invalid {key}.") From 66739ef27090ecd3921ce8eee4b0f7258593560f Mon Sep 17 00:00:00 2001 From: Tamara El Naboulsi Date: Mon, 5 Oct 2026 10:34:44 +0100 Subject: [PATCH 4/5] Remove configuration from track_categories result --- ensembl-track-api.openapi.yaml | 9 +-------- tracks/views.py | 19 ++----------------- 2 files changed, 3 insertions(+), 25 deletions(-) diff --git a/ensembl-track-api.openapi.yaml b/ensembl-track-api.openapi.yaml index fd6b242..7df48fe 100644 --- a/ensembl-track-api.openapi.yaml +++ b/ensembl-track-api.openapi.yaml @@ -92,7 +92,7 @@ paths: Automatically deduplicates tracks with overlapping specifications, keeping only the most recent version of each track type. Configured transcriptomic categories use the same release selection and - return a dataset-pinned configuration link with an empty track_list. + return an empty track_list. parameters: - name: genome_id in: path @@ -311,13 +311,6 @@ components: TrackCategory: type: object properties: - configuration: - type: object - description: Optional discovery entry point; enables categories with an empty track_list. - properties: - href: - type: string - format: uri track_category_id: type: string example: genes-transcripts diff --git a/tracks/views.py b/tracks/views.py index f0a4fdd..282a5ab 100644 --- a/tracks/views.py +++ b/tracks/views.py @@ -17,9 +17,6 @@ from collections import defaultdict from typing import TypedDict from uuid import UUID -from urllib.parse import urlencode - -from django.urls import reverse from django.db import IntegrityError from django.db.models import Prefetch @@ -247,7 +244,7 @@ def combine_track_and_specification( return data -def get_transcriptomic_category(request, genome_id: str, browser: str, dataset_ids): +def get_transcriptomic_category(genome_id: str, browser: str, dataset_ids): if browser != "GenomeBrowser": return None @@ -274,19 +271,9 @@ def get_transcriptomic_category(request, genome_id: str, browser: str, dataset_i ): return None - configuration_path = reverse( - "tracks:transcriptomic_configuration", - kwargs={"genome_id": genome_id}, - ) - return { **CategorySerializer(configuration.specification.category).data, "track_list": [], - "configuration": { - "href": request.build_absolute_uri(configuration_path) - + "?" - + urlencode({"dataset_id": str(configuration.dataset_id)}), - }, } @@ -393,7 +380,7 @@ def get(self, request, genome_id): # Step 8: Get transcriptomic category configured_category = get_transcriptomic_category( - request, genome_id, browser, selected_dataset_ids + genome_id, browser, selected_dataset_ids ) if configured_category is not None: existing = next( @@ -409,8 +396,6 @@ def get(self, request, genome_id): categories[configured_category["track_category_id"]] = ( configured_category ) - else: - existing["configuration"] = configured_category["configuration"] if not categories: return Response( From f4d01315376503826b707e568f9ad6fa2a87cf3f Mon Sep 17 00:00:00 2001 From: Tamara El Naboulsi Date: Mon, 5 Oct 2026 11:46:37 +0100 Subject: [PATCH 5/5] Refine configuration endpoint selections --- ensembl-track-api.openapi.yaml | 57 +++++++++++++++++-- .../seed_transcriptomic_configuration.py | 12 +++- tracks/transcriptomic.py | 17 +++++- 3 files changed, 78 insertions(+), 8 deletions(-) diff --git a/ensembl-track-api.openapi.yaml b/ensembl-track-api.openapi.yaml index 7df48fe..35981ce 100644 --- a/ensembl-track-api.openapi.yaml +++ b/ensembl-track-api.openapi.yaml @@ -33,8 +33,9 @@ paths: Resolved through the same dataset/release selection as track_categories, or pinned with dataset_id. Counts are occurrences across handover records, grouped case-insensitively, and do not change with - selection. Source metadata is preserved. Each product includes a track_id - referencing the registered track. Rendering settings may not yet be configured. + selection. Selections contain display and filtering metadata. Each product includes + a track_id referencing the registered track. File locations are returned + by /track/{track_id}, not by this configuration endpoint. Rendering settings may not yet be configured. parameters: - name: genome_id in: path @@ -80,8 +81,7 @@ paths: selections: type: array items: - type: object - additionalProperties: true + $ref: '#/components/schemas/TranscriptomicSelection' '404': description: No coverage configuration for the requested genome. /track_categories/{genome_id}: @@ -324,6 +324,55 @@ components: - Variation - Regulation + TranscriptomicSelection: + type: object + additionalProperties: false + required: + - selection_id + - parent_sample_id + - display_label + - selection_level + - data_type + - metadata + - track_products + properties: + selection_id: + type: string + parent_sample_id: + type: string + display_label: + type: string + selection_level: + type: string + enum: [run] + data_type: + type: string + enum: [rnaseq] + metadata: + type: object + additionalProperties: + type: string + track_products: + type: array + minItems: 1 + maxItems: 1 + items: + $ref: '#/components/schemas/TranscriptomicTrackProduct' + + TranscriptomicTrackProduct: + type: object + additionalProperties: false + required: + - track_type + - track_id + properties: + track_type: + type: string + enum: [rnaseq_coverage] + track_id: + type: string + format: uuid + TrackList: type: object properties: diff --git a/tracks/management/commands/seed_transcriptomic_configuration.py b/tracks/management/commands/seed_transcriptomic_configuration.py index 7a2dbc3..25ecedd 100644 --- a/tracks/management/commands/seed_transcriptomic_configuration.py +++ b/tracks/management/commands/seed_transcriptomic_configuration.py @@ -133,13 +133,22 @@ def handle(self, *args, **options): ) created_count = 0 - for record in prepared["selections"]: + for record in records: product = record["track_products"][0] # Retained catalogue UUID gives stable track IDs across repeated seeds. track_id = uuid.uuid5( config.dataset_id, record["selection_id"] + ":rnaseq_coverage" ) + + existing_track = Track.objects.filter(track_id=track_id).first() + if existing_track is not None and existing_track.datafiles != { + "rnaseq_coverage": product["track_file"] + }: + raise CommandError( + f"Track {track_id} already exists with a different file path. Omit --dataset-id to create a new version at a new release." + ) + track, created = Track.objects.update_or_create( track_id=track_id, defaults={ @@ -149,7 +158,6 @@ def handle(self, *args, **options): }, ) track.specifications.add(spec) - product["track_id"] = str(track.track_id) created_count += int(created) config.configuration = prepared diff --git a/tracks/transcriptomic.py b/tracks/transcriptomic.py index 103a846..8b08f7d 100644 --- a/tracks/transcriptomic.py +++ b/tracks/transcriptomic.py @@ -2,7 +2,6 @@ import re from collections import Counter, defaultdict -from copy import deepcopy def prepare_configuration(records): @@ -95,5 +94,19 @@ def prepare_configuration(records): ] for key, values in sorted(counts.items()) }, - "selections": deepcopy(records), + "selections": [ + { + "selection_id": record["selection_id"], + "parent_sample_id": record["parent_sample_id"], + "display_label": record["display_label"], + "selection_level": record["selection_level"], + "data_type": record["data_type"], + "metadata": dict(record["metadata"]), + "track_products": [ + {"track_type": product["track_type"]} + for product in record["track_products"] + ], + } + for record in records + ], }