Skip to content

Commit 508b305

Browse files
authored
Add recording tag model (#264)
* Add recording tag model * Include tag text in recordings response * Add tag to recording tables * Enable adding tag via recording edit button * Enable filtering by one or more tags * Save tags on upload * Format and linting * Fix typing for custom filter functions * Add recording count to recording tag admin * Make tag relationship many to many * Use the plural for tag in the UI * Squash migrations * Fix tag assignment in patch recording Co-authored-by: Bryon Lewis <[email protected]>
1 parent 83acdfb commit 508b305

File tree

15 files changed

+464
-98
lines changed

15 files changed

+464
-98
lines changed

bats_ai/api.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
ProcessingTaskRouter,
1212
RecordingAnnotationRouter,
1313
RecordingRouter,
14+
RecordingTagRouter,
1415
SpeciesRouter,
1516
)
1617
from bats_ai.core.views.nabat import NABatConfigurationRouter, NABatRecordingRouter
@@ -42,5 +43,6 @@ def global_auth(request):
4243
api.add_router('/export-annotation/', ExportAnnotationRouter)
4344
api.add_router('/configuration/', ConfigurationRouter)
4445
api.add_router('/processing-task/', ProcessingTaskRouter)
46+
api.add_router('/recording-tag/', RecordingTagRouter)
4547
api.add_router('/nabat/recording/', NABatRecordingRouter)
4648
api.add_router('/nabat/configuration/', NABatConfigurationRouter)

bats_ai/core/admin/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from .processing_task import ProcessingTaskAdmin
1414
from .recording import RecordingAdmin
1515
from .recording_annotations import RecordingAnnotationAdmin
16+
from .recording_tag import RecordingTagAdmin
1617
from .sequence_annotations import SequenceAnnotationsAdmin
1718
from .species import SpeciesAdmin
1819
from .spectrogram import SpectrogramAdmin
@@ -28,6 +29,7 @@
2829
'GRTSCellsAdmin',
2930
'CompressedSpectrogramAdmin',
3031
'RecordingAnnotationAdmin',
32+
'RecordingTagAdmin',
3133
'ProcessingTaskAdmin',
3234
'ConfigurationAdmin',
3335
'ExportedAnnotationFileAdmin',
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from django.contrib import admin
2+
from django.db.models import Count
3+
4+
from bats_ai.core.models import RecordingTag
5+
6+
7+
@admin.register(RecordingTag)
8+
class RecordingTagAdmin(admin.ModelAdmin):
9+
list_display = (
10+
'id',
11+
'user',
12+
'text',
13+
'recording_count',
14+
)
15+
search_fields = ('id', 'user', 'text')
16+
17+
def get_queryset(self, request):
18+
qs = super().get_queryset(request)
19+
return qs.annotate(_recording_count=Count('recording'))
20+
21+
def recording_count(self, obj):
22+
return obj._recording_count
23+
24+
recording_count.short_description = 'Number of recordings'
25+
recording_count.admin_order_field = '_recording_count'
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Generated by Django 4.2.23 on 2025-12-01 20:32
2+
3+
from django.conf import settings
4+
from django.db import migrations, models
5+
import django.db.models.deletion
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12+
('core', '0022_rename_temporalannotations_sequenceannotations'),
13+
]
14+
15+
operations = [
16+
migrations.CreateModel(
17+
name='RecordingTag',
18+
fields=[
19+
(
20+
'id',
21+
models.BigAutoField(
22+
auto_created=True, primary_key=True, serialize=False, verbose_name='ID'
23+
),
24+
),
25+
('text', models.CharField(max_length=50)),
26+
(
27+
'user',
28+
models.ForeignKey(
29+
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
30+
),
31+
),
32+
],
33+
),
34+
migrations.AddField(
35+
model_name='recording',
36+
name='tags',
37+
field=models.ManyToManyField(to='core.recordingtag'),
38+
),
39+
migrations.AddConstraint(
40+
model_name='recordingtag',
41+
constraint=models.UniqueConstraint(
42+
fields=('user', 'text'), name='unique_user_text_tag'
43+
),
44+
),
45+
]

bats_ai/core/models/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from .grts_cells import GRTSCells
66
from .image import Image
77
from .processing_task import ProcessingTask, ProcessingTaskType
8-
from .recording import Recording
8+
from .recording import Recording, RecordingTag
99
from .recording_annotation import RecordingAnnotation
1010
from .recording_annotation_status import RecordingAnnotationStatus
1111
from .sequence_annotations import SequenceAnnotations
@@ -17,6 +17,7 @@
1717
'Annotations',
1818
'Image',
1919
'Recording',
20+
'RecordingTag',
2021
'RecordingAnnotationStatus',
2122
'Species',
2223
'Spectrogram',

bats_ai/core/models/recording.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,19 @@
1010
logger = logging.getLogger(__name__)
1111

1212

