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 c1f483e2..bafa70f5 100644 --- a/bats_ai/core/admin/__init__.py +++ b/bats_ai/core/admin/__init__.py @@ -17,6 +17,7 @@ from .species import SpeciesAdmin from .spectrogram import SpectrogramAdmin from .spectrogram_image import SpectrogramImageAdmin +from .vetting_details import VettingDetailsAdmin __all__ = [ 'AnnotationsAdmin', @@ -32,6 +33,7 @@ 'ConfigurationAdmin', 'ExportedAnnotationFileAdmin', 'SpectrogramImageAdmin', + 'VettingDetailsAdmin', # NABat Models 'NABatRecordingAnnotationAdmin', 'NABatCompressedSpectrogramAdmin', 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/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_configuration_mark_annotations_completed_enabled_and_more.py b/bats_ai/core/migrations/0025_configuration_mark_annotations_completed_enabled_and_more.py new file mode 100644 index 00000000..b92dee56 --- /dev/null +++ b/bats_ai/core/migrations/0025_configuration_mark_annotations_completed_enabled_and_more.py @@ -0,0 +1,56 @@ +# Generated by Django 4.2.23 on 2026-01-15 18:06 + +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_delete_image'), + ] + + 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='recordingannotation', + name='submitted', + field=models.BooleanField(default=False), + ), + 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 5401f260..8509896a 100644 --- a/bats_ai/core/models/__init__.py +++ b/bats_ai/core/models/__init__.py @@ -11,6 +11,7 @@ from .species import Species from .spectrogram import Spectrogram from .spectrogram_image import SpectrogramImage +from .vetting_details import VettingDetails __all__ = [ 'Annotations', @@ -28,4 +29,5 @@ 'ProcessingTaskType', 'ExportedAnnotationFile', 'SpectrogramImage', + 'VettingDetails', ] diff --git a/bats_ai/core/models/configuration.py b/bats_ai/core/models/configuration.py index 774831d8..7bdcfc65 100644 --- a/bats_ai/core/models/configuration.py +++ b/bats_ai/core/models/configuration.py @@ -32,6 +32,10 @@ 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) + 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/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..e5537267 --- /dev/null +++ b/bats_ai/core/tests/test_vetting_details.py @@ -0,0 +1,114 @@ +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 + + +@pytest.mark.django_db +def test_update_vetting_details_length_constraint(authorized_client, random_user_vetting_details): + data = {'reference_materials': 'a' * 2001} + resp = authorized_client.post( + f'/api/v1/vetting/user/{random_user_vetting_details.user.id}', + data=data, + content_type='application/json', + ) + assert resp.status_code == 400 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 5714688f..d3034a0d 100644 --- a/bats_ai/core/views/configuration.py +++ b/bats_ai/core/views/configuration.py @@ -22,6 +22,8 @@ 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 # Endpoint to retrieve the configuration status @@ -38,6 +40,8 @@ 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, is_admin=request.user.is_authenticated and request.user.is_superuser, ) @@ -61,3 +65,14 @@ 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, + 'id': request.user.id, + } + return {'email': '', 'name': ''} diff --git a/bats_ai/core/views/grts_cells.py b/bats_ai/core/views/grts_cells.py index 95a3c078..67e578a3 100644 --- a/bats_ai/core/views/grts_cells.py +++ b/bats_ai/core/views/grts_cells.py @@ -81,3 +81,39 @@ def custom_sort_key(cell): return JsonResponse({'latitude': center_latitude, 'longitude': center_longitude}) except GRTSCells.DoesNotExist: return JsonResponse({'error': f'Cell with cellId={id} does not exist'}, status=200) + + +@router.get('/{id}/bbox') +def get_grts_cell_bbox(request: HttpRequest, id: int): + try: + cells = GRTSCells.objects.filter(grts_cell_id=id) + custom_order = GRTSCells.sort_order() + + def custom_sort_key(cell): + return custom_order.index(cell.sample_frame_id) + + sorted_cells = sorted(cells, key=custom_sort_key) + cell = sorted_cells[0] + geom = cell.geom_4326 + + min_x, min_y, max_x, max_y = geom.extent + + geojson = { + 'type': 'Feature', + 'geometry': { + 'type': 'Polygon', + 'coordinates': [ + [min_x, min_y], + [min_x, max_y], + [max_x, max_y], + [max_x, min_y], + ], + }, + 'properties': { + 'grts_cell_id': id, + 'annotationType': 'rectangle', + }, + } + return JsonResponse(geojson) + except (GRTSCells.DoesNotExist, IndexError): + return JsonResponse({'error': f'Cell with id {id} does not exist'}, status=200) diff --git a/bats_ai/core/views/recording.py b/bats_ai/core/views/recording.py index 9c1c8e91..a97dfd66 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..834714be 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.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/bats_ai/core/views/vetting_details.py b/bats_ai/core/views/vetting_details.py new file mode 100644 index 00000000..38a9d5eb --- /dev/null +++ b/bats_ai/core/views/vetting_details.py @@ -0,0 +1,62 @@ +from django.http import Http404, HttpRequest, HttpResponseBadRequest +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 + + if len(payload.reference_materials) > 2000: + return HttpResponseBadRequest( + 'reference_materials exceeds maximum length of 2000 characters' + ) + + 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..823ef327 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,8 @@ export default defineComponent({ const checkLogin = async () => { if (oauthClient.isLoggedIn) { loginText.value = "Logout"; - loadConfiguration(); + await loadCurrentUser(); + await loadReviewerMaterials(); if (sharedList.value.length === 0) { getShared(); } @@ -42,7 +51,8 @@ export default defineComponent({ oauthClient.redirectToLogin(); } }; - onMounted(() => { + onMounted(async () => { + await loadConfiguration(); checkLogin(); }); router.afterEach((guard) => { diff --git a/client/src/api/api.ts b/client/src/api/api.ts index 3b13e655..102f3462 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 { @@ -100,6 +101,7 @@ export interface FileAnnotation { confidence: number; hasDetails: boolean; id: number; + submitted: boolean; } export interface FileAnnotationDetails { @@ -257,6 +259,18 @@ interface GRTSCellCenter { error?: string; } +interface GRTSCellBbox { + type: string; + geometry: { + type: string; + coordinates: number[][]; + }; + properties: { + grts_cell_id: number; + annotationType: string; + }; +} + export interface RecordingTag { id: number; text: string; @@ -366,6 +380,11 @@ async function getOtherUserAnnotations(recordingId: string) { async function getCellLocation(cellId: number, quadrant?: "SW" | "NE" | "NW" | "SE") { return axiosInstance.get(`/grts/${cellId}`, { params: { quadrant } }); } + +async function getCellBbox(cellId: number) { + return await axiosInstance.get(`/grts/${cellId}/bbox`); +} + async function getFileAnnotations(recordingId: number) { return axiosInstance.get(`recording/${recordingId}/recording-annotations`); } @@ -395,6 +414,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 +439,8 @@ 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; } export type Configuration = ConfigurationSettings & { is_admin: boolean }; @@ -425,6 +452,10 @@ async function patchConfiguration(config: ConfigurationSettings) { return axiosInstance.patch("/configuration/", { ...config }); } +async function getCurrentUser() { + return axiosInstance.get<{name: string, email: string, id: number}>("/configuration/me"); +} + export interface ProcessingTask { id: number; created: string; @@ -506,6 +537,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, @@ -525,12 +586,14 @@ export { deleteAnnotation, deleteSequenceAnnotation, getCellLocation, + getCellBbox, getCellfromLocation, getGuanoMetadata, getFileAnnotations, putFileAnnotation, patchFileAnnotation, deleteFileAnnotation, + submitFileAnnotation, getConfiguration, patchConfiguration, getProcessingTasks, @@ -540,4 +603,7 @@ export { getFileAnnotationDetails, getExportStatus, getRecordingTags, + getCurrentUser, + getVettingDetailsForUser, + createOrUpdateVettingDetailsForUser, }; 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 @@ \ No newline at end of file + +.overflow-recordings { + max-height: 50vh; + overflow-y: auto; +} + diff --git a/client/src/components/ReferenceMaterialsDialog.vue b/client/src/components/ReferenceMaterialsDialog.vue new file mode 100644 index 00000000..665ddce9 --- /dev/null +++ b/client/src/components/ReferenceMaterialsDialog.vue @@ -0,0 +1,79 @@ + + + diff --git a/client/src/components/SpeciesEditor.vue b/client/src/components/SpeciesEditor.vue index 7daaf8f9..aa994404 100644 --- a/client/src/components/SpeciesEditor.vue +++ b/client/src/components/SpeciesEditor.vue @@ -13,6 +13,14 @@ export default defineComponent({ type: Array as PropType, default: () => [], }, + disabled: { + type: Boolean, + default: false, + }, + multiple: { + type: Boolean, + default: true, + }, }, emits: ['update:modelValue'], @@ -121,7 +129,7 @@ export default defineComponent({ :items="groupedItems" item-title="species_code" item-value="species_code" - multiple + :multiple="multiple" chips closable-chips :custom-filter="customFilter" @@ -129,6 +137,7 @@ export default defineComponent({ clear-on-select label="Select Labels" :menu-props="{ maxHeight: '300px', maxWidth: '400px' }" + :disabled="disabled" > diff --git a/client/src/views/Recordings.vue b/client/src/views/Recordings.vue index 250f087b..0fb79dd8 100644 --- a/client/src/views/Recordings.vue +++ b/client/src/views/Recordings.vue @@ -9,8 +9,10 @@ import { import { deleteRecording, getRecordings, - Recording , + Recording, + FileAnnotation, getRecordingTags, + getConfiguration, } from '../api/api'; import UploadRecording, { EditingRecording } from '@components/UploadRecording.vue'; import MapLocation from '@components/MapLocation.vue'; @@ -21,22 +23,37 @@ import RecordingAnnotationSummary from '@components/RecordingAnnotationSummary.v import { FilterFunction, InternalItem } from 'vuetify'; export default defineComponent({ - components: { - UploadRecording, - MapLocation, - BatchUploadRecording, - RecordingInfoDisplay, - RecordingAnnotationSummary, - }, + components: { + UploadRecording, + MapLocation, + BatchUploadRecording, + RecordingInfoDisplay, + RecordingAnnotationSummary, + }, setup() { const itemsPerPage = ref(-1); - const { sharedList, recordingList, recordingTagList } = useState(); + const { + sharedList, + recordingList, + recordingTagList, + currentUser, + configuration, + showSubmittedRecordings, + submittedMyRecordings, + submittedSharedRecordings, + myRecordingsDisplay, + sharedRecordingsDisplay, + } = useState(); const editingRecording: Ref = ref(null); let intervalRef: number | null = null; const uploadDialog = ref(false); const batchUploadDialog = ref(false); - const headers = ref([ + const headers: Ref<{ + title: string, + key: string, + value?: (item: Recording) => boolean | string | number, + }[]> = ref([ { title:'Name', key: 'name', @@ -74,7 +91,7 @@ export default defineComponent({ key:'comments' }, { - title:'Users Annotated', + title:'User Pulse Annotations', key:'userAnnotations', }, { @@ -200,9 +217,78 @@ export default defineComponent({ return filterTagSet.intersection(itemTagSet).size > 0; }; + function currentUserSubmissionStatus(recording: Recording) { + const userAnnotations = recording.fileAnnotations.filter((annotation: FileAnnotation) => ( + annotation.owner === currentUser.value && annotation.model === 'User Defined' + )); + if (userAnnotations.find((annotation: FileAnnotation) => annotation.submitted)) { + return 1; + } + return userAnnotations.length ? 0 : -1; + } + + function currentUserSubmission(recording: Recording) { + const userSubmittedAnnotation = recording.fileAnnotations.find((annotation: FileAnnotation) => ( + annotation.owner === currentUser.value && annotation.submitted + )); + return userSubmittedAnnotation?.species[0]?.species_code || ''; + } + + function addSubmittedColumns() { + if (configuration.value.mark_annotations_completed_enabled) { + const submittedHeader = { + title: 'Submission Status', + key: 'submitted', + value: currentUserSubmissionStatus, + }; + const myLabelHeader = { + title: 'My Submitted Label', + key: 'submittedLabel', + value: currentUserSubmission, + }; + headers.value.push(submittedHeader, myLabelHeader); + sharedHeaders.value.push(submittedHeader, myLabelHeader); + } + } + + function hideDetailedMetadataColumns() { + if (!configuration.value.mark_annotations_completed_enabled) return; + const filterDetailedMetadataFunction = (val: { key: string }) => ( + !['comments', 'details', 'annotation', 'userAnnotations'].includes(val.key) + ); + headers.value = headers.value.filter(filterDetailedMetadataFunction); + sharedHeaders.value = sharedHeaders.value.filter(filterDetailedMetadataFunction); + } + + const myRecordingListStyles = computed(() => { + const sectionHeight = configuration.value.mark_annotations_completed_enabled ? '35vh' : '40vh'; + return { + 'height': sectionHeight, + 'max-height': sectionHeight, + }; + }); + + const sharedRecordingListStyles = computed(() => { + let sectionHeight: string; + if (configuration.value.mark_annotations_completed_enabled) { + sectionHeight = '35vh'; + } else { + sectionHeight = '40vh'; + } + if (!configuration.value.is_admin && !configuration.value.non_admin_upload_enabled) { + sectionHeight = '75vh'; + } + return { + 'height': sectionHeight, + 'max-height': sectionHeight, + }; + }); + onMounted(async () => { await fetchRecordingTags(); await fetchRecordings(); + addSubmittedColumns(); + hideDetailedMetadataColumns(); }); const uploadDone = () => { @@ -273,6 +359,16 @@ export default defineComponent({ recordingToDelete, editingRecording, dataLoading, + currentUserSubmission, + currentUserSubmissionStatus, + configuration, + submittedMyRecordings, + submittedSharedRecordings, + myRecordingListStyles, + sharedRecordingListStyles, + showSubmittedRecordings, + myRecordingsDisplay, + sharedRecordingsDisplay, }; }, }); @@ -281,31 +377,39 @@ export default defineComponent({ + +