Skip to content

Commit a3feff3

Browse files
authored
Allow Users to Submit Annotations (#281)
* Add settings for vetting * Model submission status of recording annotations * Add endpoint to get current user * Show recording submission status in table view * Don't filter out noise The model can predict noise, so sorting it out of the species list can lead to problems when loading/displaying that value in a list. * Add endpoint to submit file-level annotations * Allow submitting file annotations in interface * Squash migrations * Indicate when a file has been reviewed * Format * Show the current user's submitted label in sidebar * Disable deletion for non-admin vetters * Make 403 message more descriptive
1 parent 561df37 commit a3feff3

16 files changed

+413
-28
lines changed

bats_ai/core/admin/recording_annotations.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ class RecordingAnnotationAdmin(admin.ModelAdmin):
1414
'additional_data',
1515
'comments',
1616
'model',
17+
'submitted',
1718
]
1819
list_select_related = True
1920
filter_horizontal = ('species',) # or filter_vertical
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Generated by Django 4.2.23 on 2025-12-23 20:18
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('core', '0023_recordingtag_recording_tags_and_more'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='configuration',
15+
name='mark_annotations_completed_enabled',
16+
field=models.BooleanField(default=False),
17+
),
18+
migrations.AddField(
19+
model_name='configuration',
20+
name='non_admin_upload_enabled',
21+
field=models.BooleanField(default=True),
22+
),
23+
migrations.AddField(
24+
model_name='configuration',
25+
name='show_my_recordings',
26+
field=models.BooleanField(default=True),
27+
),
28+
migrations.AddField(
29+
model_name='recordingannotation',
30+
name='submitted',
31+
field=models.BooleanField(default=False),
32+
),
33+
]

bats_ai/core/models/configuration.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ class AvailableColorScheme(models.TextChoices):
3232
# 18 characters is just enough for "rgb(255, 255, 255)"
3333
default_spectrogram_background_color = models.CharField(max_length=18, default='rgb(0, 0, 0)')
3434

35+
# Fields used for community vetting focused deployment of BatAI
36+
non_admin_upload_enabled = models.BooleanField(default=True)
37+
mark_annotations_completed_enabled = models.BooleanField(default=False)
38+
show_my_recordings = models.BooleanField(default=True)
39+
3540
def save(self, *args, **kwargs):
3641
# Ensure only one instance of Configuration exists
3742
if not Configuration.objects.exists() and not self.pk:

bats_ai/core/models/recording_annotation.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ class RecordingAnnotation(TimeStampedModel, models.Model):
1212
owner = models.ForeignKey(User, on_delete=models.CASCADE)
1313
species = models.ManyToManyField(Species)
1414
comments = models.TextField(blank=True, null=True)
15-
model = models.TextField(blank=True, null=True) # AI Model information if inference used
15+
# AI Model information if inference used, else "User Defined"
16+
model = models.TextField(blank=True, null=True)
1617
confidence = models.FloatField(
1718
default=1.0,
1819
validators=[
@@ -24,3 +25,4 @@ class RecordingAnnotation(TimeStampedModel, models.Model):
2425
additional_data = models.JSONField(
2526
blank=True, null=True, help_text='Additional information about the models/data'
2627
)
28+
submitted = models.BooleanField(default=False)

bats_ai/core/views/configuration.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ class ConfigurationSchema(Schema):
2222
spectrogram_view: Configuration.SpectrogramViewMode
2323
default_color_scheme: Configuration.AvailableColorScheme
2424
default_spectrogram_background_color: str
25+
non_admin_upload_enabled: bool
26+
mark_annotations_completed_enabled: bool
27+
show_my_recordings: bool
2528

2629

2730
# Endpoint to retrieve the configuration status
@@ -38,6 +41,9 @@ def get_configuration(request):
3841
spectrogram_view=config.spectrogram_view,
3942
default_color_scheme=config.default_color_scheme,
4043
default_spectrogram_background_color=config.default_spectrogram_background_color,
44+
non_admin_upload_enabled=config.non_admin_upload_enabled,
45+
mark_annotations_completed_enabled=config.mark_annotations_completed_enabled,
46+
show_my_recordings=config.show_my_recordings,
4147
is_admin=request.user.is_authenticated and request.user.is_superuser,
4248
)
4349

@@ -61,3 +67,13 @@ def check_is_admin(request):
6167
if request.user.is_authenticated:
6268
return {'is_admin': request.user.is_superuser}
6369
return {'is_admin': False}
70+
71+
72+
@router.get('/me')
73+
def get_current_user(request):
74+
if request.user.is_authenticated:
75+
return {
76+
'email': request.user.email,
77+
'name': request.user.username,
78+
}
79+
return {'email': '', 'name': ''}

bats_ai/core/views/recording.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ class RecordingAnnotationSchema(Schema):
7676
confidence: float
7777
id: int | None = None
7878
hasDetails: bool
79+
submitted: bool
7980

8081
@classmethod
8182
def from_orm(cls, obj: RecordingAnnotation, **kwargs):
@@ -87,6 +88,7 @@ def from_orm(cls, obj: RecordingAnnotation, **kwargs):
8788
model=obj.model,
8889
id=obj.pk,
8990
hasDetails=obj.additional_data is not None,
91+
submitted=obj.submitted,
9092
)
9193

