Skip to content
Merged
2 changes: 2 additions & 0 deletions bats_ai/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
ProcessingTaskRouter,
RecordingAnnotationRouter,
RecordingRouter,
RecordingTagRouter,
SpeciesRouter,
)
from bats_ai.core.views.nabat import NABatConfigurationRouter, NABatRecordingRouter
Expand Down Expand Up @@ -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)
2 changes: 2 additions & 0 deletions bats_ai/core/admin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -28,6 +29,7 @@
'GRTSCellsAdmin',
'CompressedSpectrogramAdmin',
'RecordingAnnotationAdmin',
'RecordingTagAdmin',
'ProcessingTaskAdmin',
'ConfigurationAdmin',
'ExportedAnnotationFileAdmin',
Expand Down
25 changes: 25 additions & 0 deletions bats_ai/core/admin/recording_tag.py
Original file line number Diff line number Diff line change
@@ -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'
Original file line number Diff line number Diff line change
@@ -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'
),
),
]
3 changes: 2 additions & 1 deletion bats_ai/core/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -17,6 +17,7 @@
'Annotations',
'Image',
'Recording',
'RecordingTag',
'RecordingAnnotationStatus',
'Species',
'Spectrogram',
Expand Down
16 changes: 14 additions & 2 deletions bats_ai/core/models/recording.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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):
Expand Down Expand Up @@ -70,8 +84,6 @@ def compressed_spectrograms(self):

@property
def compressed_spectrogram(self):
pass

compressed_spectrograms = self.compressed_spectrograms

assert len(compressed_spectrograms) >= 1
Expand Down
2 changes: 2 additions & 0 deletions bats_ai/core/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -20,4 +21,5 @@
'ConfigurationRouter',
'ProcessingTaskRouter',
'ExportAnnotationRouter',
'RecordingTagRouter',
]
56 changes: 42 additions & 14 deletions bats_ai/core/views/recording.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand All @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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]

Expand Down Expand Up @@ -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'}
Expand Down
21 changes: 21 additions & 0 deletions bats_ai/core/views/recording_tag.py
Original file line number Diff line number Diff line change
@@ -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())
17 changes: 17 additions & 0 deletions client/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export interface Recording {
detector?: string;
species_list?: string;
unusual_occurrences?: string;
tags_text?: string[];
}

export interface Species {
Expand Down Expand Up @@ -154,6 +155,7 @@ export interface RecordingFileParameters {
detector?: string;
species_list?: string;
unusual_occurrences?: string;
tags?: string[];
}

async function uploadRecordingFile(file: File, params: RecordingFileParameters) {
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -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,
Expand All @@ -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[]>(`/recording/?public=${getPublic}`);
}
async function getRecording(id: string) {
return axiosInstance.get<Recording>(`/recording/${id}/`);
}
async function getRecordingTags() {
return axiosInstance.get<RecordingTag[]>(`/recording-tag/`);
}

async function deleteRecording(id: number) {
return axiosInstance.delete<DeletionResponse>(`/recording/${id}`);
Expand Down Expand Up @@ -523,4 +539,5 @@ export {
getFilteredProcessingTasks,
getFileAnnotationDetails,
getExportStatus,
getRecordingTags,
};
Loading