13+
class RecordingTag(models.Model):
14+
user = models.ForeignKey(User, on_delete=models.CASCADE)
15+
text = models.CharField(max_length=50)
16+
17+
class Meta:
18+
constraints = [
19+
models.UniqueConstraint(fields=['user', 'text'], name='unique_user_text_tag')
20+
]
21+
22+
def __str__(self):
23+
return f'{self.text} ({self.user.username})'
24+
25+
1326
# TimeStampedModel also provides "created" and "modified" fields
1427
class Recording(TimeStampedModel, models.Model):
1528
name = models.CharField(max_length=255)
@@ -34,6 +47,7 @@ class Recording(TimeStampedModel, models.Model):
3447
Species, related_name='recording_official_species'
3548
) # species that are detemrined by the owner or from annotations as official species list
3649
unusual_occurrences = models.TextField(blank=True, null=True)
50+
tags = models.ManyToManyField(RecordingTag)
3751

3852
@property
3953
def has_spectrogram(self):
@@ -70,8 +84,6 @@ def compressed_spectrograms(self):
7084

7185
@property
7286
def compressed_spectrogram(self):
73-
pass
74-
7587
compressed_spectrograms = self.compressed_spectrograms
7688

7789
assert len(compressed_spectrograms) >= 1

bats_ai/core/views/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from .processing_tasks import router as ProcessingTaskRouter
77
from .recording import router as RecordingRouter
88
from .recording_annotation import router as RecordingAnnotationRouter
9+
from .recording_tag import router as RecordingTagRouter
910
from .sequence_annotations import router as SequenceAnnotationRouter
1011
from .species import router as SpeciesRouter
1112

@@ -20,4 +21,5 @@
2021
'ConfigurationRouter',
2122
'ProcessingTaskRouter',
2223
'ExportAnnotationRouter',
24+
'RecordingTagRouter',
2325
]

bats_ai/core/views/recording.py

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
from datetime import datetime
22
import json
33
import logging
4+
from typing import List, Optional
45