9294

@@ -246,7 +248,9 @@ def delete_recording(
246248

247249

248250
@router.get('/')
249-
def get_recordings(request: HttpRequest, public: bool | None = None):
251+
def get_recordings(
252+
request: HttpRequest, public: bool | None = None, exclude_submitted: bool | None = None
253+
):
250254
# Filter recordings based on the owner's id or public=True
251255
if public is not None and public:
252256
recordings = (
@@ -290,6 +294,16 @@ def get_recordings(request: HttpRequest, public: bool | None = None):
290294
)
291295
recording['userMadeAnnotations'] = user_has_annotations
292296

297+
if exclude_submitted:
298+
recordings = [
299+
recording
300+
for recording in recordings
301+
if not any(
302+
annotation['submitted'] and annotation['owner'] == request.user.username
303+
for annotation in recording['fileAnnotations']
304+
)
305+
]
306+
293307
return list(recordings)
294308

295309

bats_ai/core/views/recording_annotation.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from ninja import Router, Schema
55
from ninja.errors import HttpError
66

7-
from bats_ai.core.models import Recording, RecordingAnnotation, Species
7+
from bats_ai.core.models import Configuration, Recording, RecordingAnnotation, Species
88
from bats_ai.core.views.recording import SpeciesSchema
99

1010
logger = logging.getLogger(__name__)
@@ -20,6 +20,7 @@ class RecordingAnnotationSchema(Schema):
2020
owner: str
2121
confidence: float
2222
id: int | None = None
23+
submitted: bool
2324
hasDetails: bool
2425

2526
@classmethod
@@ -32,9 +33,11 @@ def from_orm(cls, obj: RecordingAnnotation, **kwargs):
3233
model=obj.model,
3334
id=obj.pk,
3435
hasDetails=obj.additional_data is not None,
36+
submitted=obj.submitted,
3537
)
3638

3739

40+
# TODO: do we really need this? why can't we just always return the details?
3841
class RecordingAnnotationDetailsSchema(Schema):
3942
species: list[SpeciesSchema] | None
4043
comments: str | None = None
@@ -44,6 +47,7 @@ class RecordingAnnotationDetailsSchema(Schema):
4447
id: int | None = None
4548
details: dict
4649
hasDetails: bool
50+
submitted: bool
4751

