From 25f825f6ab06757aacaacbbd5c8fdb6edeb5e0db Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Wed, 1 Apr 2026 07:33:08 -0400 Subject: [PATCH 1/3] backend logic for recording location display --- bats_ai/core/api.py | 4 + .../management/commands/copy_recordings.py | 36 +- .../management/commands/importRecordings.py | 20 +- bats_ai/core/management/commands/loadGRTS.py | 14 +- .../0035_add_grtscells_centroid_4326.py | 44 ++ bats_ai/core/models/grts_cells.py | 2 + bats_ai/core/views/__init__.py | 2 + bats_ai/core/views/recording.py | 674 +++++++++++------- bats_ai/core/views/recording_location.py | 260 +++++++ 9 files changed, 789 insertions(+), 267 deletions(-) create mode 100644 bats_ai/core/migrations/0035_add_grtscells_centroid_4326.py create mode 100644 bats_ai/core/views/recording_location.py diff --git a/bats_ai/core/api.py b/bats_ai/core/api.py index 51008c2c..0d6a8d49 100644 --- a/bats_ai/core/api.py +++ b/bats_ai/core/api.py @@ -28,6 +28,10 @@ def global_auth(request): api = NinjaAPI(auth=global_auth) api.add_router("/recording/", views.recording_router) +api.add_router( + "/recording-locations/", + views.recording_locations_router, +) api.add_router("/species/", views.species_router) api.add_router("/grts/", views.grts_cells_router) api.add_router("/guano/", views.guano_metadata_router) diff --git a/bats_ai/core/management/commands/copy_recordings.py b/bats_ai/core/management/commands/copy_recordings.py index 5d04babb..9b1f2752 100644 --- a/bats_ai/core/management/commands/copy_recordings.py +++ b/bats_ai/core/management/commands/copy_recordings.py @@ -21,6 +21,7 @@ from bats_ai.core.models import ( CompressedSpectrogram, + GRTSCells, Recording, RecordingAnnotation, RecordingTag, @@ -31,7 +32,7 @@ logger = logging.getLogger(__name__) -DEFAULT_TAGS = ["test", "foo", "bar"] +DEFAULT_TAGS = ["test", "data", "sample", "foo", "bar"] def _link_spectrogram_and_annotations(source_recording, new_recording): @@ -141,6 +142,14 @@ def add_arguments(self, parser): help="Username of the owner for the new recordings\ (default: use source recording owner)", ) + parser.add_argument( + "--random-grts-cell-id", + action="store_true", + help=( + "Assign a random valid GRTS Cell ID to each copied recording. " + "When enabled, recording_location and grts_cell are cleared." + ), + ) def handle(self, *args, **options): count = options["count"] @@ -149,6 +158,7 @@ def handle(self, *args, **options): if not tag_texts: tag_texts = DEFAULT_TAGS owner_username = options.get("owner") + randomize_grts_cell_id = options.get("random_grts_cell_id", False) if count < 1: raise CommandError("--count must be at least 1.") @@ -157,6 +167,16 @@ def handle(self, *args, **options): if not recordings: raise CommandError("No existing recordings found. Create or import some first.") + valid_grts_cell_ids: list[int] = [] + if randomize_grts_cell_id: + valid_grts_cell_ids = list( + GRTSCells.objects.exclude(grts_cell_id__isnull=True) + .values_list("grts_cell_id", flat=True) + .distinct() + ) + if not valid_grts_cell_ids: + raise CommandError("No valid GRTS Cell IDs were found in GRTSCells.") + owner = None if owner_username: try: @@ -190,6 +210,14 @@ def handle(self, *args, **options): ext = "." + source.audio_file.name.rsplit(".", 1)[-1] save_name = new_name + ext if ext else new_name + grts_cell_id = source.grts_cell_id + grts_cell = source.grts_cell + recording_location = source.recording_location + if randomize_grts_cell_id: + grts_cell_id = random.choice(valid_grts_cell_ids) # noqa: S311 + grts_cell = None + recording_location = None + new_recording = Recording( name=new_name, owner=owner, @@ -198,9 +226,9 @@ def handle(self, *args, **options): recorded_time=source.recorded_time, equipment=source.equipment, comments=source.comments, - recording_location=source.recording_location, - grts_cell_id=source.grts_cell_id, - grts_cell=source.grts_cell, + recording_location=recording_location, + grts_cell_id=grts_cell_id, + grts_cell=grts_cell, public=source.public, software=source.software, detector=source.detector, diff --git a/bats_ai/core/management/commands/importRecordings.py b/bats_ai/core/management/commands/importRecordings.py index 1116132c..f8c46e49 100644 --- a/bats_ai/core/management/commands/importRecordings.py +++ b/bats_ai/core/management/commands/importRecordings.py @@ -3,6 +3,7 @@ import contextlib import logging from pathlib import Path +import random from django.contrib.auth.models import User from django.contrib.gis.geos import Point @@ -10,12 +11,14 @@ from django.core.management.base import BaseCommand from django.utils import timezone -from bats_ai.core.models import Recording +from bats_ai.core.models import Recording, RecordingTag from bats_ai.core.tasks.tasks import recording_compute_spectrogram from bats_ai.core.utils.guano_utils import extract_guano_metadata logger = logging.getLogger(__name__) +_RANDOM_TAG_POOL = ("foo", "bar", "test", "sample", "data") + class Command(BaseCommand): help = "Import WAV files from a directory, extract GUANO metadata, and create recordings" @@ -43,12 +46,21 @@ def add_arguments(self, parser): type=int, help="Limit the number of WAV files to import (useful for testing)", ) + parser.add_argument( + "--assign-random-tags", + action="store_true", + help=( + "Assign each imported recording one tag chosen at random from: " + + ", ".join(_RANDOM_TAG_POOL) + ), + ) def handle(self, *args, **options): directory_path = Path(options["directory"]) owner_username = options.get("owner") is_public = options.get("public", False) limit = options.get("limit") + assign_random_tags = options.get("assign_random_tags", False) # Validate directory if not directory_path.exists(): @@ -176,6 +188,12 @@ def handle(self, *args, **options): self.stdout.write(self.style.SUCCESS(f" Created recording ID: {recording.pk}")) + if assign_random_tags: + tag_text = random.choice(_RANDOM_TAG_POOL) # noqa: S311 + tag, _ = RecordingTag.objects.get_or_create(user=owner, text=tag_text) + recording.tags.add(tag) + self.stdout.write(self.style.SUCCESS(f" Assigned random tag: {tag_text}")) + # Generate spectrogram synchronously self.stdout.write(" Generating spectrogram...") try: diff --git a/bats_ai/core/management/commands/loadGRTS.py b/bats_ai/core/management/commands/loadGRTS.py index e93946dc..3f13605a 100644 --- a/bats_ai/core/management/commands/loadGRTS.py +++ b/bats_ai/core/management/commands/loadGRTS.py @@ -53,7 +53,10 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument( - "--batch-size", type=int, default=5000, help="Batch size for database insertion" + "--batch-size", + type=int, + default=5000, + help="Batch size for database insertion", ) def _download_file(self, url: str, zip_path: Path) -> None: @@ -78,7 +81,8 @@ def handle(self, *args, **options): self._download_file(url, zip_path) except requests.RequestException as e: logger.warning( - "Failed to download from primary URL: %s. Attempting backup URL...", e + "Failed to download from primary URL: %s. Attempting backup URL...", + e, ) if backup_url is None: logger.warning("No backup URL provided, skipping this shapefile.") @@ -117,7 +121,9 @@ def handle(self, *args, **options): count_new = 0 for idx, row in tqdm( - gdf.iterrows(), total=len(gdf), desc=f"Importing {sample_frame_id}" + gdf.iterrows(), + total=len(gdf), + desc=f"Importing {sample_frame_id}", ): # Hard fail if GRTS_ID is missing if "GRTS_ID" not in row or row["GRTS_ID"] is None: @@ -131,6 +137,7 @@ def handle(self, *args, **options): continue geom_4326 = row.geometry.wkt + centroid_4326 = row.geometry.centroid.wkt if gdf.crs and gdf.crs.to_epsg() != 4326: grts_geom = row.geometry.to_wkt() else: @@ -142,6 +149,7 @@ def handle(self, *args, **options): sample_frame_id=sample_frame_id, grts_geom=grts_geom, geom_4326=geom_4326, + centroid_4326=centroid_4326, ) records_to_create.append(cell) count_new += 1 diff --git a/bats_ai/core/migrations/0035_add_grtscells_centroid_4326.py b/bats_ai/core/migrations/0035_add_grtscells_centroid_4326.py new file mode 100644 index 00000000..80f5fc32 --- /dev/null +++ b/bats_ai/core/migrations/0035_add_grtscells_centroid_4326.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from django.contrib.gis.db import models +from django.db import migrations + + +def backfill_centroids(apps, schema_editor) -> None: + GRTSCells = apps.get_model("core", "GRTSCells") + + batch_size = 1000 + to_update = [] + + qs = ( + GRTSCells.objects.filter(centroid_4326__isnull=True) + .exclude(geom_4326__isnull=True) + .only("id", "grts_cell_id", "sample_frame_id", "geom_4326") + ) + + for cell in qs.iterator(chunk_size=batch_size): + # `centroid` returns a Point geometry in the same SRID. + cell.centroid_4326 = cell.geom_4326.centroid + to_update.append(cell) + + if len(to_update) >= batch_size: + GRTSCells.objects.bulk_update(to_update, ["centroid_4326"], batch_size=batch_size) + to_update.clear() + + if to_update: + GRTSCells.objects.bulk_update(to_update, ["centroid_4326"], batch_size=batch_size) + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0034_alter_spectrogramimage_type"), + ] + + operations = [ + migrations.AddField( + model_name="grtscells", + name="centroid_4326", + field=models.PointField(srid=4326, null=True, blank=True), + ), + migrations.RunPython(backfill_centroids, migrations.RunPython.noop), + ] diff --git a/bats_ai/core/models/grts_cells.py b/bats_ai/core/models/grts_cells.py index c0de5144..270f0a76 100644 --- a/bats_ai/core/models/grts_cells.py +++ b/bats_ai/core/models/grts_cells.py @@ -24,6 +24,8 @@ class GRTSCells(models.Model): grts_geom = models.GeometryField(blank=True, null=True) # continue defining all fields similarly geom_4326 = models.GeometryField() + # Precomputed centroid of `geom_4326` for faster lookup of cell centers. + centroid_4326 = models.PointField(srid=4326, blank=True, null=True) @property def sample_frame_mapping(self): diff --git a/bats_ai/core/views/__init__.py b/bats_ai/core/views/__init__.py index 70331d60..39c9fd22 100644 --- a/bats_ai/core/views/__init__.py +++ b/bats_ai/core/views/__init__.py @@ -7,6 +7,7 @@ from .processing_tasks import router as processing_task_router from .recording import router as recording_router from .recording_annotation import router as recording_annotation_router +from .recording_location import router as recording_locations_router from .recording_tag import router as recording_tag_router from .species import router as species_router from .vetting_details import router as vetting_router @@ -18,6 +19,7 @@ "guano_metadata_router", "processing_task_router", "recording_annotation_router", + "recording_locations_router", "recording_router", "recording_tag_router", "species_router", diff --git a/bats_ai/core/views/recording.py b/bats_ai/core/views/recording.py index 60b1c192..62056286 100644 --- a/bats_ai/core/views/recording.py +++ b/bats_ai/core/views/recording.py @@ -6,11 +6,10 @@ from typing import TYPE_CHECKING, Any, Literal from django.contrib.auth.models import User -from django.contrib.gis.geos import Point +from django.contrib.gis.geos import Point, Polygon from django.contrib.postgres.aggregates import ArrayAgg from django.core.files.storage import default_storage from django.db.models import Count, Exists, OuterRef, Prefetch, Q, QuerySet -from django.shortcuts import get_object_or_404 from ninja import File, Form, Query, Schema # Django-Ninja accesses additional params directly, so we need to ignore the type checker. @@ -19,6 +18,8 @@ from bats_ai.core.models import ( Annotations, + CompressedSpectrogram, + GRTSCells, PulseMetadata, Recording, RecordingAnnotation, @@ -28,6 +29,7 @@ Spectrogram, ) from bats_ai.core.tasks.tasks import recording_compute_spectrogram +from bats_ai.core.views.recording_location import _parse_bbox from bats_ai.core.views.species import SpeciesSchema if TYPE_CHECKING: @@ -62,6 +64,8 @@ class RecordingListQuerySchema(Schema): annotation_completed: bool | None = None search: str | None = None tags: str | None = None # Comma-separated tag texts; recording must have all listed tags + # [min_lon, min_lat, max_lon, max_lat] as JSON array or comma-separated (see _parse_bbox). + bbox: str | None = None sort_by: ( Literal["id", "name", "created", "modified", "recorded_date", "owner_username"] | None ) = "created" @@ -346,7 +350,10 @@ def create_recording( @router.patch("/{pk}") def update_recording(request: HttpRequest, pk: int, recording_data: RecordingUploadSchema): - recording = get_object_or_404(Recording, pk=pk, owner=request.user) + try: + recording = Recording.objects.get(pk=pk, owner=request.user) + except Recording.DoesNotExist: + return {"error": "Recording not found"} if recording_data.name: recording.name = recording_data.name @@ -455,15 +462,23 @@ def delete_recording( request, pk: int, ): - recording = get_object_or_404(Recording, pk=pk) + try: + recording = Recording.objects.get(pk=pk) - # Check if the user owns the recording - if recording.owner == request.user: - # Delete the annotation - recording.delete() - return {"message": "Recording deleted successfully"} - else: - return {"error": "Permission denied. You do not own this recording, and it is not public."} + # Check if the user owns the recording + if recording.owner == request.user: + # Delete the annotation + recording.delete() + return {"message": "Recording deleted successfully"} + else: + return { + "error": "Permission denied. You do not own this recording, and it is not public." + } + + except Recording.DoesNotExist: + return {"error": "Recording not found"} + except Annotations.DoesNotExist: + return {"error": "Annotation not found"} @router.get("/", response=RecordingPaginatedResponse) @@ -505,6 +520,16 @@ def get_recordings( for tag in tag_list: queryset = queryset.filter(tags__text=tag) queryset = queryset.distinct() + if q.bbox and q.bbox.strip(): + min_lon, min_lat, max_lon, max_lat = _parse_bbox(q.bbox) + bbox_poly = Polygon.from_bbox((min_lon, min_lat, max_lon, max_lat)) + # Need to check the GRTSCells centroids as well as the recording_location + grts_cell_ids = GRTSCells.objects.filter(centroid_4326__intersects=bbox_poly).values_list( + "grts_cell_id", flat=True + ) + queryset = queryset.filter( + Q(recording_location__intersects=bbox_poly) | Q(grts_cell_id__in=grts_cell_ids) + ) sort_field = q.sort_by or "created" order_prefix = "" if q.sort_direction == "asc" else "-" @@ -566,6 +591,7 @@ def _unsubmitted_recording_ids_ordered( sort_by: str = "created", sort_direction: str = "desc", tags: str | None = None, + bbox: str | None = None, ) -> list[int]: submitted_by_user = RecordingAnnotation.objects.filter( owner=request.user, submitted=True @@ -579,6 +605,16 @@ def apply_filters_and_sort(qs: QuerySet[Recording]) -> QuerySet[Recording]: qs = qs.filter(tags__text=tag) if tag_list: qs = qs.distinct() + if bbox and bbox.strip(): + min_lon, min_lat, max_lon, max_lat = _parse_bbox(bbox) + bbox_poly = Polygon.from_bbox((min_lon, min_lat, max_lon, max_lat)) + # Need to check the GRTSCells centroids as well as the recording_location + grts_cell_ids = GRTSCells.objects.filter( + centroid_4326__intersects=bbox_poly + ).values_list("grts_cell_id", flat=True) + qs = qs.filter( + Q(recording_location__intersects=bbox_poly) | Q(grts_cell_id__in=grts_cell_ids) + ) order_prefix = "" if sort_direction == "asc" else "-" if sort_by == "owner_username": qs = qs.order_by(f"{order_prefix}owner__username") @@ -611,7 +647,7 @@ def get_unsubmitted_neighbors( sort_by = q.sort_by or "created" sort_direction = q.sort_direction or "desc" raw_ids = _unsubmitted_recording_ids_ordered( - request, sort_by=sort_by, sort_direction=sort_direction, tags=q.tags + request, sort_by=sort_by, sort_direction=sort_direction, tags=q.tags, bbox=q.bbox ) # One entry per recording, order preserved (my + shared can duplicate ids) ids = list(dict.fromkeys(int(x) for x in raw_ids)) @@ -635,56 +671,68 @@ def get_unsubmitted_neighbors( @router.get("/{pk}/") def get_recording(request: HttpRequest, pk: int): - recording = get_object_or_404( - Recording.objects.annotate( - tags_text=ArrayAgg("tags__text", filter=Q(tags__text__isnull=False)) - ).values(), - pk=pk, - ) - - user = User.objects.get(id=recording["owner_id"]) - recording["owner_username"] = user.username - recording["audio_file_presigned_url"] = default_storage.url(recording["audio_file"]) - recording["hasSpectrogram"] = len(recording["spectrograms"]) > 0 - if recording["recording_location"]: - recording["recording_location"] = json.loads(recording["recording_location"].json) - annotation_owners = ( - Annotations.objects.filter(recording_id=recording["id"]) - .values_list("owner", flat=True) - .distinct() - ) - recording_annotation_owners = ( - RecordingAnnotation.objects.filter(recording_id=recording["id"]) - .values_list("owner", flat=True) - .distinct() - ) + # Filter recordings based on the owner's id or public=True + try: + recordings = ( + Recording.objects.filter(pk=pk) + .annotate(tags_text=ArrayAgg("tags__text", filter=Q(tags__text__isnull=False))) + .values() + ) + if len(recordings) > 0: + recording = recordings[0] + + user = User.objects.get(id=recording["owner_id"]) + recording["owner_username"] = user.username + recording["audio_file_presigned_url"] = default_storage.url(recording["audio_file"]) + recording["hasSpectrogram"] = Recording.objects.get(id=recording["id"]).has_spectrogram + if recording["recording_location"]: + recording["recording_location"] = json.loads(recording["recording_location"].json) + annotation_owners = ( + Annotations.objects.filter(recording_id=recording["id"]) + .values_list("owner", flat=True) + .distinct() + ) + recording_annotation_owners = ( + RecordingAnnotation.objects.filter(recording_id=recording["id"]) + .values_list("owner", flat=True) + .distinct() + ) - # Combine the sets of owners and count unique entries - unique_users_with_annotations = len( - set(annotation_owners).union(set(recording_annotation_owners)) - ) - recording["userAnnotations"] = unique_users_with_annotations - user_has_annotations = ( - Annotations.objects.filter(recording_id=recording["id"], owner=request.user).exists() - or RecordingAnnotation.objects.filter( - recording_id=recording["id"], owner=request.user - ).exists() - ) - recording["userMadeAnnotations"] = user_has_annotations - # Only expose file-level annotations owned by the current user - file_annotations = RecordingAnnotation.objects.filter( - recording=pk, owner=request.user - ).order_by("confidence") - recording["fileAnnotations"] = [ - RecordingAnnotationSchema.from_orm(fileAnnotation).dict() - for fileAnnotation in file_annotations - ] - return recording + # Combine the sets of owners and count unique entries + unique_users_with_annotations = len( + set(annotation_owners).union(set(recording_annotation_owners)) + ) + recording["userAnnotations"] = unique_users_with_annotations + user_has_annotations = ( + Annotations.objects.filter( + recording_id=recording["id"], owner=request.user + ).exists() + or RecordingAnnotation.objects.filter( + recording_id=recording["id"], owner=request.user + ).exists() + ) + recording["userMadeAnnotations"] = user_has_annotations + # Only expose file-level annotations owned by the current user + file_annotations = RecordingAnnotation.objects.filter( + recording=pk, owner=request.user + ).order_by("confidence") + recording["fileAnnotations"] = [ + RecordingAnnotationSchema.from_orm(fileAnnotation).dict() + for fileAnnotation in file_annotations + ] + return recording + else: + return {"error": "Recording not found"} + except Recording.DoesNotExist: + return {"error": "Recording not found"} @router.get("/{recording_id}/recording-annotations") def get_recording_annotations(request: HttpRequest, recording_id: int): - recording = get_object_or_404(Recording, pk=recording_id) + try: + recording = Recording.objects.get(pk=recording_id) + except Recording.DoesNotExist: + return {"error": "Recording not found"} if recording.owner != request.user and not recording.public: return {"error": "Permission denied. You do not own this recording, and it is not public."} # Only return file-level annotations owned by the current user (same as pulse) @@ -703,7 +751,10 @@ def get_recording_annotations(request: HttpRequest, recording_id: int): @router.get("/{pk}/spectrogram") def get_spectrogram(request: HttpRequest, pk: int): - recording = get_object_or_404(Recording, pk=pk) + try: + recording = Recording.objects.get(pk=pk) + except Recording.DoesNotExist: + return {"error": "Recording not found"} spectrogram = recording.spectrogram @@ -772,8 +823,13 @@ def get_spectrogram(request: HttpRequest, pk: int): @router.get("/{pk}/spectrogram/compressed") def get_spectrogram_compressed(request: HttpRequest, pk: int): - recording = get_object_or_404(Recording, pk=pk) - compressed_spectrogram = get_object_or_404(recording.compressedspectrogram_set) + try: + recording = Recording.objects.get(pk=pk) + compressed_spectrogram = CompressedSpectrogram.objects.filter(recording=pk).first() + except compressed_spectrogram.DoesNotExist: + return {"error": "Compressed Spectrogram"} + except recording.DoesNotExist: + return {"error": "Recording does not exist"} spectro_data = { "urls": compressed_spectrogram.image_url_list, @@ -839,90 +895,118 @@ def get_spectrogram_compressed(request: HttpRequest, pk: int): @router.get("/{pk}/annotations") def get_annotations(request: HttpRequest, pk: int): - recording = get_object_or_404(Recording, pk=pk) + try: + recording = Recording.objects.get(pk=pk) - # Check if the user owns the recording or if the recording is public - if recording.owner == request.user or recording.public: - # Query annotations associated with the recording that are owned by the current user - annotations_qs = Annotations.objects.filter(recording=recording, owner=request.user) + # Check if the user owns the recording or if the recording is public + if recording.owner == request.user or recording.public: + # Query annotations associated with the recording that are owned by the current user + annotations_qs = Annotations.objects.filter(recording=recording, owner=request.user) - # Serialize the annotations using AnnotationSchema - return [ - AnnotationSchema.from_orm(annotation, owner_email=request.user.email).dict() - for annotation in annotations_qs - ] + # Serialize the annotations using AnnotationSchema + return [ + AnnotationSchema.from_orm(annotation, owner_email=request.user.email).dict() + for annotation in annotations_qs + ] - else: - return {"error": "Permission denied. You do not own this recording, and it is not public."} + else: + return { + "error": "Permission denied. You do not own this recording, and it is not public." + } + + except Recording.DoesNotExist: + return {"error": "Recording not found"} @router.get("/{pk}/pulse_contours") def get_pulse_contours(request: HttpRequest, pk: int): - recording = get_object_or_404(Recording, pk=pk) - if recording.owner == request.user or recording.public: - computed_pulse_annotation_qs = PulseMetadata.objects.filter(recording=recording).order_by( - "index" - ) - return [PulseContourSchema.from_orm(pulse) for pulse in computed_pulse_annotation_qs.all()] - else: - return {"error": "Permission denied. You do not own this recording, and it is not public."} + try: + recording = Recording.objects.get(pk=pk) + if recording.owner == request.user or recording.public: + computed_pulse_annotation_qs = PulseMetadata.objects.filter( + recording=recording + ).order_by("index") + return [ + PulseContourSchema.from_orm(pulse) for pulse in computed_pulse_annotation_qs.all() + ] + else: + return { + "error": "Permission denied. You do not own this recording, and it is not public." + } + except Recording.DoesNotExist: + return {"error": "Recording not found"} @router.get("/{pk}/pulse_data") def get_pulse_data(request: HttpRequest, pk: int): - recording = get_object_or_404(Recording, pk=pk) - if recording.owner == request.user or recording.public: - computed_pulse_annotation_qs = PulseMetadata.objects.filter(recording=recording).order_by( - "index" - ) - return [PulseMetadataSchema.from_orm(pulse) for pulse in computed_pulse_annotation_qs.all()] - else: - return {"error": "Permission denied. You do not own this recording, and it is not public."} + try: + recording = Recording.objects.get(pk=pk) + if recording.owner == request.user or recording.public: + computed_pulse_annotation_qs = PulseMetadata.objects.filter( + recording=recording + ).order_by("index") + return [ + PulseMetadataSchema.from_orm(pulse) for pulse in computed_pulse_annotation_qs.all() + ] + else: + return { + "error": "Permission denied. You do not own this recording, and it is not public." + } + except Recording.DoesNotExist: + return {"error": "Recording not found"} @router.get("/{pk}/annotations/other_users") def get_other_user_annotations(request: HttpRequest, pk: int): - recording = get_object_or_404(Recording, pk=pk) - - # Check if the user owns the recording or if the recording is public - if recording.owner == request.user or request.user.is_superuser: - # Query annotations associated with the recording that are owned by other users - annotations_qs = Annotations.objects.filter(recording=recording).exclude(owner=request.user) - sequence_qs = SequenceAnnotations.objects.filter(recording=recording).exclude( - owner=request.user - ) + try: + recording = Recording.objects.get(pk=pk) - # Create a dictionary to store annotations for each user - annotations_by_user = {} + # Check if the user owns the recording or if the recording is public + if recording.owner == request.user or request.user.is_superuser: + # Query annotations associated with the recording that are owned by other users + annotations_qs = Annotations.objects.filter(recording=recording).exclude( + owner=request.user + ) + sequence_qs = SequenceAnnotations.objects.filter(recording=recording).exclude( + owner=request.user + ) - # Serialize the annotations using AnnotationSchema - for annotation in annotations_qs: - user_email = annotation.owner.email + # Create a dictionary to store annotations for each user + annotations_by_user = {} - # If user_email is not already a key in the dictionary, initialize it with - # an empty list - annotations_by_user.setdefault(user_email, {"annotations": [], "sequence": []}) + # Serialize the annotations using AnnotationSchema + for annotation in annotations_qs: + user_email = annotation.owner.email - # Append the annotation to the list for the corresponding user_email - annotations_by_user[user_email]["annotations"].append( - AnnotationSchema.from_orm(annotation, owner_email=user_email).dict() - ) + # If user_email is not already a key in the dictionary, initialize it with + # an empty list + annotations_by_user.setdefault(user_email, {"annotations": [], "sequence": []}) - for annotation in sequence_qs: - user_email = annotation.owner.email + # Append the annotation to the list for the corresponding user_email + annotations_by_user[user_email]["annotations"].append( + AnnotationSchema.from_orm(annotation, owner_email=user_email).dict() + ) - # If user_email is not already a key in the dictionary, initialize it with - # an empty list - annotations_by_user.setdefault(user_email, {"annotations": [], "sequence": []}) + for annotation in sequence_qs: + user_email = annotation.owner.email - # Append the annotation to the list for the corresponding user_email - annotations_by_user[user_email]["sequence"].append( - SequenceAnnotationSchema.from_orm(annotation, owner_email=user_email).dict() - ) + # If user_email is not already a key in the dictionary, initialize it with + # an empty list + annotations_by_user.setdefault(user_email, {"annotations": [], "sequence": []}) - return annotations_by_user - else: - return {"error": "Permission denied. You do not own this recording, and it is not public."} + # Append the annotation to the list for the corresponding user_email + annotations_by_user[user_email]["sequence"].append( + SequenceAnnotationSchema.from_orm(annotation, owner_email=user_email).dict() + ) + + return annotations_by_user + else: + return { + "error": "Permission denied. You do not own this recording, and it is not public." + } + + except Recording.DoesNotExist: + return {"error": "Recording not found"} @router.get("/{pk}/annotations/user/{userId}") @@ -931,18 +1015,24 @@ def get_user_annotations( pk: int, userId: int, # noqa: N803 ): - recording = get_object_or_404(Recording, pk=pk) + try: + recording = Recording.objects.get(pk=pk) - # Check if the user owns the recording or if the recording is public - if recording.owner == request.user or recording.public: - # Query annotations associated with the recording that are owned by the current user - annotations_qs = Annotations.objects.filter(recording=recording, owner=userId) + # Check if the user owns the recording or if the recording is public + if recording.owner == request.user or recording.public: + # Query annotations associated with the recording that are owned by the current user + annotations_qs = Annotations.objects.filter(recording=recording, owner=userId) - # Serialize the annotations using AnnotationSchema - return [AnnotationSchema.from_orm(annotation).dict() for annotation in annotations_qs] + # Serialize the annotations using AnnotationSchema + return [AnnotationSchema.from_orm(annotation).dict() for annotation in annotations_qs] - else: - return {"error": "Permission denied. You do not own this recording, and it is not public."} + else: + return { + "error": "Permission denied. You do not own this recording, and it is not public." + } + + except Recording.DoesNotExist: + return {"error": "Recording not found"} @router.put("/{pk}/annotations") @@ -952,28 +1042,38 @@ def put_annotation( annotation: AnnotationSchema, species_ids: list[int], ): - recording = get_object_or_404(Recording, pk=pk) - if recording.owner == request.user or recording.public: - # Create a new annotation - new_annotation = Annotations.objects.create( - recording=recording, - owner=request.user, - start_time=annotation.start_time, - end_time=annotation.end_time, - low_freq=annotation.low_freq, - high_freq=annotation.high_freq, - comments=annotation.comments, - type=annotation.type, - ) + try: + recording = Recording.objects.get(pk=pk) + if recording.owner == request.user or recording.public: + # Create a new annotation + new_annotation = Annotations.objects.create( + recording=recording, + owner=request.user, + start_time=annotation.start_time, + end_time=annotation.end_time, + low_freq=annotation.low_freq, + high_freq=annotation.high_freq, + comments=annotation.comments, + type=annotation.type, + ) - # Add species to the annotation based on the provided species_ids - for species_id in species_ids: - species_obj = get_object_or_404(Species, pk=species_id) - new_annotation.species.add(species_obj) + # Add species to the annotation based on the provided species_ids + for species_id in species_ids: + try: + species_obj = Species.objects.get(pk=species_id) + new_annotation.species.add(species_obj) + except Species.DoesNotExist: + # Handle the case where the species with the given ID doesn't exist + return {"error": f"Species with ID {species_id} not found"} + + return {"message": "Annotation added successfully", "id": new_annotation.pk} + else: + return { + "error": "Permission denied. You do not own this recording, and it is not public." + } - return {"message": "Annotation added successfully", "id": new_annotation.pk} - else: - return {"error": "Permission denied. You do not own this recording, and it is not public."} + except Recording.DoesNotExist: + return {"error": "Recording not found"} @router.patch("/{recording_pk}/annotations/{annotation_pk}") @@ -984,42 +1084,56 @@ def patch_annotation( annotation: UpdateAnnotationsSchema, species_ids: list[int] | None, ): - recording = get_object_or_404(Recording, pk=recording_pk) - - # Check if the user owns the recording or if the recording is public - if recording.owner == request.user or recording.public: - annotation_instance = get_object_or_404( - Annotations, pk=annotation_pk, recording=recording, owner=request.user - ) + try: + recording = Recording.objects.get(pk=recording_pk) - # Update annotation details - if annotation.start_time is not None: - annotation_instance.start_time = annotation.start_time - if annotation.end_time: - annotation_instance.end_time = annotation.end_time - if annotation.low_freq: - annotation_instance.low_freq = annotation.low_freq - if annotation.high_freq: - annotation_instance.high_freq = annotation.high_freq - if annotation.type: - annotation_instance.type = annotation.type + # Check if the user owns the recording or if the recording is public + if recording.owner == request.user or recording.public: + annotation_instance = Annotations.objects.get( + pk=annotation_pk, recording=recording, owner=request.user + ) + if annotation_instance is None: + return {"error": "Annotation not found"} + + # Update annotation details + if annotation.start_time is not None: + annotation_instance.start_time = annotation.start_time + if annotation.end_time: + annotation_instance.end_time = annotation.end_time + if annotation.low_freq: + annotation_instance.low_freq = annotation.low_freq + if annotation.high_freq: + annotation_instance.high_freq = annotation.high_freq + if annotation.type: + annotation_instance.type = annotation.type + else: + annotation_instance.type = None + if annotation.comments: + annotation_instance.comments = annotation.comments + annotation_instance.save() + + # Clear existing species associations + if species_ids is not None: + annotation_instance.species.clear() + # Add species to the annotation based on the provided species_ids + for species_id in species_ids: + try: + species_obj = Species.objects.get(pk=species_id) + annotation_instance.species.add(species_obj) + except Species.DoesNotExist: + # Handle the case where the species with the given ID doesn't exist + return {"error": f"Species with ID {species_id} not found"} + + return {"message": "Annotation updated successfully", "id": annotation_instance.pk} else: - annotation_instance.type = None - if annotation.comments: - annotation_instance.comments = annotation.comments - annotation_instance.save() - - # Clear existing species associations - if species_ids is not None: - annotation_instance.species.clear() - # Add species to the annotation based on the provided species_ids - for species_id in species_ids: - species_obj = get_object_or_404(Species, pk=species_id) - annotation_instance.species.add(species_obj) + return { + "error": "Permission denied. You do not own this recording, and it is not public." + } - return {"message": "Annotation updated successfully", "id": annotation_instance.pk} - else: - return {"error": "Permission denied. You do not own this recording, and it is not public."} + except Recording.DoesNotExist: + return {"error": "Recording not found"} + except Annotations.DoesNotExist: + return {"error": "Annotation not found"} @router.patch("/{recording_pk}/sequence-annotations/{sequence_annotation_pk}") @@ -1030,56 +1144,76 @@ def patch_sequence_annotation( annotation: UpdateSequenceAnnotationSchema, species_ids: list[int] | None, ): - recording = get_object_or_404(Recording, pk=recording_pk) + try: + recording = Recording.objects.get(pk=recording_pk) - # Check if the user owns the recording or if the recording is public - if recording.owner == request.user or recording.public: - annotation_instance = get_object_or_404( - SequenceAnnotations, pk=sequence_annotation_pk, recording=recording, owner=request.user - ) + # Check if the user owns the recording or if the recording is public + if recording.owner == request.user or recording.public: + annotation_instance = SequenceAnnotations.objects.get( + pk=sequence_annotation_pk, recording=recording, owner=request.user + ) - # Update annotation details - if annotation.start_time is not None: - annotation_instance.start_time = annotation.start_time - if annotation.end_time: - annotation_instance.end_time = annotation.end_time - if annotation.comments: - annotation_instance.comments = annotation.comments - if annotation.type: - annotation_instance.type = annotation.type + # Update annotation details + if annotation.start_time is not None: + annotation_instance.start_time = annotation.start_time + if annotation.end_time: + annotation_instance.end_time = annotation.end_time + if annotation.comments: + annotation_instance.comments = annotation.comments + if annotation.type: + annotation_instance.type = annotation.type + else: + annotation_instance.type = None + annotation_instance.save() + + # Clear existing species associations + if species_ids is not None: + annotation_instance.species.clear() + # Add species to the annotation based on the provided species_ids + for species_id in species_ids: + try: + species_obj = Species.objects.get(pk=species_id) + annotation_instance.species.add(species_obj) + except Species.DoesNotExist: + # Handle the case where the species with the given ID doesn't exist + return {"error": f"Species with ID {species_id} not found"} + + return {"message": "Annotation updated successfully", "id": annotation_instance.pk} else: - annotation_instance.type = None - annotation_instance.save() - - # Clear existing species associations - if species_ids is not None: - annotation_instance.species.clear() - # Add species to the annotation based on the provided species_ids - for species_id in species_ids: - species_obj = get_object_or_404(Species, pk=species_id) - annotation_instance.species.add(species_obj) + return { + "error": "Permission denied. You do not own this recording, and it is not public." + } - return {"message": "Annotation updated successfully", "id": annotation_instance.pk} - else: - return {"error": "Permission denied. You do not own this recording, and it is not public."} + except Recording.DoesNotExist: + return {"error": "Recording not found"} + except Annotations.DoesNotExist: + return {"error": "Annotation not found"} @router.delete("/{recording_pk}/annotations/{annotation_pk}") def delete_annotation(request, recording_pk: int, annotation_pk: int): - recording = get_object_or_404(Recording, pk=recording_pk) + try: + recording = Recording.objects.get(pk=recording_pk) - # Check if the user owns the recording or if the recording is public - if recording.owner == request.user or recording.public: - annotation_instance = get_object_or_404( - Annotations, pk=annotation_pk, recording=recording, owner=request.user - ) + # Check if the user owns the recording or if the recording is public + if recording.owner == request.user or recording.public: + annotation_instance = Annotations.objects.get( + pk=annotation_pk, recording=recording, owner=request.user + ) - # Delete the annotation - annotation_instance.delete() + # Delete the annotation + annotation_instance.delete() - return {"message": "Annotation deleted successfully"} - else: - return {"error": "Permission denied. You do not own this recording, and it is not public."} + return {"message": "Annotation deleted successfully"} + else: + return { + "error": "Permission denied. You do not own this recording, and it is not public." + } + + except Recording.DoesNotExist: + return {"error": "Recording not found"} + except Annotations.DoesNotExist: + return {"error": "Annotation not found"} # SEQUENCE ANNOTATIONS @@ -1087,21 +1221,29 @@ def delete_annotation(request, recording_pk: int, annotation_pk: int): @router.get("/{pk}/sequence-annotations") def get_sequence_annotations(request: HttpRequest, pk: int): - recording = get_object_or_404(Recording, pk=pk) + try: + recording = Recording.objects.get(pk=pk) - # Check if the user owns the recording or if the recording is public - if recording.owner == request.user or recording.public: - # Query annotations associated with the recording that are owned by the current user - annotations_qs = SequenceAnnotations.objects.filter(recording=recording, owner=request.user) + # Check if the user owns the recording or if the recording is public + if recording.owner == request.user or recording.public: + # Query annotations associated with the recording that are owned by the current user + annotations_qs = SequenceAnnotations.objects.filter( + recording=recording, owner=request.user + ) - # Serialize the annotations using AnnotationSchema - return [ - SequenceAnnotationSchema.from_orm(annotation, owner_email=request.user.email).dict() - for annotation in annotations_qs - ] + # Serialize the annotations using AnnotationSchema + return [ + SequenceAnnotationSchema.from_orm(annotation, owner_email=request.user.email).dict() + for annotation in annotations_qs + ] - else: - return {"error": "Permission denied. You do not own this recording, and it is not public."} + else: + return { + "error": "Permission denied. You do not own this recording, and it is not public." + } + + except Recording.DoesNotExist: + return {"error": "Recording not found"} @router.put("/{pk}/sequence-annotations") @@ -1111,36 +1253,50 @@ def put_sequence_annotation( annotation: SequenceAnnotationSchema, species_ids: list[int] | None, ): - recording = get_object_or_404(Recording, pk=pk) - if recording.owner == request.user or recording.public: - # Create a new annotation - new_annotation = SequenceAnnotations.objects.create( - recording=recording, - owner=request.user, - start_time=annotation.start_time, - end_time=annotation.end_time, - type=annotation.type, - comments=annotation.comments, - ) + try: + recording = Recording.objects.get(pk=pk) + if recording.owner == request.user or recording.public: + # Create a new annotation + new_annotation = SequenceAnnotations.objects.create( + recording=recording, + owner=request.user, + start_time=annotation.start_time, + end_time=annotation.end_time, + type=annotation.type, + comments=annotation.comments, + ) - return {"message": "Annotation added successfully", "id": new_annotation.pk} - else: - return {"error": "Permission denied. You do not own this recording, and it is not public."} + return {"message": "Annotation added successfully", "id": new_annotation.pk} + else: + return { + "error": "Permission denied. You do not own this recording, and it is not public." + } + + except Recording.DoesNotExist: + return {"error": "Recording not found"} @router.delete("/{recording_pk}/sequence-annotations/{sequence_annotation_pk}") def delete_sequence_annotation(request, recording_pk: int, sequence_annotation_pk: int): - recording = get_object_or_404(Recording, pk=recording_pk) + try: + recording = Recording.objects.get(pk=recording_pk) - # Check if the user owns the recording or if the recording is public - if recording.owner == request.user or recording.public: - annotation_instance = get_object_or_404( - SequenceAnnotations, pk=sequence_annotation_pk, recording=recording, owner=request.user - ) + # Check if the user owns the recording or if the recording is public + if recording.owner == request.user or recording.public: + annotation_instance = SequenceAnnotations.objects.get( + pk=sequence_annotation_pk, recording=recording, owner=request.user + ) - # Delete the annotation - annotation_instance.delete() + # Delete the annotation + annotation_instance.delete() - return {"message": "Annotation deleted successfully"} - else: - return {"error": "Permission denied. You do not own this recording, and it is not public."} + return {"message": "Annotation deleted successfully"} + else: + return { + "error": "Permission denied. You do not own this recording, and it is not public." + } + + except Recording.DoesNotExist: + return {"error": "Recording not found"} + except Annotations.DoesNotExist: + return {"error": "Annotation not found"} diff --git a/bats_ai/core/views/recording_location.py b/bats_ai/core/views/recording_location.py new file mode 100644 index 00000000..fb164317 --- /dev/null +++ b/bats_ai/core/views/recording_location.py @@ -0,0 +1,260 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any, Literal + +from django.contrib.gis.geos import Polygon +from django.db.models import Case, IntegerField, Q, QuerySet, Value, When +from ninja import Query, Router, Schema +from ninja.errors import HttpError + +from bats_ai.core.models import ( + Configuration, + GRTSCells, + Recording, + RecordingAnnotation, +) + +if TYPE_CHECKING: + from django.http import HttpRequest + +logger = logging.getLogger(__name__) + +router = Router() + +# Continental US sample frame ID, defaulting to CONUS GRTS. +_CONUS_SAMPLE_FRAME_ID = 14 + + +class RecordingLocationsQuerySchema(Schema): + # When true, exclude recordings the current user has already submitted. + exclude_submitted: bool | None = None + # Comma-separated tag texts; recording must have all listed tags. + tags: str | None = None + # Bounding box filter (lon/lat) as `[min_lon, min_lat, max_lon, max_lat]`. + bbox: str | None = None + + +class RecordingLocationsFeaturePropertiesSchema(Schema): + recording_id: int + filename: str + + +class RecordingLocationsFeatureGeometrySchema(Schema): + type: Literal["Point"] = "Point" + coordinates: list[float] + + +class RecordingLocationsFeatureSchema(Schema): + type: Literal["Feature"] = "Feature" + geometry: RecordingLocationsFeatureGeometrySchema + properties: RecordingLocationsFeaturePropertiesSchema + + +class RecordingLocationsResponseSchema(Schema): + type: Literal["FeatureCollection"] = "FeatureCollection" + features: list[RecordingLocationsFeatureSchema] + + +def _split_tags(tags: str | None) -> list[str]: + if not tags: + return [] + return [t.strip() for t in tags.split(",") if t.strip()] + + +def _apply_recording_filters_and_sort( + *, + qs: QuerySet[Recording], + exclude_submitted: bool, + submitted_by_user: QuerySet[int] | None, + tags: str | None, + bbox_poly: Polygon | None, + grts_cell_ids: QuerySet[int] | None, +) -> QuerySet[Recording]: + if exclude_submitted and submitted_by_user is not None: + qs = qs.exclude(pk__in=submitted_by_user) + + tag_list = _split_tags(tags) + if tag_list: + for tag in tag_list: + qs = qs.filter(tags__text=tag) + qs = qs.distinct() + + if bbox_poly is not None and grts_cell_ids is not None: + qs = qs.filter( + Q(recording_location__intersects=bbox_poly) | Q(grts_cell_id__in=grts_cell_ids) + ) + + # Keep deterministic ordering even though we don't expose sorting params. + return qs.order_by("-created") + + +def _parse_bbox(bbox: str | None) -> tuple[float, float, float, float] | None: + if not bbox: + return None + + raw = bbox.strip() + # Allow bbox=lon1,lat1,lon2,lat2 + values = [v.strip() for v in raw.split(",")] + + if not isinstance(values, list) or len(values) != 4: + raise HttpError(400, "bbox must contain exactly 4 numbers") + + try: + min_lon, min_lat, max_lon, max_lat = (float(v) for v in values) + except Exception as e: + raise HttpError(400, f"bbox values must be numbers: {e}") from e + + if not (-90.0 <= min_lat <= 90.0 and -90.0 <= max_lat <= 90.0): + raise HttpError(400, "bbox latitude values must be within [-90, 90]") + + # Normalize ordering. + if min_lon > max_lon: + min_lon, max_lon = max_lon, min_lon + if min_lat > max_lat: + min_lat, max_lat = max_lat, min_lat + + return min_lon, min_lat, max_lon, max_lat + + +def _get_recording_location_coords(recording: Recording) -> list[float] | None: + """Return `[lon, lat]` for `Recording.recording_location` if present.""" + point = recording.recording_location + if not point: + return None + + # Prefer direct GEOS access instead of serializing geometry to JSON. + return [float(point.x), float(point.y)] + + +def _precompute_grts_cell_centroids( + cell_ids: set[int], +) -> dict[int, list[float]]: + """Precompute centroid coordinates for each `grts_cell_id`. + + Choose the same "best" cell as `core/views/grts_cells.py` does, + then compute `[lon, lat]` from its centroid. + """ + if not cell_ids: + return {} + + # Default to Continental US (sample_frame_id=14). We currently only import + # CONUS GRTS, so this keeps centroid selection aligned with loaded data. + frame_rank = Case( + When(sample_frame_id=_CONUS_SAMPLE_FRAME_ID, then=Value(0)), + default=Value(1), + output_field=IntegerField(), + ) + + rows = ( + GRTSCells.objects.filter( + grts_cell_id__in=cell_ids, + centroid_4326__isnull=False, + ) + .annotate(frame_rank=frame_rank) + .order_by("grts_cell_id", "frame_rank") + .distinct("grts_cell_id") + .values_list("grts_cell_id", "centroid_4326") + ) + + return { + int(cell_id): [float(centroid.x), float(centroid.y)] + for cell_id, centroid in rows + if centroid is not None + } + + +@router.get("/", response=RecordingLocationsResponseSchema) +def get_recording_locations( + request: HttpRequest, + q: Query[RecordingLocationsQuerySchema], +): + config = Configuration.objects.first() + vetting_enabled = bool(config.mark_annotations_completed_enabled) if config else False + + # Build "full" set: my + shared recordings. + my_qs = Recording.objects.filter(owner=request.user) + shared_qs = Recording.objects.filter(public=True).exclude( + Q(owner=request.user) | Q(spectrogram__isnull=True) + ) + + exclude_submitted = bool(q.exclude_submitted) + submitted_by_user = None + if exclude_submitted: + submitted_by_user = RecordingAnnotation.objects.filter( + owner=request.user, submitted=True + ).values_list("recording_id", flat=True) + + bbox = _parse_bbox(q.bbox) + bbox_poly: Polygon | None = None + grts_cell_ids: QuerySet[int] | None = None + if bbox is not None: + bbox_poly = Polygon.from_bbox((bbox[0], bbox[1], bbox[2], bbox[3])) + grts_cell_ids = GRTSCells.objects.filter(centroid_4326__intersects=bbox_poly).values_list( + "grts_cell_id", flat=True + ) + + my_qs = _apply_recording_filters_and_sort( + qs=my_qs, + exclude_submitted=exclude_submitted, + submitted_by_user=submitted_by_user, + tags=q.tags, + bbox_poly=bbox_poly, + grts_cell_ids=grts_cell_ids, + ) + shared_qs = _apply_recording_filters_and_sort( + qs=shared_qs, + exclude_submitted=exclude_submitted, + submitted_by_user=submitted_by_user, + tags=q.tags, + bbox_poly=bbox_poly, + grts_cell_ids=grts_cell_ids, + ) + + my_list = list(my_qs.only("id", "audio_file", "recording_location", "grts_cell_id", "created")) + shared_list = list( + shared_qs.only( + "id", + "audio_file", + "recording_location", + "grts_cell_id", + "created", + ) + ) + recordings = my_list + shared_list + + required_cell_ids = {r.grts_cell_id for r in recordings if r.grts_cell_id is not None} + centroids_by_cell_id = _precompute_grts_cell_centroids(required_cell_ids) + + features: list[dict[str, Any]] = [] + for rec in recordings: + coords: list[float] | None = None + + if vetting_enabled: + # When vetting is enabled, we only show the centroid of the + # GRTS cell and not the direct recording location. + if rec.grts_cell_id is not None: + coords = centroids_by_cell_id.get(rec.grts_cell_id) + # If we can't resolve a centroid, fall back to recording_location. + if coords is None: + coords = _get_recording_location_coords(rec) + else: + coords = _get_recording_location_coords(rec) + if coords is None and rec.grts_cell_id is not None: + coords = centroids_by_cell_id.get(rec.grts_cell_id) + + if coords is None: + continue + + features.append( + { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": coords}, + "properties": { + "recording_id": rec.id, + "filename": str(rec.audio_file), + }, + } + ) + + return {"type": "FeatureCollection", "features": features} From 649eacd176950a80d9f76a4ce5fb2169a663dbc2 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Wed, 1 Apr 2026 07:45:46 -0400 Subject: [PATCH 2/3] add bbox to neighbors schema --- bats_ai/core/views/recording.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bats_ai/core/views/recording.py b/bats_ai/core/views/recording.py index 62056286..7cfada1a 100644 --- a/bats_ai/core/views/recording.py +++ b/bats_ai/core/views/recording.py @@ -83,7 +83,8 @@ class UnsubmittedNeighborsQuerySchema(Schema): ) = "created" sort_direction: Literal["asc", "desc"] | None = "desc" tags: str | None = None # Comma-separated tag texts; recording must have all listed tags - + # [min_lon, min_lat, max_lon, max_lat] as JSON array or comma-separated (see _parse_bbox). + bbox: str | None = None class UnsubmittedNeighborsResponse(Schema): """Response for unsubmitted neighbors: next and previous recording IDs in the vetting order.""" From 981c43364a4dbd782e9f9dbc8a60497ae4681078 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Wed, 1 Apr 2026 07:48:01 -0400 Subject: [PATCH 3/3] linting --- bats_ai/core/views/recording.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bats_ai/core/views/recording.py b/bats_ai/core/views/recording.py index 7cfada1a..c6b80da3 100644 --- a/bats_ai/core/views/recording.py +++ b/bats_ai/core/views/recording.py @@ -86,6 +86,7 @@ class UnsubmittedNeighborsQuerySchema(Schema): # [min_lon, min_lat, max_lon, max_lat] as JSON array or comma-separated (see _parse_bbox). bbox: str | None = None + class UnsubmittedNeighborsResponse(Schema): """Response for unsubmitted neighbors: next and previous recording IDs in the vetting order."""