From a3feff32f028dcdb138bf4eaaa024ae5a2c464b1 Mon Sep 17 00:00:00 2001 From: Michael Nagler Date: Tue, 30 Dec 2025 14:56:46 -0500 Subject: [PATCH 01/10] 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 --- bats_ai/core/admin/recording_annotations.py | 1 + ..._annotations_completed_enabled_and_more.py | 33 +++++++ bats_ai/core/models/configuration.py | 5 ++ bats_ai/core/models/recording_annotation.py | 4 +- bats_ai/core/views/configuration.py | 16 ++++ bats_ai/core/views/recording.py | 16 +++- bats_ai/core/views/recording_annotation.py | 36 +++++++- client/src/api/api.ts | 16 ++++ .../components/RecordingAnnotationEditor.vue | 87 +++++++++++++++++-- .../src/components/RecordingAnnotations.vue | 38 +++++++- client/src/components/RecordingList.vue | 29 +++++-- client/src/components/SpeciesEditor.vue | 13 ++- client/src/use/useState.ts | 10 +++ client/src/views/Admin.vue | 56 +++++++++++- client/src/views/Recordings.vue | 77 +++++++++++++++- client/src/views/Spectrogram.vue | 4 +- 16 files changed, 413 insertions(+), 28 deletions(-) create mode 100644 bats_ai/core/migrations/0024_configuration_mark_annotations_completed_enabled_and_more.py diff --git a/bats_ai/core/admin/recording_annotations.py b/bats_ai/core/admin/recording_annotations.py index d83c31cb..705033e2 100644 --- a/bats_ai/core/admin/recording_annotations.py +++ b/bats_ai/core/admin/recording_annotations.py @@ -14,6 +14,7 @@ class RecordingAnnotationAdmin(admin.ModelAdmin): 'additional_data', 'comments', 'model', + 'submitted', ] list_select_related = True filter_horizontal = ('species',) # or filter_vertical diff --git a/bats_ai/core/migrations/0024_configuration_mark_annotations_completed_enabled_and_more.py b/bats_ai/core/migrations/0024_configuration_mark_annotations_completed_enabled_and_more.py new file mode 100644 index 00000000..110c58c7 --- /dev/null +++ b/bats_ai/core/migrations/0024_configuration_mark_annotations_completed_enabled_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.23 on 2025-12-23 20:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0023_recordingtag_recording_tags_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='configuration', + name='mark_annotations_completed_enabled', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='configuration', + name='non_admin_upload_enabled', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='configuration', + name='show_my_recordings', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='recordingannotation', + name='submitted', + field=models.BooleanField(default=False), + ), + ] diff --git a/bats_ai/core/models/configuration.py b/bats_ai/core/models/configuration.py index 774831d8..9592f0f2 100644 --- a/bats_ai/core/models/configuration.py +++ b/bats_ai/core/models/configuration.py @@ -32,6 +32,11 @@ class AvailableColorScheme(models.TextChoices): # 18 characters is just enough for "rgb(255, 255, 255)" default_spectrogram_background_color = models.CharField(max_length=18, default='rgb(0, 0, 0)') + # Fields used for community vetting focused deployment of BatAI + non_admin_upload_enabled = models.BooleanField(default=True) + mark_annotations_completed_enabled = models.BooleanField(default=False) + show_my_recordings = models.BooleanField(default=True) + def save(self, *args, **kwargs): # Ensure only one instance of Configuration exists if not Configuration.objects.exists() and not self.pk: diff --git a/bats_ai/core/models/recording_annotation.py b/bats_ai/core/models/recording_annotation.py index ab5d9b52..96dafb49 100644 --- a/bats_ai/core/models/recording_annotation.py +++ b/bats_ai/core/models/recording_annotation.py @@ -12,7 +12,8 @@ class RecordingAnnotation(TimeStampedModel, models.Model): owner = models.ForeignKey(User, on_delete=models.CASCADE) species = models.ManyToManyField(Species) comments = models.TextField(blank=True, null=True) - model = models.TextField(blank=True, null=True) # AI Model information if inference used + # AI Model information if inference used, else "User Defined" + model = models.TextField(blank=True, null=True) confidence = models.FloatField( default=1.0, validators=[ @@ -24,3 +25,4 @@ class RecordingAnnotation(TimeStampedModel, models.Model): additional_data = models.JSONField( blank=True, null=True, help_text='Additional information about the models/data' ) + submitted = models.BooleanField(default=False) diff --git a/bats_ai/core/views/configuration.py b/bats_ai/core/views/configuration.py index 5714688f..494beb58 100644 --- a/bats_ai/core/views/configuration.py +++ b/bats_ai/core/views/configuration.py @@ -22,6 +22,9 @@ class ConfigurationSchema(Schema): spectrogram_view: Configuration.SpectrogramViewMode default_color_scheme: Configuration.AvailableColorScheme default_spectrogram_background_color: str + non_admin_upload_enabled: bool + mark_annotations_completed_enabled: bool + show_my_recordings: bool # Endpoint to retrieve the configuration status @@ -38,6 +41,9 @@ def get_configuration(request): spectrogram_view=config.spectrogram_view, default_color_scheme=config.default_color_scheme, default_spectrogram_background_color=config.default_spectrogram_background_color, + non_admin_upload_enabled=config.non_admin_upload_enabled, + mark_annotations_completed_enabled=config.mark_annotations_completed_enabled, + show_my_recordings=config.show_my_recordings, is_admin=request.user.is_authenticated and request.user.is_superuser, ) @@ -61,3 +67,13 @@ def check_is_admin(request): if request.user.is_authenticated: return {'is_admin': request.user.is_superuser} return {'is_admin': False} + + +@router.get('/me') +def get_current_user(request): + if request.user.is_authenticated: + return { + 'email': request.user.email, + 'name': request.user.username, + } + return {'email': '', 'name': ''} diff --git a/bats_ai/core/views/recording.py b/bats_ai/core/views/recording.py index 0bd8f35d..5e5076a5 100644 --- a/bats_ai/core/views/recording.py +++ b/bats_ai/core/views/recording.py @@ -76,6 +76,7 @@ class RecordingAnnotationSchema(Schema): confidence: float id: int | None = None hasDetails: bool + submitted: bool @classmethod def from_orm(cls, obj: RecordingAnnotation, **kwargs): @@ -87,6 +88,7 @@ def from_orm(cls, obj: RecordingAnnotation, **kwargs): model=obj.model, id=obj.pk, hasDetails=obj.additional_data is not None, + submitted=obj.submitted, ) @@ -246,7 +248,9 @@ def delete_recording( @router.get('/') -def get_recordings(request: HttpRequest, public: bool | None = None): +def get_recordings( + request: HttpRequest, public: bool | None = None, exclude_submitted: bool | None = None +): # Filter recordings based on the owner's id or public=True if public is not None and public: recordings = ( @@ -290,6 +294,16 @@ def get_recordings(request: HttpRequest, public: bool | None = None): ) recording['userMadeAnnotations'] = user_has_annotations + if exclude_submitted: + recordings = [ + recording + for recording in recordings + if not any( + annotation['submitted'] and annotation['owner'] == request.user.username + for annotation in recording['fileAnnotations'] + ) + ] + return list(recordings) diff --git a/bats_ai/core/views/recording_annotation.py b/bats_ai/core/views/recording_annotation.py index e7924c63..d765d5c2 100644 --- a/bats_ai/core/views/recording_annotation.py +++ b/bats_ai/core/views/recording_annotation.py @@ -4,7 +4,7 @@ from ninja import Router, Schema from ninja.errors import HttpError -from bats_ai.core.models import Recording, RecordingAnnotation, Species +from bats_ai.core.models import Configuration, Recording, RecordingAnnotation, Species from bats_ai.core.views.recording import SpeciesSchema logger = logging.getLogger(__name__) @@ -20,6 +20,7 @@ class RecordingAnnotationSchema(Schema): owner: str confidence: float id: int | None = None + submitted: bool hasDetails: bool @classmethod @@ -32,9 +33,11 @@ def from_orm(cls, obj: RecordingAnnotation, **kwargs): model=obj.model, id=obj.pk, hasDetails=obj.additional_data is not None, + submitted=obj.submitted, ) +# TODO: do we really need this? why can't we just always return the details? class RecordingAnnotationDetailsSchema(Schema): species: list[SpeciesSchema] | None comments: str | None = None @@ -44,6 +47,7 @@ class RecordingAnnotationDetailsSchema(Schema): id: int | None = None details: dict hasDetails: bool + submitted: bool @classmethod def from_orm(cls, obj: RecordingAnnotation, **kwargs): @@ -56,6 +60,7 @@ def from_orm(cls, obj: RecordingAnnotation, **kwargs): hasDetails=obj.additional_data is not None, details=obj.additional_data, id=obj.pk, + submitted=obj.submitted, ) @@ -168,6 +173,15 @@ def update_recording_annotation( @router.delete('/{id}', response={200: str}) def delete_recording_annotation(request: HttpRequest, id: int): try: + configuration = Configuration.objects.first() + vetting_enabled = ( + configuration.mark_annotations_completed_enabled if configuration else False + ) + if vetting_enabled and not request.user.is_staff: + raise HttpError( + 403, 'Permission denied. Annotations cannot be deleted while vetting is enabled' + ) + annotation = RecordingAnnotation.objects.get(pk=id) # Check permission @@ -178,3 +192,23 @@ def delete_recording_annotation(request: HttpRequest, id: int): return 'Recording annotation deleted successfully.' except RecordingAnnotation.DoesNotExist: raise HttpError(404, 'Recording annotation not found.') + + +# Submit endpoint +@router.patch('/{id}/submit', response={200: dict}) +def submit_recording_annotation(request: HttpRequest, id: int): + try: + annotation = RecordingAnnotation.objects.get(pk=id) + + # Check permission + if annotation.recording.owner != request.user: + raise HttpError(403, 'Permission denied.') + + annotation.submitted = True + annotation.save() + return { + 'id': id, + 'submitted': annotation.submitted, + } + except RecordingAnnotation.DoesNotExist: + raise HttpError(404, 'Recording annotation not found.') diff --git a/client/src/api/api.ts b/client/src/api/api.ts index 3b13e655..246ce16d 100644 --- a/client/src/api/api.ts +++ b/client/src/api/api.ts @@ -100,6 +100,7 @@ export interface FileAnnotation { confidence: number; hasDetails: boolean; id: number; + submitted: boolean; } export interface FileAnnotationDetails { @@ -395,6 +396,12 @@ async function deleteFileAnnotation(fileAnnotationId: number) { ); } +async function submitFileAnnotation(fileAnnotationId: number) { + return axiosInstance.patch<{ id: number, submitted: boolean }>( + `recording-annotation/${fileAnnotationId}/submit` + ); +} + interface CellIDReponse { grid_cell_id?: number; error?: string; @@ -414,6 +421,9 @@ export interface ConfigurationSettings { is_admin?: boolean; default_color_scheme: string; default_spectrogram_background_color: string; + non_admin_upload_enabled: boolean; + mark_annotations_completed_enabled: boolean; + show_my_recordings: boolean; } export type Configuration = ConfigurationSettings & { is_admin: boolean }; @@ -425,6 +435,10 @@ async function patchConfiguration(config: ConfigurationSettings) { return axiosInstance.patch("/configuration/", { ...config }); } +async function getCurrentUser() { + return axiosInstance.get<{name: string, email: string}>("/configuration/me"); +} + export interface ProcessingTask { id: number; created: string; @@ -531,6 +545,7 @@ export { putFileAnnotation, patchFileAnnotation, deleteFileAnnotation, + submitFileAnnotation, getConfiguration, patchConfiguration, getProcessingTasks, @@ -540,4 +555,5 @@ export { getFileAnnotationDetails, getExportStatus, getRecordingTags, + getCurrentUser, }; diff --git a/client/src/components/RecordingAnnotationEditor.vue b/client/src/components/RecordingAnnotationEditor.vue index d731e511..59d2b062 100644 --- a/client/src/components/RecordingAnnotationEditor.vue +++ b/client/src/components/RecordingAnnotationEditor.vue @@ -1,8 +1,16 @@ + + diff --git a/client/src/use/useState.ts b/client/src/use/useState.ts index cd5fba69..d17f7ac8 100644 --- a/client/src/use/useState.ts +++ b/client/src/use/useState.ts @@ -12,6 +12,7 @@ import { SpectrogramSequenceAnnotation, RecordingTag, FileAnnotation, + getVettingDetailsForUser, } from "../api/api"; import { interpolateCividis, @@ -41,6 +42,7 @@ const colorScheme: Ref<{ value: string; title: string; scheme: (input: number) = const backgroundColor = ref("rgb(0, 0, 0)"); const selectedUsers: Ref = ref([]); const currentUser: Ref = ref(""); +const currentUserId: Ref = ref(undefined); const selectedId: Ref = ref(null); const selectedType: Ref<"pulse" | "sequence"> = ref("pulse"); const annotations: Ref = ref([]); @@ -85,6 +87,8 @@ const toggleFixedAxes = () => { fixedAxes.value = !fixedAxes.value; }; +const reviewerMaterials = ref(''); + type AnnotationState = "" | "editing" | "creating" | "disabled"; export default function useState() { const setAnnotationState = (state: AnnotationState) => { @@ -147,6 +151,7 @@ export default function useState() { async function loadCurrentUser() { const userInfo = (await getCurrentUser()).data; currentUser.value = userInfo.name; + currentUserId.value = userInfo.id; } /** @@ -288,6 +293,17 @@ export default function useState() { return undefined; }); + async function loadReviewerMaterials() { + // Only make this request if vetting is enabled and a user is logged in + if (!configuration.value.mark_annotations_completed_enabled) return; + if (currentUserId.value === undefined) return; + + const vettingDetails = await getVettingDetailsForUser(currentUserId.value); + if (vettingDetails) { + reviewerMaterials.value = vettingDetails.reference_materials; + } + } + return { annotationState, creationType, @@ -308,6 +324,7 @@ export default function useState() { setSelectedUsers, selectedUsers, currentUser, + currentUserId, setSelectedId, loadConfiguration, loadCurrentUser, @@ -341,5 +358,7 @@ export default function useState() { previousUnsubmittedRecordingId, markAnnotationSubmitted, currentRecordingId, + reviewerMaterials, + loadReviewerMaterials, }; } diff --git a/client/src/views/Recordings.vue b/client/src/views/Recordings.vue index 28d93cc5..e780b3c6 100644 --- a/client/src/views/Recordings.vue +++ b/client/src/views/Recordings.vue @@ -38,7 +38,6 @@ export default defineComponent({ recordingTagList, currentUser, configuration, - loadCurrentUser, showSubmittedRecordings, submittedMyRecordings, submittedSharedRecordings, @@ -288,7 +287,6 @@ export default defineComponent({ onMounted(async () => { addSubmittedColumns(); hideDetailedMetadataColumns(); - await loadCurrentUser(); await fetchRecordingTags(); await fetchRecordings(); }); diff --git a/client/src/views/Spectrogram.vue b/client/src/views/Spectrogram.vue index 5c4830a5..cc4f8d2a 100644 --- a/client/src/views/Spectrogram.vue +++ b/client/src/views/Spectrogram.vue @@ -25,8 +25,9 @@ import ThumbnailViewer from "@components/ThumbnailViewer.vue"; import RecordingList from "@components/RecordingList.vue"; import OtherUserAnnotationsDialog from "@/components/OtherUserAnnotationsDialog.vue"; import ColorSchemeDialog from "@/components/ColorSchemeDialog.vue"; -import useState from "@use/useState"; import RecordingInfoDialog from "@components/RecordingInfoDialog.vue"; +import ReferenceMaterialsDialog from "@/components/ReferenceMaterialsDialog.vue"; +import useState from "@use/useState"; export default defineComponent({ name: "Spectrogram", components: { @@ -37,6 +38,7 @@ export default defineComponent({ RecordingList, OtherUserAnnotationsDialog, ColorSchemeDialog, + ReferenceMaterialsDialog, }, props: { id: { @@ -656,6 +658,11 @@ export default defineComponent({ + + + + + Date: Thu, 15 Jan 2026 12:47:57 -0500 Subject: [PATCH 08/10] Vetting workflow feedback (#301) * Make return type compatable for column definition * Add scale widget to location map * Fix casing for kHz * Fix scrolling in spectrogram sidebar * Add tooltip to submission status * Ensure configuration is loaded before column check --- client/src/App.vue | 4 +- client/src/components/AnnotationList.vue | 123 ++++--- client/src/components/MapLocation.vue | 3 + .../src/components/RecordingAnnotations.vue | 10 - client/src/components/RecordingList.vue | 336 ++++++++++-------- .../src/components/geoJS/layers/axesLayer.ts | 2 +- .../geoJS/layers/boundingBoxLayer.ts | 4 +- .../geoJS/layers/measureToolLayer.ts | 2 +- client/src/views/Recordings.vue | 57 +-- client/src/views/Spectrogram.vue | 44 +-- 10 files changed, 317 insertions(+), 268 deletions(-) diff --git a/client/src/App.vue b/client/src/App.vue index c7d25f30..823ef327 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -32,7 +32,6 @@ export default defineComponent({ const checkLogin = async () => { if (oauthClient.isLoggedIn) { loginText.value = "Logout"; - await loadConfiguration(); await loadCurrentUser(); await loadReviewerMaterials(); if (sharedList.value.length === 0) { @@ -52,7 +51,8 @@ export default defineComponent({ oauthClient.redirectToLogin(); } }; - onMounted(() => { + onMounted(async () => { + await loadConfiguration(); checkLogin(); }); router.afterEach((guard) => { diff --git a/client/src/components/AnnotationList.vue b/client/src/components/AnnotationList.vue index dd89abf2..daa606ad 100644 --- a/client/src/components/AnnotationList.vue +++ b/client/src/components/AnnotationList.vue @@ -1,5 +1,5 @@ diff --git a/client/src/components/geoJS/layers/axesLayer.ts b/client/src/components/geoJS/layers/axesLayer.ts index 7b0218fe..03a5acb3 100644 --- a/client/src/components/geoJS/layers/axesLayer.ts +++ b/client/src/components/geoJS/layers/axesLayer.ts @@ -289,7 +289,7 @@ export default class AxesLayer extends BaseTextLayer { this.lineData.push(line); this.textData.push({ - text: `${(tick.value / 1000).toFixed(0)}KHz`, + text: `${(tick.value / 1000).toFixed(0)}kHz`, x: gcsTextStart, y, textAlign: 'left', diff --git a/client/src/components/geoJS/layers/boundingBoxLayer.ts b/client/src/components/geoJS/layers/boundingBoxLayer.ts index 86616116..d8bb31ad 100644 --- a/client/src/components/geoJS/layers/boundingBoxLayer.ts +++ b/client/src/components/geoJS/layers/boundingBoxLayer.ts @@ -118,14 +118,14 @@ export default class BoundingBoxLayer extends BaseTextLayer textBaseline: 'top', }, { - text: `${(lowFreq / 1000).toFixed(1)}KHz`, + text: `${(lowFreq / 1000).toFixed(1)}kHz`, x: coordinates[3][0] + 5, y: coordinates[3][1], textAlign: 'start', textBaseline: 'middle', }, { - text: `${(highFreq / 1000).toFixed(1)}KHz`, + text: `${(highFreq / 1000).toFixed(1)}kHz`, x: coordinates[2][0] + 5, y: coordinates[2][1], textAlign: 'start', diff --git a/client/src/components/geoJS/layers/measureToolLayer.ts b/client/src/components/geoJS/layers/measureToolLayer.ts index 17ffef4f..b132e1e4 100644 --- a/client/src/components/geoJS/layers/measureToolLayer.ts +++ b/client/src/components/geoJS/layers/measureToolLayer.ts @@ -164,7 +164,7 @@ export default class MeasureToolLayer extends BaseTextLayer { const frequency = height - this.yValue >= 0 ? ((height - newY) * (this.spectroInfo.high_freq - this.spectroInfo.low_freq)) / height / 1000 + this.spectroInfo.low_freq / 1000 : -1; - const textValue = `${frequency.toFixed(1)}KHz`; + const textValue = `${frequency.toFixed(1)}kHz`; const { x: textX, y: textY } = this._getTextCoordinates(); this.textData = [ { diff --git a/client/src/views/Recordings.vue b/client/src/views/Recordings.vue index e780b3c6..995b02e8 100644 --- a/client/src/views/Recordings.vue +++ b/client/src/views/Recordings.vue @@ -231,7 +231,7 @@ export default defineComponent({ const userSubmittedAnnotation = recording.fileAnnotations.find((annotation: FileAnnotation) => ( annotation.owner === currentUser.value && annotation.submitted )); - return userSubmittedAnnotation?.species[0]?.species_code; + return userSubmittedAnnotation?.species[0]?.species_code || ''; } function addSubmittedColumns() { @@ -285,10 +285,10 @@ export default defineComponent({ }); onMounted(async () => { - addSubmittedColumns(); - hideDetailedMetadataColumns(); await fetchRecordingTags(); await fetchRecordings(); + addSubmittedColumns(); + hideDetailedMetadataColumns(); }); const uploadDone = () => { @@ -582,24 +582,39 @@ export default defineComponent({ v-if="configuration.mark_annotations_completed_enabled" #item.submitted="{ item }" > - - mdi-check - - - mdi-close - - - mdi-circle-outline - + + + You have submitted an annotation for this recording + + + + You have created an annotation, but it has not been submitted + + + + You have not created an annotation for this recording +