56
from django.contrib.auth.models import User
67
from django.contrib.gis.geos import Point
8+
from django.contrib.postgres.aggregates import ArrayAgg
79
from django.core.files.storage import default_storage
810
from django.db.models import Q
911
from django.http import HttpRequest
@@ -16,9 +18,11 @@
1618
CompressedSpectrogram,
1719
Recording,
1820
RecordingAnnotation,
21+
RecordingTag,
1922
SequenceAnnotations,
2023
Species,
2124
)
25+
from bats_ai.core.views.recording_tag import RecordingTagSchema
2226
from bats_ai.core.views.sequence_annotations import (
2327
SequenceAnnotationSchema,
2428
UpdateSequenceAnnotationSchema,
@@ -43,23 +47,25 @@ class RecordingSchema(Schema):
4347
recording_location: str | None
4448
grts_cell_id: int | None
4549
grts_cell: int | None
50+
tags: list[RecordingTagSchema] = []
4651

4752

4853
class RecordingUploadSchema(Schema):
4954
name: str
5055
recorded_date: str
5156
recorded_time: str
52-
equipment: str | None
53-
comments: str | None
54-
latitude: float = None
55-
longitude: float = None
56-
gridCellId: int = None
57-
publicVal: bool = None
58-
site_name: str = None
59-
software: str = None
60-
detector: str = None
61-
species_list: str = None
62-
unusual_occurrences: str = None
57+
equipment: str | None = None
58+
comments: str | None = None
59+
latitude: float | None = None
60+
longitude: float | None = None
61+
gridCellId: int | None = None
62+
publicVal: bool | None = None
63+
site_name: str | None = None
64+
software: str | None = None
65+
detector: str | None = None
66+
species_list: str | None = None
67+
unusual_occurrences: str | None = None
68+
tags: Optional[List[str]] = None
6369

6470

6571
class RecordingAnnotationSchema(Schema):
@@ -150,6 +156,12 @@ def create_recording(
150156
species_list=payload.species_list,
151157
unusual_occurrences=payload.unusual_occurrences,
152158
)
159+
recording.save()
160+
161+
if payload.tags:
162+
for tag in payload.tags:
163+
tag, _ = RecordingTag.objects.get_or_create(user=request.user, text=tag)
164+
recording.tags.add(tag)
153165

154166
recording.save()
155167
# Start generating recording as soon as created
@@ -193,6 +205,16 @@ def update_recording(request: HttpRequest, id: int, recording_data: RecordingUpl
193205
recording.species_list = recording_data.species_list
194206
if recording_data.unusual_occurrences:
195207
recording.unusual_occurrences = recording_data.unusual_occurrences
208+
if recording_data.tags:
209+
existing_tags = recording.tags.all()
210+
for tag in recording_data.tags:
211+
tag, _ = RecordingTag.objects.get_or_create(user=request.user, text=tag)
212+
if tag not in existing_tags:
213+
recording.tags.add(tag)
214+
# Remove any tags that are not in the updated list
215+
for existing_tag in existing_tags:
216+
if existing_tag.text not in recording_data.tags:
217+
recording.tags.remove(existing_tag)
196218

197219
recording.save()
198220

@@ -230,10 +252,15 @@ def get_recordings(request: HttpRequest, public: bool | None = None):
230252
recordings = (
231253
Recording.objects.filter(public=True)
232254
.exclude(Q(owner=request.user) | Q(spectrogram__isnull=True))
255+
.annotate(tags_text=ArrayAgg('tags__text'))
233256
.values()
234257
)
235258
else:
236-
recordings = Recording.objects.filter(owner=request.user).values()
259+
recordings = (
260+
Recording.objects.filter(owner=request.user)
261+
.annotate(tags_text=ArrayAgg('tags__text'))
262+
.values()
263+
)
237264

238265
# TODO with larger dataset it may be better to do this in a queryset instead of python
239266
for recording in recordings:
@@ -270,7 +297,9 @@ def get_recordings(request: HttpRequest, public: bool | None = None):
270297
def get_recording(request: HttpRequest, id: int):
271298
# Filter recordings based on the owner's id or public=True
272299
try:
273-
recordings = Recording.objects.filter(pk=id).values()
300+
recordings = (
301+
Recording.objects.filter(pk=id).annotate(tags_text=ArrayAgg('tags__text')).values()
302+
)
274303
if len(recordings) > 0:
275304
recording = recordings[0]
276305

@@ -312,7 +341,6 @@ def get_recording(request: HttpRequest, id: int):
312341
RecordingAnnotationSchema.from_orm(fileAnnotation).dict()
313342
for fileAnnotation in fileAnnotations
314343
]
315-
316344
return recording
317345
else:
318346
return {'error': 'Recording not found'}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from django.http import Http404, HttpRequest
2+
from ninja import Schema
3+
from ninja.pagination import RouterPaginated
4+
5+
from bats_ai.core.models import RecordingTag
6+
7+
8+
class RecordingTagSchema(Schema):
9+
text: str
10+
user: int
11+
12+
13+
router = RouterPaginated()
14+
15+
16+
@router.get('/')
17+
def get_recording_tags(request: HttpRequest):
18+
user = request.user
19+
if not user:
20+
return Http404()
21+
return list(RecordingTag.objects.filter(user=request.user).values())

client/src/api/api.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export interface Recording {
2727
detector?: string;
2828
species_list?: string;
2929
unusual_occurrences?: string;
30+
tags_text?: string[];
3031
}
3132

3233
export interface Species {
@@ -154,6 +155,7 @@ export interface RecordingFileParameters {
154155
detector?: string;
155156
species_list?: string;
156157
unusual_occurrences?: string;
158+
tags?: string[];
157159
}
158160

159161
async function uploadRecordingFile(file: File, params: RecordingFileParameters) {
@@ -189,6 +191,9 @@ async function uploadRecordingFile(file: File, params: RecordingFileParameters)
189191
if (params.unusual_occurrences) {
190192
formData.append("unusual_occurrences", params.unusual_occurrences);
191193
}
194+
if (params.tags) {
195+
params.tags.forEach((tag: string) => formData.append("tags", tag));
196+
}
192197
const recordingParams = {
193198
name: params.name,
194199
equipment: params.equipment,
@@ -198,6 +203,7 @@ async function uploadRecordingFile(file: File, params: RecordingFileParameters)
198203
detector: params.detector,
199204
species_list: params.species_list,
200205
unusual_occurrences: params.unusual_occurrences,
206+
tags: params.tags,
201207
};
202208
const payloadBlob = new Blob([JSON.stringify(recordingParams)], { type: "application/json" });
203209
formData.append("payload", payloadBlob);
@@ -226,6 +232,7 @@ async function patchRecording(recordingId: number, params: RecordingFileParamete
226232
latitude,
227233
longitude,
228234
gridCellId,
235+
tags: params.tags,
229236
site_name: params.site_name,
230237
software: params.software,
231238
detector: params.detector,
@@ -250,12 +257,21 @@ interface GRTSCellCenter {
250257
error?: string;
251258
}
252259

260+
export interface RecordingTag {
261+
id: number;
262+
text: string;
263+
user_id: number;
264+
}
265+
253266
async function getRecordings(getPublic = false) {
254267
return axiosInstance.get<Recording[]>(`/recording/?public=${getPublic}`);
255268
}
256269
async function getRecording(id: string) {
257270
return axiosInstance.get<Recording>(`/recording/${id}/`);
258271
}
272+
async function getRecordingTags() {
273+
return axiosInstance.get<RecordingTag[]>(`/recording-tag/`);
274+
}
259275

260276
async function deleteRecording(id: number) {
261277
return axiosInstance.delete<DeletionResponse>(`/recording/${id}`);
@@ -523,4 +539,5 @@ export {
523539
getFilteredProcessingTasks,
524540
getFileAnnotationDetails,
525541
getExportStatus,
542+
getRecordingTags,
526543
};

0 commit comments

Comments
 (0)