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.';
}
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