4852
@classmethod
4953
def from_orm(cls, obj: RecordingAnnotation, **kwargs):
@@ -56,6 +60,7 @@ def from_orm(cls, obj: RecordingAnnotation, **kwargs):
5660
hasDetails=obj.additional_data is not None,
5761
details=obj.additional_data,
5862
id=obj.pk,
63+
submitted=obj.submitted,
5964
)
6065

6166

@@ -168,6 +173,15 @@ def update_recording_annotation(
168173
@router.delete('/{id}', response={200: str})
169174
def delete_recording_annotation(request: HttpRequest, id: int):
170175
try:
176+
configuration = Configuration.objects.first()
177+
vetting_enabled = (
178+
configuration.mark_annotations_completed_enabled if configuration else False
179+
)
180+
if vetting_enabled and not request.user.is_staff:
181+
raise HttpError(
182+
403, 'Permission denied. Annotations cannot be deleted while vetting is enabled'
183+
)
184+
171185
annotation = RecordingAnnotation.objects.get(pk=id)
172186

173187
# Check permission
@@ -178,3 +192,23 @@ def delete_recording_annotation(request: HttpRequest, id: int):
178192
return 'Recording annotation deleted successfully.'
179193
except RecordingAnnotation.DoesNotExist:
180194
raise HttpError(404, 'Recording annotation not found.')
195+
196+
197+
# Submit endpoint
198+
@router.patch('/{id}/submit', response={200: dict})
199+
def submit_recording_annotation(request: HttpRequest, id: int):
200+
try:
201+
annotation = RecordingAnnotation.objects.get(pk=id)
202+
203+
# Check permission
204+
if annotation.recording.owner != request.user:
205+
raise HttpError(403, 'Permission denied.')
206+
207+
annotation.submitted = True
208+
annotation.save()
209+
return {
210+
'id': id,
211+
'submitted': annotation.submitted,
212+
}
213+
except RecordingAnnotation.DoesNotExist:
214+
raise HttpError(404, 'Recording annotation not found.')

client/src/api/api.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ export interface FileAnnotation {
100100
confidence: number;
101101
hasDetails: boolean;
102102
id: number;
103+
submitted: boolean;
103104
}
104105

105106
export interface FileAnnotationDetails {
@@ -395,6 +396,12 @@ async function deleteFileAnnotation(fileAnnotationId: number) {
395396
);
396397
}
397398

399+
async function submitFileAnnotation(fileAnnotationId: number) {
400+
return axiosInstance.patch<{ id: number, submitted: boolean }>(
401+
`recording-annotation/${fileAnnotationId}/submit`
402+
);
403+
}
404+
398405
interface CellIDReponse {
399406
grid_cell_id?: number;
400407
error?: string;
@@ -414,6 +421,9 @@ export interface ConfigurationSettings {
414421
is_admin?: boolean;
415422
default_color_scheme: string;
416423
default_spectrogram_background_color: string;
424+
non_admin_upload_enabled: boolean;
425+
mark_annotations_completed_enabled: boolean;
426+
show_my_recordings: boolean;
417427
}
418428

419429
export type Configuration = ConfigurationSettings & { is_admin: boolean };
@@ -425,6 +435,10 @@ async function patchConfiguration(config: ConfigurationSettings) {
425435
return axiosInstance.patch("/configuration/", { ...config });
426436
}
427437

438+
async function getCurrentUser() {
439+
return axiosInstance.get<{name: string, email: string}>("/configuration/me");
440+
}
441+
428442
export interface ProcessingTask {
429443
id: number;
430444
created: string;
@@ -531,6 +545,7 @@ export {
531545
putFileAnnotation,
532546
patchFileAnnotation,
533547
deleteFileAnnotation,
548+
submitFileAnnotation,
534549
getConfiguration,
535550
patchConfiguration,
536551
getProcessingTasks,
@@ -540,4 +555,5 @@ export {
540555
getFileAnnotationDetails,
541556
getExportStatus,
542557
getRecordingTags,
558+
getCurrentUser,
543559
};

0 commit comments

Comments
 (0)