Skip to content
1 change: 1 addition & 0 deletions bats_ai/core/admin/recording_annotations.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class RecordingAnnotationAdmin(admin.ModelAdmin):
'additional_data',
'comments',
'model',
'submitted',
]
list_select_related = True
filter_horizontal = ('species',) # or filter_vertical
Expand Down
Original file line number Diff line number Diff line change
@@ -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),
),
]
5 changes: 5 additions & 0 deletions bats_ai/core/models/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion bats_ai/core/models/recording_annotation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=[
Expand All @@ -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)
16 changes: 16 additions & 0 deletions bats_ai/core/views/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
)

Expand All @@ -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': ''}
16 changes: 15 additions & 1 deletion bats_ai/core/views/recording.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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,
)


Expand Down Expand Up @@ -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 = (
Expand Down Expand Up @@ -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)


Expand Down
25 changes: 25 additions & 0 deletions bats_ai/core/views/recording_annotation.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class RecordingAnnotationSchema(Schema):
owner: str
confidence: float
id: int | None = None
submitted: bool
hasDetails: bool

@classmethod
Expand All @@ -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
Expand All @@ -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):
Expand All @@ -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,
)


Expand Down Expand Up @@ -178,3 +183,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.')
16 changes: 16 additions & 0 deletions client/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export interface FileAnnotation {
confidence: number;
hasDetails: boolean;
id: number;
submitted: boolean;
}

export interface FileAnnotationDetails {
Expand Down Expand Up @@ -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;
Expand All @@ -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 };
Expand All @@ -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;
Expand Down Expand Up @@ -531,6 +545,7 @@ export {
putFileAnnotation,
patchFileAnnotation,
deleteFileAnnotation,
submitFileAnnotation,
getConfiguration,
patchConfiguration,
getProcessingTasks,
Expand All @@ -540,4 +555,5 @@ export {
getFileAnnotationDetails,
getExportStatus,
getRecordingTags,
getCurrentUser,
};
74 changes: 68 additions & 6 deletions client/src/components/RecordingAnnotationEditor.vue
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
<script lang="ts">
import { defineComponent, PropType, ref, Ref, watch } from "vue";
import { computed, defineComponent, PropType, ref, Ref, watch } from "vue";
import { SpectroInfo } from './geoJS/geoJSUtils';
import { deleteFileAnnotation, FileAnnotation, patchFileAnnotation, Species, UpdateFileAnnotation } from "../api/api";
import {
deleteFileAnnotation,
FileAnnotation,
patchFileAnnotation,
Species,
UpdateFileAnnotation,
submitFileAnnotation,
} from "../api/api";
import { deleteNABatFileAnnotation, patchNABatFileAnnotationLocal } from "../api/NABatApi";
import useState from "@use/useState";
import SpeciesInfo from "./SpeciesInfo.vue";
import SpeciesEditor from "./SpeciesEditor.vue";
import SpeciesNABatSave from "./SpeciesNABatSave.vue";
Expand Down Expand Up @@ -38,10 +46,14 @@ export default defineComponent({
type: String as PropType<'nabat' | null>,
default: () => null,
},
submittedAnnotationId: {
type: Number as PropType<number | undefined>,
default: () => undefined,
},
},
emits: ['update:annotation', 'delete:annotation'],
setup(props, { emit }) {

const { configuration, currentUser } = useState();
const speciesEdit: Ref<string[]> = ref( props.annotation?.species?.map((item) => item.species_code || item.common_name) || []);
const comments: Ref<string> = ref(props.annotation?.comments || '');
const confidence: Ref<number> = ref(props.annotation?.confidence || 1.0);
Expand Down Expand Up @@ -84,21 +96,48 @@ export default defineComponent({

};



const deleteAnnotation = async () => {
if (props.annotation && props.recordingId) {
props.type === 'nabat' ? await deleteNABatFileAnnotation(props.annotation.id, props.apiToken, props.recordingId) : await deleteFileAnnotation(props.annotation.id,);
emit('delete:annotation');
}
};

const submitAnnotation = async () => {
if (props.annotation && props.recordingId) {
await submitFileAnnotation(props.annotation.id);
emit('update:annotation');
}
};

const canSubmit = computed(() => (
props.annotation
&& props.annotation.owner === currentUser.value
&& props.annotation.model === 'User Defined'
&& configuration.value.mark_annotations_completed_enabled
));

const submissionTooltip = computed(() => {
if (props.submittedAnnotationId !== undefined && props.submittedAnnotationId !== props.annotation?.id) {
return 'You have already submitted a different annotation for this recording.';
}
if (props.annotation && props.annotation.submitted) {
return 'This annotation has been submitted. This cannot be undone.';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can get around this by simply deleting the annotation?
Should deletion be guarded/removed for non-admin users?

}
return 'Submit this annotation. This action cannot be undone.';
});

return {
speciesEdit,
confidence,
comments,
updateAnnotation,
deleteAnnotation,
submitAnnotation,
singleSpecies,
configuration,
canSubmit,
submissionTooltip,
};
},
});
Expand Down Expand Up @@ -152,6 +191,7 @@ export default defineComponent({
:key="`species_${annotation?.id}`"
v-model="speciesEdit"
:species-list="species"
:disabled="annotation?.submitted"
@update:model-value="updateAnnotation()"
/>
</v-row>
Expand All @@ -177,14 +217,36 @@ export default defineComponent({
/>
</v-row>
<v-row
v-if="type !== 'nabat'"
v-if="type !== 'nabat' && !configuration.mark_annotations_completed_enabled"
>
<v-textarea
v-model="comments"
label="Comments"
@change="updateAnnotation()"
/>
</v-row>
<v-row v-if="canSubmit">
<v-tooltip>
<template #activator="{ props }">
<div
v-bind="props"
>
<v-btn
flat
color="primary"
:disabled="annotation.submitted || (submittedAnnotationId !== undefined && annotation.id !== submittedAnnotationId)"
@click="submitAnnotation"
>
Submit
<template #append>
<v-icon>mdi-check</v-icon>
</template>
</v-btn>
</div>
</template>
{{ submissionTooltip }}
</v-tooltip>
</v-row>
</v-card-text>
</v-card>
</template>
Expand Down
Loading