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..bc7158b4 --- /dev/null +++ b/bats_ai/core/admin/recording_tag.py @@ -0,0 +1,25 @@ +from django.contrib import admin +from django.db.models import Count + +from bats_ai.core.models import RecordingTag + + +@admin.register(RecordingTag) +class RecordingTagAdmin(admin.ModelAdmin): + list_display = ( + 'id', + 'user', + 'text', + 'recording_count', + ) + search_fields = ('id', 'user', 'text') + + def get_queryset(self, request): + qs = super().get_queryset(request) + return qs.annotate(_recording_count=Count('recording')) + + def recording_count(self, obj): + return obj._recording_count + + recording_count.short_description = 'Number of recordings' + recording_count.admin_order_field = '_recording_count' diff --git a/bats_ai/core/migrations/0023_recordingtag_recording_tags_and_more.py b/bats_ai/core/migrations/0023_recordingtag_recording_tags_and_more.py new file mode 100644 index 00000000..69abb8a4 --- /dev/null +++ b/bats_ai/core/migrations/0023_recordingtag_recording_tags_and_more.py @@ -0,0 +1,45 @@ +# Generated by Django 4.2.23 on 2025-12-01 20:32 + +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='tags', + field=models.ManyToManyField(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..4f81e7b0 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) + tags = models.ManyToManyField(RecordingTag) @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..d4b5552f 100644 --- a/bats_ai/core/views/recording.py +++ b/bats_ai/core/views/recording.py @@ -1,9 +1,11 @@ from datetime import datetime import json import logging +from typing import List, Optional from django.contrib.auth.models import User from django.contrib.gis.geos import Point +from django.contrib.postgres.aggregates import ArrayAgg from django.core.files.storage import default_storage from django.db.models import Q from django.http import HttpRequest @@ -16,9 +18,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,23 +47,25 @@ class RecordingSchema(Schema): recording_location: str | None grts_cell_id: int | None grts_cell: int | None + tags: list[RecordingTagSchema] = [] class RecordingUploadSchema(Schema): name: str recorded_date: str 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 + equipment: str | None = None + comments: str | None = None + latitude: float | None = None + longitude: float | None = None + gridCellId: int | None = None + publicVal: bool | None = None + site_name: str | None = None + software: str | None = None + detector: str | None = None + species_list: str | None = None + unusual_occurrences: str | None = None + tags: Optional[List[str]] = None class RecordingAnnotationSchema(Schema): @@ -150,6 +156,12 @@ def create_recording( species_list=payload.species_list, unusual_occurrences=payload.unusual_occurrences, ) + recording.save() + + if payload.tags: + for tag in payload.tags: + tag, _ = RecordingTag.objects.get_or_create(user=request.user, text=tag) + recording.tags.add(tag) recording.save() # Start generating recording as soon as created @@ -193,6 +205,16 @@ 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.tags: + existing_tags = recording.tags.all() + for tag in recording_data.tags: + tag, _ = RecordingTag.objects.get_or_create(user=request.user, text=tag) + if tag not in existing_tags: + recording.tags.add(tag) + # Remove any tags that are not in the updated list + for existing_tag in existing_tags: + if existing_tag.text not in recording_data.tags: + recording.tags.remove(existing_tag) recording.save() @@ -230,10 +252,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(tags_text=ArrayAgg('tags__text')) .values() ) else: - recordings = Recording.objects.filter(owner=request.user).values() + recordings = ( + Recording.objects.filter(owner=request.user) + .annotate(tags_text=ArrayAgg('tags__text')) + .values() + ) # TODO with larger dataset it may be better to do this in a queryset instead of python for recording in recordings: @@ -270,7 +297,9 @@ def get_recordings(request: HttpRequest, public: bool | None = None): def get_recording(request: HttpRequest, id: int): # Filter recordings based on the owner's id or public=True try: - recordings = Recording.objects.filter(pk=id).values() + recordings = ( + Recording.objects.filter(pk=id).annotate(tags_text=ArrayAgg('tags__text')).values() + ) if len(recordings) > 0: recording = recordings[0] @@ -312,7 +341,6 @@ def get_recording(request: HttpRequest, id: int): RecordingAnnotationSchema.from_orm(fileAnnotation).dict() for fileAnnotation in fileAnnotations ] - return recording else: return {'error': 'Recording not found'} 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()) diff --git a/client/src/api/api.ts b/client/src/api/api.ts index 0b65104d..3b13e655 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; + tags_text?: string[]; } export interface Species { @@ -154,6 +155,7 @@ export interface RecordingFileParameters { detector?: string; species_list?: string; unusual_occurrences?: string; + tags?: string[]; } async function uploadRecordingFile(file: File, params: RecordingFileParameters) { @@ -189,6 +191,9 @@ async function uploadRecordingFile(file: File, params: RecordingFileParameters) if (params.unusual_occurrences) { formData.append("unusual_occurrences", params.unusual_occurrences); } + if (params.tags) { + params.tags.forEach((tag: string) => formData.append("tags", tag)); + } const recordingParams = { name: params.name, equipment: params.equipment, @@ -198,6 +203,7 @@ async function uploadRecordingFile(file: File, params: RecordingFileParameters) detector: params.detector, species_list: params.species_list, unusual_occurrences: params.unusual_occurrences, + tags: params.tags, }; const payloadBlob = new Blob([JSON.stringify(recordingParams)], { type: "application/json" }); formData.append("payload", payloadBlob); @@ -226,6 +232,7 @@ async function patchRecording(recordingId: number, params: RecordingFileParamete latitude, longitude, gridCellId, + tags: params.tags, site_name: params.site_name, software: params.software, detector: params.detector, @@ -250,12 +257,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 +539,5 @@ export { getFilteredProcessingTasks, getFileAnnotationDetails, getExportStatus, + getRecordingTags, }; diff --git a/client/src/components/BatchRecordingElement.vue b/client/src/components/BatchRecordingElement.vue index ad50148c..75379ae8 100644 --- a/client/src/components/BatchRecordingElement.vue +++ b/client/src/components/BatchRecordingElement.vue @@ -1,10 +1,11 @@