From 5e3a492499000755be3530dee56b3c6f24ccc52e Mon Sep 17 00:00:00 2001 From: naglepuff Date: Mon, 10 Nov 2025 16:38:16 -0500 Subject: [PATCH 01/16] Add recording tag model --- bats_ai/api.py | 2 ++ bats_ai/core/admin/__init__.py | 2 ++ bats_ai/core/admin/recording_tag.py | 13 ++++++++ ...023_recordingtag_recording_tag_and_more.py | 33 +++++++++++++++++++ bats_ai/core/models/__init__.py | 3 +- bats_ai/core/models/recording.py | 16 +++++++-- bats_ai/core/views/__init__.py | 2 ++ bats_ai/core/views/recording.py | 28 +++++++++++----- bats_ai/core/views/recording_tag.py | 21 ++++++++++++ 9 files changed, 108 insertions(+), 12 deletions(-) create mode 100644 bats_ai/core/admin/recording_tag.py create mode 100644 bats_ai/core/migrations/0023_recordingtag_recording_tag_and_more.py create mode 100644 bats_ai/core/views/recording_tag.py diff --git a/bats_ai/api.py b/bats_ai/api.py index f3599a92..4cd1c0bb 100644 --- a/bats_ai/api.py +++ b/bats_ai/api.py @@ -11,6 +11,7 @@ ProcessingTaskRouter, RecordingAnnotationRouter, RecordingRouter, + RecordingTagRouter, SpeciesRouter, ) from bats_ai.core.views.nabat import NABatConfigurationRouter, NABatRecordingRouter @@ -42,5 +43,6 @@ def global_auth(request): api.add_router('/export-annotation/', ExportAnnotationRouter) api.add_router('/configuration/', ConfigurationRouter) api.add_router('/processing-task/', ProcessingTaskRouter) +api.add_router('/recording-tag/', RecordingTagRouter) api.add_router('/nabat/recording/', NABatRecordingRouter) api.add_router('/nabat/configuration/', NABatConfigurationRouter) diff --git a/bats_ai/core/admin/__init__.py b/bats_ai/core/admin/__init__.py index 7fe6cc7c..fe5a419e 100644 --- a/bats_ai/core/admin/__init__.py +++ b/bats_ai/core/admin/__init__.py @@ -13,6 +13,7 @@ from .processing_task import ProcessingTaskAdmin from .recording import RecordingAdmin from .recording_annotations import RecordingAnnotationAdmin +from .recording_tag import RecordingTagAdmin from .sequence_annotations import SequenceAnnotationsAdmin from .species import SpeciesAdmin from .spectrogram import SpectrogramAdmin @@ -28,6 +29,7 @@ 'GRTSCellsAdmin', 'CompressedSpectrogramAdmin', 'RecordingAnnotationAdmin', + 'RecordingTagAdmin', 'ProcessingTaskAdmin', 'ConfigurationAdmin', 'ExportedAnnotationFileAdmin', diff --git a/bats_ai/core/admin/recording_tag.py b/bats_ai/core/admin/recording_tag.py new file mode 100644 index 00000000..fc1cfa02 --- /dev/null +++ b/bats_ai/core/admin/recording_tag.py @@ -0,0 +1,13 @@ +from django.contrib import admin + +from bats_ai.core.models import RecordingTag + + +@admin.register(RecordingTag) +class RecordingTagAdmin(admin.ModelAdmin): + list_display = ( + 'id', + 'user', + 'text', + ) + search_fields = ('id', 'user', 'text') diff --git a/bats_ai/core/migrations/0023_recordingtag_recording_tag_and_more.py b/bats_ai/core/migrations/0023_recordingtag_recording_tag_and_more.py new file mode 100644 index 00000000..3148f0a3 --- /dev/null +++ b/bats_ai/core/migrations/0023_recordingtag_recording_tag_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.23 on 2025-11-11 16:17 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('core', '0022_rename_temporalannotations_sequenceannotations'), + ] + + operations = [ + migrations.CreateModel( + name='RecordingTag', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('text', models.CharField(max_length=50)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddField( + model_name='recording', + name='tag', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.DO_NOTHING, to='core.recordingtag'), + ), + migrations.AddConstraint( + model_name='recordingtag', + constraint=models.UniqueConstraint(fields=('user', 'text'), name='unique_user_text_tag'), + ), + ] diff --git a/bats_ai/core/models/__init__.py b/bats_ai/core/models/__init__.py index 487fe7e4..ad11fab9 100644 --- a/bats_ai/core/models/__init__.py +++ b/bats_ai/core/models/__init__.py @@ -5,7 +5,7 @@ from .grts_cells import GRTSCells from .image import Image from .processing_task import ProcessingTask, ProcessingTaskType -from .recording import Recording +from .recording import Recording, RecordingTag from .recording_annotation import RecordingAnnotation from .recording_annotation_status import RecordingAnnotationStatus from .sequence_annotations import SequenceAnnotations @@ -17,6 +17,7 @@ 'Annotations', 'Image', 'Recording', + 'RecordingTag', 'RecordingAnnotationStatus', 'Species', 'Spectrogram', diff --git a/bats_ai/core/models/recording.py b/bats_ai/core/models/recording.py index c7c198a3..8ff6aa4f 100644 --- a/bats_ai/core/models/recording.py +++ b/bats_ai/core/models/recording.py @@ -10,6 +10,19 @@ logger = logging.getLogger(__name__) +class RecordingTag(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE) + text = models.CharField(max_length=50) + + class Meta: + constraints = [ + models.UniqueConstraint(fields=['user', 'text'], name='unique_user_text_tag') + ] + + def __str__(self): + return f'{self.text} ({self.user.username})' + + # TimeStampedModel also provides "created" and "modified" fields class Recording(TimeStampedModel, models.Model): name = models.CharField(max_length=255) @@ -34,6 +47,7 @@ class Recording(TimeStampedModel, models.Model): Species, related_name='recording_official_species' ) # species that are detemrined by the owner or from annotations as official species list unusual_occurrences = models.TextField(blank=True, null=True) + tag = models.ForeignKey(RecordingTag, on_delete=models.DO_NOTHING, null=True) @property def has_spectrogram(self): @@ -70,8 +84,6 @@ def compressed_spectrograms(self): @property def compressed_spectrogram(self): - pass - compressed_spectrograms = self.compressed_spectrograms assert len(compressed_spectrograms) >= 1 diff --git a/bats_ai/core/views/__init__.py b/bats_ai/core/views/__init__.py index e50c46c8..0be8ef80 100644 --- a/bats_ai/core/views/__init__.py +++ b/bats_ai/core/views/__init__.py @@ -6,6 +6,7 @@ from .processing_tasks import router as ProcessingTaskRouter from .recording import router as RecordingRouter from .recording_annotation import router as RecordingAnnotationRouter +from .recording_tag import router as RecordingTagRouter from .sequence_annotations import router as SequenceAnnotationRouter from .species import router as SpeciesRouter @@ -20,4 +21,5 @@ 'ConfigurationRouter', 'ProcessingTaskRouter', 'ExportAnnotationRouter', + 'RecordingTagRouter', ] diff --git a/bats_ai/core/views/recording.py b/bats_ai/core/views/recording.py index 536c3c2e..235137ab 100644 --- a/bats_ai/core/views/recording.py +++ b/bats_ai/core/views/recording.py @@ -16,9 +16,11 @@ CompressedSpectrogram, Recording, RecordingAnnotation, + RecordingTag, SequenceAnnotations, Species, ) +from bats_ai.core.views.recording_tag import RecordingTagSchema from bats_ai.core.views.sequence_annotations import ( SequenceAnnotationSchema, UpdateSequenceAnnotationSchema, @@ -43,6 +45,7 @@ class RecordingSchema(Schema): recording_location: str | None grts_cell_id: int | None grts_cell: int | None + tag: RecordingTagSchema | None class RecordingUploadSchema(Schema): @@ -51,15 +54,16 @@ class RecordingUploadSchema(Schema): recorded_time: str equipment: str | None comments: str | None - latitude: float = None - longitude: float = None - gridCellId: int = None - publicVal: bool = None - site_name: str = None - software: str = None - detector: str = None - species_list: str = None - unusual_occurrences: str = None + latitude: float | None + longitude: float | None + gridCellId: int | None + publicVal: bool | None + site_name: str | None + software: str | None + detector: str | None + species_list: str | None + unusual_occurrences: str | None + tag: str | None class RecordingAnnotationSchema(Schema): @@ -150,6 +154,9 @@ def create_recording( species_list=payload.species_list, unusual_occurrences=payload.unusual_occurrences, ) + if payload.tag: + tag, _ = RecordingTag.objects.get_or_create(user=request.user, text=payload.tag) + recording.tag = tag recording.save() # Start generating recording as soon as created @@ -193,6 +200,9 @@ def update_recording(request: HttpRequest, id: int, recording_data: RecordingUpl recording.species_list = recording_data.species_list if recording_data.unusual_occurrences: recording.unusual_occurrences = recording_data.unusual_occurrences + if recording_data.tag: + tag, _ = RecordingTag.objects.get_or_create(user=request.user, text=recording_data.tag) + recording.tag = tag recording.save() diff --git a/bats_ai/core/views/recording_tag.py b/bats_ai/core/views/recording_tag.py new file mode 100644 index 00000000..52903fb5 --- /dev/null +++ b/bats_ai/core/views/recording_tag.py @@ -0,0 +1,21 @@ +from django.http import Http404, HttpRequest +from ninja import Schema +from ninja.pagination import RouterPaginated + +from bats_ai.core.models import RecordingTag + + +class RecordingTagSchema(Schema): + text: str + user: int + + +router = RouterPaginated() + + +@router.get('/') +def get_recording_tags(request: HttpRequest): + user = request.user + if not user: + return Http404() + return list(RecordingTag.objects.filter(user=request.user).values()) From 21bf3d26204c62195424b2ae41e242b16f02f310 Mon Sep 17 00:00:00 2001 From: naglepuff Date: Tue, 11 Nov 2025 14:58:09 -0500 Subject: [PATCH 02/16] Include tag text in recordings response --- bats_ai/core/views/recording.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/bats_ai/core/views/recording.py b/bats_ai/core/views/recording.py index 235137ab..3c073aff 100644 --- a/bats_ai/core/views/recording.py +++ b/bats_ai/core/views/recording.py @@ -5,7 +5,7 @@ from django.contrib.auth.models import User from django.contrib.gis.geos import Point from django.core.files.storage import default_storage -from django.db.models import Q +from django.db.models import F, Q from django.http import HttpRequest from ninja import File, Form, Schema from ninja.files import UploadedFile @@ -240,10 +240,15 @@ def get_recordings(request: HttpRequest, public: bool | None = None): recordings = ( Recording.objects.filter(public=True) .exclude(Q(owner=request.user) | Q(spectrogram__isnull=True)) + .annotate(tag_text=F('tag__text')) .values() ) else: - recordings = Recording.objects.filter(owner=request.user).values() + recordings = ( + Recording.objects.filter(owner=request.user) + .annotate(tag_text=F('tag__text')) + .values() + ) # TODO with larger dataset it may be better to do this in a queryset instead of python for recording in recordings: From fd285cdceccdeefe6262658513d8df133238a7f7 Mon Sep 17 00:00:00 2001 From: naglepuff Date: Tue, 11 Nov 2025 15:34:43 -0500 Subject: [PATCH 03/16] Add tag to recording tables --- client/src/api/api.ts | 12 ++ client/src/components/UploadRecording.vue | 31 ++++- client/src/use/useState.ts | 3 + client/src/views/Recordings.vue | 146 ++++++++++++++-------- 4 files changed, 134 insertions(+), 58 deletions(-) diff --git a/client/src/api/api.ts b/client/src/api/api.ts index 0b65104d..b5540452 100644 --- a/client/src/api/api.ts +++ b/client/src/api/api.ts @@ -27,6 +27,7 @@ export interface Recording { detector?: string; species_list?: string; unusual_occurrences?: string; + tag_text: string | null; } export interface Species { @@ -154,6 +155,7 @@ export interface RecordingFileParameters { detector?: string; species_list?: string; unusual_occurrences?: string; + tag?: string; } async function uploadRecordingFile(file: File, params: RecordingFileParameters) { @@ -250,12 +252,21 @@ interface GRTSCellCenter { error?: string; } +export interface RecordingTag { + id: number; + text: string; + user_id: number; +} + async function getRecordings(getPublic = false) { return axiosInstance.get(`/recording/?public=${getPublic}`); } async function getRecording(id: string) { return axiosInstance.get(`/recording/${id}/`); } +async function getRecordingTags() { + return axiosInstance.get(`/recording-tag/`); +} async function deleteRecording(id: number) { return axiosInstance.delete(`/recording/${id}`); @@ -523,4 +534,5 @@ export { getFilteredProcessingTasks, getFileAnnotationDetails, getExportStatus, + getRecordingTags, }; diff --git a/client/src/components/UploadRecording.vue b/client/src/components/UploadRecording.vue index b97c8cca..851926ac 100644 --- a/client/src/components/UploadRecording.vue +++ b/client/src/components/UploadRecording.vue @@ -1,11 +1,19 @@ + + + diff --git a/client/src/components/geoJS/LayerManager.vue b/client/src/components/geoJS/LayerManager.vue index 266952a0..9b5fab6b 100644 --- a/client/src/components/geoJS/LayerManager.vue +++ b/client/src/components/geoJS/LayerManager.vue @@ -1,5 +1,5 @@