diff --git a/bats_ai/api.py b/bats_ai/api.py index 4cd1c0bb..9b1cc1ca 100644 --- a/bats_ai/api.py +++ b/bats_ai/api.py @@ -13,6 +13,7 @@ RecordingRouter, RecordingTagRouter, SpeciesRouter, + VettingRouter, ) from bats_ai.core.views.nabat import NABatConfigurationRouter, NABatRecordingRouter @@ -46,3 +47,4 @@ def global_auth(request): api.add_router('/recording-tag/', RecordingTagRouter) api.add_router('/nabat/recording/', NABatRecordingRouter) api.add_router('/nabat/configuration/', NABatConfigurationRouter) +api.add_router('/vetting/', VettingRouter) diff --git a/bats_ai/core/admin/__init__.py b/bats_ai/core/admin/__init__.py index fe5a419e..748798fd 100644 --- a/bats_ai/core/admin/__init__.py +++ b/bats_ai/core/admin/__init__.py @@ -18,6 +18,7 @@ from .species import SpeciesAdmin from .spectrogram import SpectrogramAdmin from .spectrogram_image import SpectrogramImageAdmin +from .vetting_details import VettingDetailsAdmin __all__ = [ 'AnnotationsAdmin', @@ -34,6 +35,7 @@ 'ConfigurationAdmin', 'ExportedAnnotationFileAdmin', 'SpectrogramImageAdmin', + 'VettingDetailsAdmin', # NABat Models 'NABatRecordingAnnotationAdmin', 'NABatCompressedSpectrogramAdmin', diff --git a/bats_ai/core/admin/vetting_details.py b/bats_ai/core/admin/vetting_details.py new file mode 100644 index 00000000..a350c879 --- /dev/null +++ b/bats_ai/core/admin/vetting_details.py @@ -0,0 +1,13 @@ +from django.contrib import admin + +from bats_ai.core.models import VettingDetails + + +@admin.register(VettingDetails) +class VettingDetailsAdmin(admin.ModelAdmin): + list_display = [ + 'pk', + 'user', + # 'reference_materials', + ] + search_fields = ('user',) diff --git a/bats_ai/core/migrations/0025_vettingdetails_and_more.py b/bats_ai/core/migrations/0025_vettingdetails_and_more.py new file mode 100644 index 00000000..967f5481 --- /dev/null +++ b/bats_ai/core/migrations/0025_vettingdetails_and_more.py @@ -0,0 +1,41 @@ +# Generated by Django 4.2.23 on 2026-01-08 18:57 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('core', '0024_configuration_mark_annotations_completed_enabled_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='VettingDetails', + fields=[ + ( + 'id', + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name='ID' + ), + ), + ('reference_materials', models.TextField(blank=True)), + ( + 'user', + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL + ), + ), + ], + ), + migrations.AddConstraint( + model_name='vettingdetails', + constraint=models.CheckConstraint( + check=models.Q(('reference_materials__length__lte', 2000)), + name='reference_materials_max_2000', + ), + ), + ] diff --git a/bats_ai/core/models/__init__.py b/bats_ai/core/models/__init__.py index ad11fab9..01dee04a 100644 --- a/bats_ai/core/models/__init__.py +++ b/bats_ai/core/models/__init__.py @@ -12,6 +12,7 @@ from .species import Species from .spectrogram import Spectrogram from .spectrogram_image import SpectrogramImage +from .vetting_details import VettingDetails __all__ = [ 'Annotations', @@ -30,4 +31,5 @@ 'ProcessingTaskType', 'ExportedAnnotationFile', 'SpectrogramImage', + 'VettingDetails', ] diff --git a/bats_ai/core/models/vetting_details.py b/bats_ai/core/models/vetting_details.py new file mode 100644 index 00000000..41f84fcd --- /dev/null +++ b/bats_ai/core/models/vetting_details.py @@ -0,0 +1,20 @@ +from django.contrib.auth.models import User +from django.db import models +from django.db.models import Q +from django.db.models.functions import Length + +models.TextField.register_lookup(Length, 'length') + + +class VettingDetails(models.Model): + user = models.OneToOneField(User, on_delete=models.CASCADE) + reference_materials = models.TextField(blank=True) + + class Meta: + constraints = [ + models.CheckConstraint( + # TODO change to 'condition' in Django v6 + check=Q(reference_materials__length__lte=2000), + name='reference_materials_max_2000', + ) + ] diff --git a/bats_ai/core/tests/conftest.py b/bats_ai/core/tests/conftest.py index d6545dc3..d1c5e8fe 100644 --- a/bats_ai/core/tests/conftest.py +++ b/bats_ai/core/tests/conftest.py @@ -2,7 +2,9 @@ from django.test import Client import pytest -from .factories import SuperuserFactory, UserFactory +from bats_ai.core.models import VettingDetails + +from .factories import SuperuserFactory, UserFactory, VettingDetailsFactory @pytest.fixture @@ -32,3 +34,13 @@ def authorized_client(superuser: User) -> Client: client = Client() client.force_login(user=superuser) return client + + +@pytest.fixture +def vetting_details(user: User) -> VettingDetails: + return VettingDetailsFactory(user=user) + + +@pytest.fixture +def random_user_vetting_details() -> VettingDetails: + return VettingDetailsFactory(user=UserFactory()) diff --git a/bats_ai/core/tests/factories.py b/bats_ai/core/tests/factories.py index ccb88e77..708fbacf 100644 --- a/bats_ai/core/tests/factories.py +++ b/bats_ai/core/tests/factories.py @@ -1,6 +1,8 @@ from django.contrib.auth.models import User import factory.django +from bats_ai.core.models import VettingDetails + class UserFactory(factory.django.DjangoModelFactory[User]): class Meta: @@ -28,3 +30,12 @@ class Meta: def _create(cls, model_class, *args, **kwargs): manager = cls._get_manager(model_class) return manager.create_superuser(*args, **kwargs) + + +class VettingDetailsFactory(factory.django.DjangoModelFactory[VettingDetails]): + + class Meta: + model = VettingDetails + + user = factory.SubFactory(UserFactory) + reference_materials = factory.Faker('paragraph', nb_sentences=3) diff --git a/bats_ai/core/tests/test_vetting_details.py b/bats_ai/core/tests/test_vetting_details.py new file mode 100644 index 00000000..053e6036 --- /dev/null +++ b/bats_ai/core/tests/test_vetting_details.py @@ -0,0 +1,103 @@ +import pytest + +from .factories import UserFactory, VettingDetailsFactory + + +@pytest.mark.parametrize( + 'client_fixture,status_code', + [ + ('client', 401), + ('authenticated_client', 200), + ('authorized_client', 200), + ], +) +@pytest.mark.django_db +def test_get_vetting_details(client_fixture, status_code, user, vetting_details, request): + api_client = request.getfixturevalue(client_fixture) + resp = api_client.get(f'/api/v1/vetting/user/{user.id}') + assert resp.status_code == status_code + if status_code == 200: + assert resp.json()['reference_materials'] == vetting_details.reference_materials + + +@pytest.mark.django_db +def test_get_vetting_details_other_user(authenticated_client): + other_user = UserFactory() + VettingDetailsFactory(user=other_user) + resp = authenticated_client.get(f'/api/v1/vetting/user/{other_user.id}') + assert resp.status_code == 404 + + +@pytest.mark.django_db +def test_create_vetting_details(client): + test_text = 'foo' + data = {'reference_materials': test_text} + test_user = UserFactory() + client.force_login(user=test_user) + resp = client.post( + f'/api/v1/vetting/user/{test_user.id}', data=data, content_type='application/json' + ) + assert resp.status_code == 200 + assert resp.json()['user_id'] == test_user.id + + +@pytest.mark.parametrize( + 'client_fixture,status_code', + [ + ('authenticated_client', 404), + ('authorized_client', 200), + ], +) +@pytest.mark.django_db +def test_create_vetting_details_other_user(client_fixture, status_code, request): + api_client = request.getfixturevalue(client_fixture) + test_text = 'foo' + data = {'reference_materials': test_text} + other_user = UserFactory() + resp = api_client.post( + f'/api/v1/vetting/user/{other_user.id}', data=data, content_type='application/json' + ) + assert resp.status_code == status_code + if status_code == 200: + assert resp.json()['reference_materials'] == test_text + + +@pytest.mark.django_db +def test_update_vetting_details(client): + test_text = 'bar' + data = {'reference_materials': 'bar'} + test_user = UserFactory() + VettingDetailsFactory(user=test_user, reference_materials='foo') + client.force_login(test_user) + + initial_resp = client.get(f'/api/v1/vetting/user/{test_user.id}') + assert initial_resp.status_code == 200 + + resp = client.post( + f'/api/v1/vetting/user/{test_user.id}', data=data, content_type='application/json' + ) + assert resp.status_code == 200 + + new_details_response = client.get(f'/api/v1/vetting/user/{test_user.id}') + assert new_details_response.status_code == 200 + assert new_details_response.json()['reference_materials'] == test_text + + +@pytest.mark.parametrize( + 'client_fixture,status_code', + [ + ('authenticated_client', 404), + ('authorized_client', 200), + ], +) +@pytest.mark.django_db +def test_update_vetting_details_other_user( + client_fixture, status_code, random_user_vetting_details, request +): + api_client = request.getfixturevalue(client_fixture) + resp = api_client.post( + f'/api/v1/vetting/user/{random_user_vetting_details.user.id}', + data={'reference_materials': 'foo'}, + content_type='application/json', + ) + assert resp.status_code == status_code diff --git a/bats_ai/core/views/__init__.py b/bats_ai/core/views/__init__.py index 0be8ef80..ea390e46 100644 --- a/bats_ai/core/views/__init__.py +++ b/bats_ai/core/views/__init__.py @@ -9,6 +9,7 @@ from .recording_tag import router as RecordingTagRouter from .sequence_annotations import router as SequenceAnnotationRouter from .species import router as SpeciesRouter +from .vetting_details import router as VettingRouter __all__ = [ 'RecordingRouter', @@ -22,4 +23,5 @@ 'ProcessingTaskRouter', 'ExportAnnotationRouter', 'RecordingTagRouter', + 'VettingRouter', ] diff --git a/bats_ai/core/views/configuration.py b/bats_ai/core/views/configuration.py index 494beb58..be7c66f1 100644 --- a/bats_ai/core/views/configuration.py +++ b/bats_ai/core/views/configuration.py @@ -75,5 +75,6 @@ def get_current_user(request): return { 'email': request.user.email, 'name': request.user.username, + 'id': request.user.id, } return {'email': '', 'name': ''} diff --git a/bats_ai/core/views/vetting_details.py b/bats_ai/core/views/vetting_details.py new file mode 100644 index 00000000..484e554e --- /dev/null +++ b/bats_ai/core/views/vetting_details.py @@ -0,0 +1,57 @@ +from django.http import Http404, HttpRequest +from ninja import Schema +from ninja.pagination import RouterPaginated + +from bats_ai.core.models import VettingDetails + +router = RouterPaginated() + + +class VettingDetailsSchema(Schema): + id: int + user_id: int + reference_materials: str + + @classmethod + def from_orm(cls, obj): + print(obj) + return cls(id=obj.id, reference_materials=obj.reference_materials, user_id=obj.user_id) + + +class UpdateVettingDetailsSchema(Schema): + reference_materials: str + + +@router.get('/user/{user_id}', response=VettingDetailsSchema) +def get_vetting_details_for_user(request: HttpRequest, user_id: int): + details = VettingDetails.objects.filter(user_id=user_id).first() + + if not details: + raise Http404() + + if details.user != request.user and not request.user.is_staff: + # Don't leak user IDs, prefer to return a 404 over a 403 + raise Http404 + + return details + + +@router.post('/user/{user_id}', response=VettingDetailsSchema) +def update_or_create_vetting_details_for_user( + request: HttpRequest, + payload: UpdateVettingDetailsSchema, + user_id: int, +): + if not (request.user.pk == user_id or request.user.is_staff): + raise Http404 + + details = VettingDetails.objects.filter(user_id=user_id).first() + + if not details: + details = VettingDetails(user=request.user, reference_materials=payload.reference_materials) + else: + details.reference_materials = payload.reference_materials + + details.save() + + return details diff --git a/client/src/App.vue b/client/src/App.vue index 3f6ae304..c7d25f30 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -12,7 +12,15 @@ export default defineComponent({ const oauthClient = inject("oauthClient"); const router = useRouter(); const route = useRoute(); - const { nextShared, sharedList, sideTab, loadConfiguration, configuration } = useState(); + const { + nextShared, + sharedList, + sideTab, + loadConfiguration, + configuration , + loadCurrentUser, + loadReviewerMaterials, + } = useState(); const getShared = async () => { sharedList.value = (await getRecordings(true)).data; }; @@ -24,7 +32,9 @@ export default defineComponent({ const checkLogin = async () => { if (oauthClient.isLoggedIn) { loginText.value = "Logout"; - loadConfiguration(); + await loadConfiguration(); + await loadCurrentUser(); + await loadReviewerMaterials(); if (sharedList.value.length === 0) { getShared(); } diff --git a/client/src/api/api.ts b/client/src/api/api.ts index 4faae6f5..497fff5e 100644 --- a/client/src/api/api.ts +++ b/client/src/api/api.ts @@ -1,4 +1,5 @@ import axios from "axios"; +import { AxiosError } from "axios"; import { SpectroInfo } from "@components/geoJS/geoJSUtils"; export interface Recording { @@ -453,7 +454,7 @@ async function patchConfiguration(config: ConfigurationSettings) { } async function getCurrentUser() { - return axiosInstance.get<{name: string, email: string}>("/configuration/me"); + return axiosInstance.get<{name: string, email: string, id: number}>("/configuration/me"); } export interface ProcessingTask { @@ -537,6 +538,36 @@ async function getExportStatus(exportId: number) { return result.data; } +export interface VettingDetails { + id: number; + user_id: number; + reference_materials: string; +} + +export interface UpdateVettingDetails { + reference_Materials: string; +} + +async function getVettingDetailsForUser(userId: number) { + try { + const result = await axiosInstance.get(`/vetting/user/${userId}`); + return result.data; + } catch (err) { + const error = err as AxiosError; + if (error.response?.status === 404) { + return null; + } + throw err; + } +} + +async function createOrUpdateVettingDetailsForUser(userId: number, referenceMaterials: string) { + return await axiosInstance.post( + `/vetting/user/${userId}`, + { 'reference_materials': referenceMaterials } + ); +} + export { uploadRecordingFile, getRecordings, @@ -574,4 +605,6 @@ export { getExportStatus, getRecordingTags, getCurrentUser, + getVettingDetailsForUser, + createOrUpdateVettingDetailsForUser, }; 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 ef15dbc4..dc3c29d4 100644 --- a/client/src/views/Recordings.vue +++ b/client/src/views/Recordings.vue @@ -37,7 +37,6 @@ export default defineComponent({ recordingTagList, currentUser, configuration, - loadCurrentUser, showSubmittedRecordings, submittedMyRecordings, submittedSharedRecordings, @@ -271,7 +270,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..7812430f 100644 --- a/client/src/views/Spectrogram.vue +++ b/client/src/views/Spectrogram.vue @@ -9,6 +9,7 @@ import { watch, } from "vue"; import { useRouter } from "vue-router"; +import { debounce } from "lodash"; import { getSpecies, getAnnotations, @@ -17,6 +18,7 @@ import { getSpectrogramCompressed, getOtherUserAnnotations, getSequenceAnnotations, + createOrUpdateVettingDetailsForUser, } from "../api/api"; import SpectrogramViewer from "@components/SpectrogramViewer.vue"; import { SpectroInfo } from "@components/geoJS/geoJSUtils"; @@ -74,6 +76,8 @@ export default defineComponent({ nextUnsubmittedRecordingId, previousUnsubmittedRecordingId, currentRecordingId, + currentUserId, + reviewerMaterials, } = useState(); const router = useRouter(); const images: Ref = ref([]); @@ -278,6 +282,15 @@ export default defineComponent({ router.push({ path: `/recording/${previousUnsubmittedRecordingId.value}/spectrogram`, replace: true }); } + const referenceDialog = ref(false); + + function _saveReviewerMaterials() { + if (!currentUserId.value) return; + createOrUpdateVettingDetailsForUser(currentUserId.value, reviewerMaterials.value); + } + + const saveReviewerMaterials = debounce(_saveReviewerMaterials, 500); + return { configuration, annotationState, @@ -327,6 +340,9 @@ export default defineComponent({ goToNextUnreviewed, goToPreviousUnreviewed, nextUnsubmittedRecordingId, + referenceDialog, + reviewerMaterials, + saveReviewerMaterials, }; }, }); @@ -656,6 +672,41 @@ export default defineComponent({ + + + + + + + Reference Materials + + + + + + + Close + + + + + +