diff --git a/bats_ai/core/views/recording_annotation.py b/bats_ai/core/views/recording_annotation.py index 3f6e9fa3..5be4e47f 100644 --- a/bats_ai/core/views/recording_annotation.py +++ b/bats_ai/core/views/recording_annotation.py @@ -206,17 +206,20 @@ def delete_recording_annotation(request: HttpRequest, pk: int): 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=pk) # Check permission: only the annotation owner may delete their own if annotation.owner != request.user: raise HttpError(403, "Permission denied.") + # In vetting mode, non-staff may only delete blank annotations (no species) + if vetting_enabled and not request.user.is_staff and annotation.species.exists(): + raise HttpError( + 403, + "Permission denied. Only blank annotations can be deleted " + "while vetting is enabled.", + ) + annotation.delete() return "Recording annotation deleted successfully." except RecordingAnnotation.DoesNotExist as e: diff --git a/client/src/components/RecordingAnnotationEditor.vue b/client/src/components/RecordingAnnotationEditor.vue index 7f0b359c..63b7a989 100644 --- a/client/src/components/RecordingAnnotationEditor.vue +++ b/client/src/components/RecordingAnnotationEditor.vue @@ -113,6 +113,11 @@ export default defineComponent({ updateAnnotation(); }; + const onSaveComment = (newComment: string) => { + comments.value = newComment; + updateAnnotation(); + }; + const deleteAnnotation = async () => { if (props.annotation && props.recordingId) { deletingAnnotation.value = true; @@ -170,13 +175,11 @@ export default defineComponent({ }); const deleteEnabled = computed(() => { - return ( - props.type !== 'nabat' - && ( - configuration.value.is_admin - || !configuration.value.mark_annotations_completed_enabled - ) - ); + if (props.type === 'nabat') return false; + if (configuration.value.is_admin) return true; + if (!configuration.value.mark_annotations_completed_enabled) return true; + // In vetting mode, non-admins may only delete blank annotations + return speciesEdit.value.length === 0; }); return { @@ -188,6 +191,7 @@ export default defineComponent({ comments, updateAnnotation, onSpeciesModelValue, + onSaveComment, deleteAnnotation, submitAnnotation, confirmSubmitAnnotation, @@ -266,7 +270,11 @@ export default defineComponent({ v-model="speciesEdit" :species-list="species" :disabled="annotation?.submitted || updatingAnnotation || deletingAnnotation" + :vetting-mode="configuration.mark_annotations_completed_enabled" + :annotation-comment="comments" @update:model-value="onSpeciesModelValue" + @delete-blank-annotation="deleteAnnotation" + @save-comment="onSaveComment" /> diff --git a/client/src/components/RecordingAnnotations.vue b/client/src/components/RecordingAnnotations.vue index 6fa5daff..31d7eae1 100644 --- a/client/src/components/RecordingAnnotations.vue +++ b/client/src/components/RecordingAnnotations.vue @@ -145,6 +145,26 @@ export default defineComponent({ return ( currentUserAnnotations.length > 0 && props.type === 'nabat'); }); + const vettingMode = computed(() => configuration.value.mark_annotations_completed_enabled); + // Count all annotations owned by current user (submitted and unsubmitted) for vetting one-annotation limit + const userAnnotationCount = computed(() => + annotations.value.filter( + (a: FileAnnotation) => a.owner === currentUser.value + ).length + ); + const vettingModeAddDisabled = computed(() => + vettingMode.value && userAnnotationCount.value > 0 + ); + const addButtonDisabled = computed(() => + addingAnnotation.value || disableNaBatAnnotations.value || vettingModeAddDisabled.value + ); + const addButtonTooltip = computed(() => { + if (vettingModeAddDisabled.value) { + return 'In vetting mode you may only add one annotation per recording.'; + } + return ''; + }); + function getConfidenceLabelText(confidence: number) { return `Confidence: ${confidence.toFixed(2)}`; } @@ -167,6 +187,11 @@ export default defineComponent({ userSubmittedAnnotationId, handleSubmitAnnotation, configuration, + addButtonDisabled, + addButtonTooltip, + userAnnotationCount, + vettingModeAddDisabled, + vettingMode, }; }, }); @@ -194,8 +219,26 @@ export default defineComponent({ + + + + + Addmdi-plus + + + + {{ addButtonTooltip }} + diff --git a/client/src/components/SingleSpecieEditor.vue b/client/src/components/SingleSpecieEditor.vue index 321a29ad..529dd35e 100644 --- a/client/src/components/SingleSpecieEditor.vue +++ b/client/src/components/SingleSpecieEditor.vue @@ -99,6 +99,14 @@ export default defineComponent({ onMounted(() => window.addEventListener("keydown", speciesShortcut)); onUnmounted(() => window.removeEventListener("keydown", speciesShortcut)); + const onClearOrDeleteClick = () => { + if (selectedCode.value) { + selectedCode.value = null; + } else { + emit("delete"); + } + }; + return { search, selectedCode, @@ -106,6 +114,7 @@ export default defineComponent({ customFilter, categoryColors, speciesAutocomplete, + onClearOrDeleteClick, }; }, }); @@ -129,7 +138,6 @@ export default defineComponent({ item-value="species_code" :multiple="false" :custom-filter="customFilter" - clearable clear-on-select label="Select species" :menu-props="{ maxHeight: '300px', maxWidth: '400px' }" @@ -138,6 +146,16 @@ export default defineComponent({ density="compact" hide-details > + + + mdi-close + + ( props.modelValue?.length ? [...props.modelValue] : [""] ); const addSpeciesConfirmOpen = ref(false); + const commentDialogOpen = ref(false); + const commentDraft = ref(""); watch( () => props.modelValue, @@ -54,6 +65,14 @@ export default defineComponent({ } } + function onSlotDelete(index: number) { + if (localSpeciesList.value.length <= 1 && (localSpeciesList.value[0] ?? "") === "") { + emit("deleteBlankAnnotation"); + } else { + removeSpecies(index); + } + } + function openAddSpeciesConfirm() { addSpeciesConfirmOpen.value = true; } @@ -75,14 +94,41 @@ export default defineComponent({ emitValue(); } + function openCommentDialog() { + commentDraft.value = props.annotationComment ?? ""; + commentDialogOpen.value = true; + } + + function closeCommentDialog() { + commentDialogOpen.value = false; + } + + function saveComment() { + emit("saveComment", commentDraft.value); + closeCommentDialog(); + } + + function removeComment() { + commentDraft.value = ""; + emit("saveComment", ""); + closeCommentDialog(); + } + return { localSpeciesList, onSlotUpdate, + onSlotDelete, openAddSpeciesConfirm, closeAddSpeciesConfirm, confirmAddSpecies, addSpeciesConfirmOpen, removeSpecies, + commentDialogOpen, + commentDraft, + openCommentDialog, + closeCommentDialog, + saveComment, + removeComment, }; }, }); @@ -101,25 +147,46 @@ export default defineComponent({ :disabled="disabled" :show-delete="localSpeciesList.length > 1" @update:model-value="onSlotUpdate(index, $event)" - @delete="removeSpecies(index)" + @delete="onSlotDelete(index)" /> - - - - mdi-plus - - - Add Bat - + + + + mdi-plus + + Add Bat + + + + + + + {{ annotationComment ? 'mdi-pencil' : 'mdi-plus' }} + + Comment + + + + + + Comment + + + This is an optional comment field that can be attached to the annotation. + + + + + + Remove comment + + + + Cancel + + + Save + + + +
+ This is an optional comment field that can be attached to the annotation. +