diff --git a/bats_ai/core/admin/__init__.py b/bats_ai/core/admin/__init__.py index 68eb9b8..23510a1 100644 --- a/bats_ai/core/admin/__init__.py +++ b/bats_ai/core/admin/__init__.py @@ -1,11 +1,15 @@ from .annotations import AnnotationsAdmin from .image import ImageAdmin from .recording import RecordingAdmin +from .species import SpeciesAdmin from .spectrogram import SpectrogramAdmin +from .temporal_annotations import TemporalAnnotationsAdmin __all__ = [ 'AnnotationsAdmin', 'ImageAdmin', 'RecordingAdmin', 'SpectrogramAdmin', + 'TemporalAnnotationsAdmin', + 'SpeciesAdmin', ] diff --git a/bats_ai/core/admin/species.py b/bats_ai/core/admin/species.py new file mode 100644 index 0000000..cad6420 --- /dev/null +++ b/bats_ai/core/admin/species.py @@ -0,0 +1,17 @@ +from django.contrib import admin + +from bats_ai.core.models import Species + + +@admin.register(Species) +class SpeciesAdmin(admin.ModelAdmin): + list_display = [ + 'pk', + 'species_code', + 'family', + 'genus', + 'species', + 'common_name', + 'species_code_6', + ] + list_select_related = True diff --git a/bats_ai/core/admin/temporal_annotations.py b/bats_ai/core/admin/temporal_annotations.py new file mode 100644 index 0000000..9415855 --- /dev/null +++ b/bats_ai/core/admin/temporal_annotations.py @@ -0,0 +1,18 @@ +from django.contrib import admin + +from bats_ai.core.models import TemporalAnnotations + + +@admin.register(TemporalAnnotations) +class TemporalAnnotationsAdmin(admin.ModelAdmin): + list_display = [ + 'pk', + 'recording', + 'owner', + 'start_time', + 'end_time', + 'type', + 'comments', + ] + list_select_related = True + autocomplete_fields = ['owner'] diff --git a/bats_ai/core/migrations/0007_temporalannotations.py b/bats_ai/core/migrations/0007_temporalannotations.py new file mode 100644 index 0000000..6549b82 --- /dev/null +++ b/bats_ai/core/migrations/0007_temporalannotations.py @@ -0,0 +1,43 @@ +# Generated by Django 4.1.13 on 2024-02-15 18:08 + +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', '0006_alter_recording_recording_location'), + ] + + operations = [ + migrations.CreateModel( + name='TemporalAnnotations', + fields=[ + ( + 'id', + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name='ID' + ), + ), + ('start_time', models.IntegerField(blank=True, null=True)), + ('end_time', models.IntegerField(blank=True, null=True)), + ('type', models.TextField(blank=True, null=True)), + ('comments', models.TextField(blank=True, null=True)), + ( + 'owner', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL + ), + ), + ( + 'recording', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to='core.recording' + ), + ), + ('species', models.ManyToManyField(to='core.species')), + ], + ), + ] diff --git a/bats_ai/core/models/__init__.py b/bats_ai/core/models/__init__.py index 3dfd89d..cc8bf05 100644 --- a/bats_ai/core/models/__init__.py +++ b/bats_ai/core/models/__init__.py @@ -4,6 +4,7 @@ from .recording_annotation_status import RecordingAnnotationStatus from .species import Species from .spectrogram import Spectrogram +from .temporal_annotations import TemporalAnnotations __all__ = [ 'Annotations', @@ -12,4 +13,5 @@ 'RecordingAnnotationStatus', 'Species', 'Spectrogram', + 'TemporalAnnotations', ] diff --git a/bats_ai/core/models/temporal_annotations.py b/bats_ai/core/models/temporal_annotations.py new file mode 100644 index 0000000..501e2ac --- /dev/null +++ b/bats_ai/core/models/temporal_annotations.py @@ -0,0 +1,15 @@ +from django.contrib.auth.models import User +from django.db import models + +from .recording import Recording +from .species import Species + + +class TemporalAnnotations(models.Model): + recording = models.ForeignKey(Recording, on_delete=models.CASCADE) + owner = models.ForeignKey(User, on_delete=models.CASCADE) + start_time = models.IntegerField(blank=True, null=True) + end_time = models.IntegerField(blank=True, null=True) + type = models.TextField(blank=True, null=True) + comments = models.TextField(blank=True, null=True) + species = models.ManyToManyField(Species) diff --git a/bats_ai/core/views/__init__.py b/bats_ai/core/views/__init__.py index 6a0e84a..ddf5efb 100644 --- a/bats_ai/core/views/__init__.py +++ b/bats_ai/core/views/__init__.py @@ -1,7 +1,11 @@ +from .annotations import router as AnnotationRouter from .recording import router as RecordingRouter from .species import router as SpeciesRouter +from .temporal_annotations import router as TemporalAnnotationRouter __all__ = [ 'RecordingRouter', 'SpeciesRouter', + 'AnnotationRouter', + 'TemporalAnnotationRouter', ] diff --git a/bats_ai/core/views/annotations.py b/bats_ai/core/views/annotations.py index 68c9445..e83fd25 100644 --- a/bats_ai/core/views/annotations.py +++ b/bats_ai/core/views/annotations.py @@ -2,9 +2,9 @@ from django.http import HttpRequest from ninja import Schema -from ninja.errors import HttpError from ninja.pagination import RouterPaginated -from oauth2_provider.models import AccessToken + +from bats_ai.core.models import Annotations, Recording logger = logging.getLogger(__name__) @@ -23,10 +23,28 @@ class AnnotationSchema(Schema): comments: str -def get_owner_id(request: HttpRequest): - token = request.headers.get('Authorization').replace('Bearer ', '') - token_found = AccessToken.objects.get(token=token) - if not token_found: - raise HttpError(401, 'Authentication credentials were not provided.') +@router.get('/{id}') +def get_annotation(request: HttpRequest, id: int): + try: + annotation = Annotations.objects.get(pk=id) + recording = annotation.recording + + # Check if the user owns the recording or if the recording is public + if recording.owner == request.user or recording.public: + # Query annotations associated with the recording that are owned by the current user + annotations_qs = Annotations.objects.filter(recording=recording, owner=request.user) + + # Serialize the annotations using AnnotationSchema + annotations_data = [ + AnnotationSchema.from_orm(annotation, owner_email=request.user.email).dict() + for annotation in annotations_qs + ] + + return annotations_data + else: + return { + 'error': 'Permission denied. You do not own this annotation, or the associated recording is not public.' + } - return token_found.user.pk + except Recording.DoesNotExist: + return {'error': 'Recording not found'} diff --git a/bats_ai/core/views/recording.py b/bats_ai/core/views/recording.py index 4fc9323..f9926fa 100644 --- a/bats_ai/core/views/recording.py +++ b/bats_ai/core/views/recording.py @@ -10,8 +10,12 @@ from ninja.files import UploadedFile from ninja.pagination import RouterPaginated -from bats_ai.core.models import Annotations, Recording, Species +from bats_ai.core.models import Annotations, Recording, Species, TemporalAnnotations from bats_ai.core.views.species import SpeciesSchema +from bats_ai.core.views.temporal_annotations import ( + TemporalAnnotationSchema, + UpdateTemporalAnnotationSchema, +) logger = logging.getLogger(__name__) @@ -202,13 +206,22 @@ def get_spectrogram(request: HttpRequest, id: int): spectro_data['currentUser'] = request.user.email annotations_qs = Annotations.objects.filter(recording=recording, owner=request.user) + temporal_annotations_qs = TemporalAnnotations.objects.filter( + recording=recording, owner=request.user + ) # Serialize the annotations using AnnotationSchema annotations_data = [ AnnotationSchema.from_orm(annotation, owner_email=request.user.email).dict() for annotation in annotations_qs ] + temporal_annotations_data = [ + TemporalAnnotationSchema.from_orm(annotation, owner_email=request.user.email).dict() + for annotation in temporal_annotations_qs + ] + spectro_data['annotations'] = annotations_data + spectro_data['temporal'] = temporal_annotations_data return spectro_data @@ -306,6 +319,9 @@ def get_other_user_annotations(request: HttpRequest, id: int): annotations_qs = Annotations.objects.filter(recording=recording).exclude( owner=request.user ) + temporal_qs = TemporalAnnotations.objects.filter(recording=recording).exclude( + owner=request.user + ) # Create a dictionary to store annotations for each user annotations_by_user = {} @@ -315,13 +331,24 @@ def get_other_user_annotations(request: HttpRequest, id: int): user_email = annotation.owner.email # If user_email is not already a key in the dictionary, initialize it with an empty list - annotations_by_user.setdefault(user_email, []) + annotations_by_user.setdefault(user_email, {'annotations': [], 'temporal': []}) # Append the annotation to the list for the corresponding user_email - annotations_by_user[user_email].append( + annotations_by_user[user_email]['annotations'].append( AnnotationSchema.from_orm(annotation, owner_email=user_email).dict() ) + for annotation in temporal_qs: + user_email = annotation.owner.email + + # If user_email is not already a key in the dictionary, initialize it with an empty list + annotations_by_user.setdefault(user_email, {'annotations': [], 'temporal': []}) + + # Append the annotation to the list for the corresponding user_email + annotations_by_user[user_email]['temporal'].append( + TemporalAnnotationSchema.from_orm(annotation, owner_email=user_email).dict() + ) + return annotations_by_user else: return { @@ -403,7 +430,7 @@ def patch_annotation( recording_id: int, id: int, annotation: UpdateAnnotationsSchema, - species_ids: list[int], + species_ids: list[int] | None, ): try: recording = Recording.objects.get(pk=recording_id) @@ -428,16 +455,68 @@ def patch_annotation( annotation_instance.save() # Clear existing species associations - annotation_instance.species.clear() + if species_ids: + annotation_instance.species.clear() + # Add species to the annotation based on the provided species_ids + for species_id in species_ids: + try: + species_obj = Species.objects.get(pk=species_id) + annotation_instance.species.add(species_obj) + except Species.DoesNotExist: + # Handle the case where the species with the given ID doesn't exist + return {'error': f'Species with ID {species_id} not found'} - # Add species to the annotation based on the provided species_ids - for species_id in species_ids: - try: - species_obj = Species.objects.get(pk=species_id) - annotation_instance.species.add(species_obj) - except Species.DoesNotExist: - # Handle the case where the species with the given ID doesn't exist - return {'error': f'Species with ID {species_id} not found'} + return {'message': 'Annotation updated successfully', 'id': annotation_instance.pk} + else: + return { + 'error': 'Permission denied. You do not own this recording, and it is not public.' + } + + except Recording.DoesNotExist: + return {'error': 'Recording not found'} + except Annotations.DoesNotExist: + return {'error': 'Annotation not found'} + + +@router.patch('/{recording_id}/temporal-annotations/{id}') +def patch_temporal_annotation( + request, + recording_id: int, + id: int, + annotation: UpdateTemporalAnnotationSchema, + species_ids: list[int] | None, +): + try: + recording = Recording.objects.get(pk=recording_id) + + # Check if the user owns the recording or if the recording is public + if recording.owner == request.user or recording.public: + annotation_instance = TemporalAnnotations.objects.get( + pk=id, recording=recording, owner=request.user + ) + + # Update annotation details + if annotation.start_time: + annotation_instance.start_time = annotation.start_time + if annotation.end_time: + annotation_instance.end_time = annotation.end_time + if annotation.comments: + annotation_instance.comments = annotation.comments + if annotation.type: + annotation_instance.type = annotation.type + annotation_instance.save() + + # Clear existing species associations + if species_ids: + annotation_instance.species.clear() + # Add species to the annotation based on the provided species_ids + for species_id in species_ids: + try: + species_obj = Species.objects.get(pk=species_id) + annotation_instance.species.add(species_obj) + except Species.DoesNotExist: + # Handle the case where the species with the given ID doesn't exist + return {'error': f'Species with ID {species_id} not found'} return {'message': 'Annotation updated successfully', 'id': annotation_instance.pk} else: @@ -475,3 +554,90 @@ def delete_annotation(request, recording_id: int, id: int): return {'error': 'Recording not found'} except Annotations.DoesNotExist: return {'error': 'Annotation not found'} + + +# TEMPORAL ANNOTATIONS + + +@router.get('/{id}/temporal-annotations') +def get_temporal_annotations(request: HttpRequest, id: int): + try: + recording = Recording.objects.get(pk=id) + + # Check if the user owns the recording or if the recording is public + if recording.owner == request.user or recording.public: + # Query annotations associated with the recording that are owned by the current user + annotations_qs = TemporalAnnotations.objects.filter( + recording=recording, owner=request.user + ) + + # Serialize the annotations using AnnotationSchema + annotations_data = [ + TemporalAnnotationSchema.from_orm(annotation, owner_email=request.user.email).dict() + for annotation in annotations_qs + ] + + return annotations_data + else: + return { + 'error': 'Permission denied. You do not own this recording, and it is not public.' + } + + except Recording.DoesNotExist: + return {'error': 'Recording not found'} + + +@router.put('/{id}/temporal-annotations') +def put_temporal_annotation( + request, + id: int, + annotation: TemporalAnnotationSchema, + species_ids: list[int] | None, +): + try: + recording = Recording.objects.get(pk=id) + if recording.owner == request.user or recording.public: + # Create a new annotation + new_annotation = TemporalAnnotations.objects.create( + recording=recording, + owner=request.user, + start_time=annotation.start_time, + end_time=annotation.end_time, + type=annotation.type, + comments=annotation.comments, + ) + + return {'message': 'Annotation added successfully', 'id': new_annotation.pk} + else: + return { + 'error': 'Permission denied. You do not own this recording, and it is not public.' + } + + except Recording.DoesNotExist: + return {'error': 'Recording not found'} + + +@router.delete('/{recording_id}/temporal-annotations/{id}') +def delete_temporal_annotation(request, recording_id: int, id: int): + try: + recording = Recording.objects.get(pk=recording_id) + + # Check if the user owns the recording or if the recording is public + if recording.owner == request.user or recording.public: + annotation_instance = TemporalAnnotations.objects.get( + pk=id, recording=recording, owner=request.user + ) + + # Delete the annotation + annotation_instance.delete() + + return {'message': 'Annotation deleted successfully'} + else: + return { + 'error': 'Permission denied. You do not own this recording, and it is not public.' + } + + except Recording.DoesNotExist: + return {'error': 'Recording not found'} + except Annotations.DoesNotExist: + return {'error': 'Annotation not found'} diff --git a/bats_ai/core/views/temporal_annotations.py b/bats_ai/core/views/temporal_annotations.py new file mode 100644 index 0000000..0495acb --- /dev/null +++ b/bats_ai/core/views/temporal_annotations.py @@ -0,0 +1,66 @@ +from django.http import HttpRequest +from ninja import Schema +from ninja.pagination import RouterPaginated + +from bats_ai.core.models import Annotations, Recording, TemporalAnnotations +from bats_ai.core.views.species import SpeciesSchema + +router = RouterPaginated() + + +class TemporalAnnotationSchema(Schema): + id: int + start_time: int + end_time: int + type: str + comments: str + species: list[SpeciesSchema] | None + owner_email: str = None + + @classmethod + def from_orm(cls, obj, owner_email=None, **kwargs): + return cls( + start_time=obj.start_time, + end_time=obj.end_time, + type=obj.type, + species=[SpeciesSchema.from_orm(species) for species in obj.species.all()], + comments=obj.comments, + id=obj.id, + owner_email=owner_email, # Include owner_email in the schema + ) + + +class UpdateTemporalAnnotationSchema(Schema): + start_time: int = None + end_time: int = None + type: str | None = None + comments: str | None = None + + +@router.get('/{id}') +def get_temporal_annotation(request: HttpRequest, id: int): + try: + annotation = Annotations.objects.get(pk=id) + recording = annotation.recording + + # Check if the user owns the recording or if the recording is public + if recording.owner == request.user or recording.public: + # Query annotations associated with the recording that are owned by the current user + annotations_qs = TemporalAnnotations.objects.filter( + recording=recording, owner=request.user + ) + + # Serialize the annotations using AnnotationSchema + annotations_data = [ + TemporalAnnotationSchema.from_orm(annotation, owner_email=request.user.email).dict() + for annotation in annotations_qs + ] + + return annotations_data + else: + return { + 'error': 'Permission denied. You do not own this annotation, or the associated recording is not public.' + } + + except Recording.DoesNotExist: + return {'error': 'Recording not found'} diff --git a/client/src/App.vue b/client/src/App.vue index c31c389..43b0aa9 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -1,18 +1,21 @@ + + + + diff --git a/client/src/components/UploadRecording.vue b/client/src/components/UploadRecording.vue index 8f71304..75bf020 100644 --- a/client/src/components/UploadRecording.vue +++ b/client/src/components/UploadRecording.vue @@ -291,7 +291,7 @@ export default defineComponent({ diff --git a/client/src/components/geoJS/LayerManager.vue b/client/src/components/geoJS/LayerManager.vue index 8a1ad46..ef0d3f1 100644 --- a/client/src/components/geoJS/LayerManager.vue +++ b/client/src/components/geoJS/LayerManager.vue @@ -1,13 +1,15 @@ + + diff --git a/client/src/views/Recordings.vue b/client/src/views/Recordings.vue index 37bc374..944d9cb 100644 --- a/client/src/views/Recordings.vue +++ b/client/src/views/Recordings.vue @@ -119,7 +119,7 @@ export default defineComponent({ id: item.id, }; if (item.recording_location) { - const [ lat, lon ] = item.recording_location.coordinates; + const [ lon, lat ] = item.recording_location.coordinates; editingRecording.value['location'] = {lat, lon}; } uploadDialog.value = true; diff --git a/client/src/views/Spectrogram.vue b/client/src/views/Spectrogram.vue index 3a7b421..42b96af 100644 --- a/client/src/views/Spectrogram.vue +++ b/client/src/views/Spectrogram.vue @@ -5,10 +5,9 @@ import { getAnnotations, getSpectrogram, Species, - SpectrogramAnnotation, getSpectrogramCompressed, - OtherUserAnnotations, getOtherUserAnnotations, + getTemporalAnnotations, } from "../api/api"; import SpectrogramViewer from "../components/SpectrogramViewer.vue"; import { SpectroInfo } from "../components/geoJS/geoJSUtils"; @@ -39,12 +38,14 @@ export default defineComponent({ createColorScale, currentUser, annotationState, + annotations, + temporalAnnotations, + otherUserAnnotations, + selectedId, + selectedType, } = useState(); const image: Ref = ref(new Image()); const spectroInfo: Ref = ref(); - const annotations: Ref = ref([]); - const otherUserAnnotations: Ref = ref({}); - const selectedId: Ref = ref(null); const selectedUsers: Ref = ref([]); const speciesList: Ref = ref([]); const loadedImage = ref(false); @@ -53,6 +54,8 @@ export default defineComponent({ const getAnnotationsList = async (annotationId?: number) => { const response = await getAnnotations(props.id); annotations.value = response.data.sort((a, b) => a.start_time - b.start_time); + const tempResp = await getTemporalAnnotations(props.id); + temporalAnnotations.value = tempResp.data.sort((a, b) => a.start_time - b.start_time); if (annotationId !== undefined) { selectedId.value = annotationId; } @@ -83,7 +86,8 @@ export default defineComponent({ : await getSpectrogram(props.id); image.value.src = `data:image/png;base64,${response.data["base64_spectrogram"]}`; spectroInfo.value = response.data["spectroInfo"]; - annotations.value = response.data["annotations"]?.sort((a, b) => a.start_time - b.start_time); + annotations.value = response.data["annotations"]?.sort((a, b) => a.start_time - b.start_time) || []; + temporalAnnotations.value = response.data["temporal"]?.sort((a, b) => a.start_time - b.start_time) || []; if (response.data.currentUser) { currentUser.value = response.data.currentUser; } @@ -101,12 +105,18 @@ export default defineComponent({ selectedId.value = annotationId; }; const selectedAnnotation = computed(() => { - if (selectedId.value !== null && annotations.value) { + if (selectedId.value !== null && selectedType.value === 'pulse' && annotations.value) { const found = annotations.value.findIndex((item) => item.id === selectedId.value); if (found !== -1) { return annotations.value[found]; } } + if (selectedId.value !== null && selectedType.value === 'sequence' && temporalAnnotations.value) { + const found = temporalAnnotations.value.findIndex((item) => item.id === selectedId.value); + if (found !== -1) { + return temporalAnnotations.value[found]; + } + } return null; }); watch(gridEnabled, () => { @@ -153,6 +163,11 @@ export default defineComponent({ setSelectedUsers(selectedUsers.value); }); + const processSelection = ({id, annotationType}: { id: number, annotationType: 'pulse' | 'sequence'}) => { + selectedId.value = id; + selectedType.value = annotationType; + }; + return { annotationState, compressed, @@ -161,11 +176,13 @@ export default defineComponent({ spectroInfo, annotations, selectedId, + selectedType, setSelection, getAnnotationsList, setParentGeoViewer, setHoverData, toggleLayerVisibility, + processSelection, speciesList, selectedAnnotation, parentGeoViewerRef, @@ -175,6 +192,7 @@ export default defineComponent({ freqRef, // Other user selection otherUserAnnotations, + temporalAnnotations, otherUsers, selectedUsers, deleteChip, @@ -318,9 +336,7 @@ export default defineComponent({ :image="image" :spectro-info="spectroInfo" :recording-id="id" - :annotations="annotations" :other-user-annotations="otherUserAnnotations" - :selected-id="selectedId" :grid="gridEnabled" @selected="setSelection($event)" @create:annotation="getAnnotationsList($event)" @@ -333,9 +349,6 @@ export default defineComponent({ :image="image" :spectro-info="spectroInfo" :recording-id="id" - :annotations="annotations" - :other-user-annotations="otherUserAnnotations" - :selected-id="selectedId" :parent-geo-viewer-ref="parentGeoViewerRef" @selected="setSelection($event)" /> @@ -343,9 +356,9 @@ export default defineComponent({