diff --git a/backend/api/schema/filter_sets/annotation_spectrogram.py b/backend/api/schema/filter_sets/annotation_spectrogram.py index b68e8b2e2..8b829ddbb 100644 --- a/backend/api/schema/filter_sets/annotation_spectrogram.py +++ b/backend/api/schema/filter_sets/annotation_spectrogram.py @@ -1,4 +1,6 @@ -from django.db.models import QuerySet, Exists, OuterRef, Subquery +from typing import Optional, TypedDict + +from django.db.models import QuerySet, OuterRef, Q, Exists from django_extension.filters import ExtendedFilterSet, IDFilter from django_filters import OrderingFilter, filters from graphene_django import filter @@ -8,13 +10,31 @@ AnnotationFileRange, AnnotationTask, Annotation, - AnnotationPhase, AnnotationCampaign, + Confidence, + Label, + Detector, + AnnotationPhase, ) from backend.api.schema.enums import AnnotationPhaseType, AnnotationTaskStatus from backend.aplose.models import User +class FilterData(TypedDict): + phase: Optional[AnnotationPhase.Type] + annotation_campaign: Optional[AnnotationCampaign] + annotator: Optional[User] + + annotation_tasks__status: Optional[AnnotationTask.Status] + annotations__exists: Optional[bool] + annotations__confidence: Optional[Confidence] + annotations__label: Optional[Label] + annotations__acoustic_features__exists: Optional[bool] + annotations__detector: Optional[Detector] + annotations__annotator: Optional[User] + only_assigned: Optional[bool] + + class AnnotationSpectrogramFilterSet(ExtendedFilterSet): phase = filter.TypedFilter(AnnotationPhaseType, method="fake") @@ -45,147 +65,108 @@ class Meta: def fake(self, queryset, _1, _2): return queryset - def filter_queryset(self, queryset: QuerySet[Spectrogram]): - queryset = super().filter_queryset(queryset) + def get_filter_data(self) -> FilterData: + phase = self.data.get("phase") + annotation_tasks__status = self.data.get("annotation_tasks__status") + return { + "phase": AnnotationPhase.Type(phase) if phase is not None else phase, + "annotation_campaign": AnnotationCampaign.objects.filter( + id=self.data.get("annotation_campaign") + ).first(), + "annotator": User.objects.filter(id=self.data.get("annotator")).first(), + "annotation_tasks__status": AnnotationTask.Status(annotation_tasks__status) + if annotation_tasks__status is not None + else annotation_tasks__status, + "annotations__exists": self.data.get("annotations__exists"), + "annotations__acoustic_features__exists": self.data.get( + "annotations__acoustic_features__exists" + ), + "annotations__confidence": Confidence.objects.filter( + id=self.data.get("annotations__confidence") + ).first(), + "annotations__label": Label.objects.filter( + id=self.data.get("annotations__label") + ).first(), + "annotations__detector": Detector.objects.filter( + id=self.data.get("annotations__detector") + ).first(), + "annotations__annotator": User.objects.filter( + id=self.data.get("annotations__annotator") + ).first(), + "only_assigned": self.data.get("only_assigned"), + } + + def filter_on_file_ranges( + self, queryset: QuerySet[Spectrogram] + ) -> tuple[QuerySet[Spectrogram], QuerySet[Annotation]]: + filter_data = self.get_filter_data() - queryset, file_ranges, tasks, annotations = self._get_querysets_for_filter( - queryset, only_assigned=self.data.get("only_assigned", False) + # Filter: only_assigned [bool] + filter_only_assigned: bool = ( + filter_data["only_assigned"] + or filter_data["annotations__exists"] is not None + or filter_data["annotation_tasks__status"] is not None + ) + annotator_can_see_all = ( + filter_data["annotator"].is_staff or filter_data["annotator"].is_superuser ) - # Filter on task status - status = self.data.get("annotation_tasks__status") - if status: - # Filter through existing file range - only assigned tasks have status - queryset = queryset.filter( - Exists( - file_ranges.filter( - from_datetime__lte=OuterRef("start"), - to_datetime__gte=OuterRef("end"), - ) - ) - ) - q = Exists( - tasks.filter( - status=AnnotationTask.Status.FINISHED, spectrogram_id=OuterRef("id") - ) + # => QuerySet[AnnotationFileRange] & QuerySet[Annotation] + file_ranges: QuerySet[AnnotationFileRange] = AnnotationFileRange.objects.all() + annotations: QuerySet[Annotation] = Annotation.objects.all() + if filter_data["annotator"]: + file_ranges = AnnotationFileRange.objects.filter_viewable_by( + user=filter_data["annotator"] ) - if status == AnnotationTask.Status.FINISHED: - queryset = queryset.filter(q) - if status == AnnotationTask.Status.CREATED: - queryset = queryset.filter(~q) - - # Filter on annotations status - if self.data.get("annotations__exists") is not None: - # Filter through existing file range - only assigned tasks can have annotations - or not - queryset = queryset.filter( - Exists( - file_ranges.filter( - from_datetime__lte=OuterRef("start"), - to_datetime__gte=OuterRef("end"), - ) - ) - ) - - label = self.data.get("annotations__label") - if label: - annotations = annotations.filter(label__id=label) - - confidence = self.data.get("annotations__confidence") - if confidence: - annotations = annotations.filter(confidence__id=confidence) - - features_exists = self.data.get("annotations__acoustic_features__exists") - if features_exists: - annotations = annotations.filter( - acoustic_features__isnull=not features_exists - ) - - detector = self.data.get("annotations__detector") - if detector: - annotations = annotations.filter( - detector_configuration__detector_id=detector - ) - - a_annotator = self.data.get("annotations__annotator") - if a_annotator: - annotations = annotations.filter(annotator_id=a_annotator) - - q = Exists( - Subquery( - annotations.filter( - spectrogram_id=OuterRef("id"), - ) - ) + if filter_only_assigned: + file_ranges = file_ranges.filter(annotator=filter_data["annotator"]) + if filter_data["annotation_campaign"]: + annotator_can_see_all = ( + annotator_can_see_all + or filter_data["annotation_campaign"].owner_id + == filter_data["annotator"].id ) - if self.data.get("annotations__exists"): - queryset = queryset.filter(q) - else: - queryset = queryset.filter(~q) - - return queryset.distinct() - - def _get_querysets_for_filter( - self, queryset: QuerySet[Spectrogram], only_assigned=False - ) -> tuple[ - QuerySet[Spectrogram], - QuerySet[AnnotationFileRange], - QuerySet[AnnotationTask], - QuerySet[Annotation], - ]: - can_see_unassigned = False - - spectrograms = queryset - file_ranges = AnnotationFileRange.objects.all() - tasks = AnnotationTask.objects.all() - annotations = Annotation.objects.all() - - phase_type = self.data.get("phase") - if phase_type: - file_ranges = file_ranges.filter(annotation_phase__phase=phase_type) - tasks = tasks.filter(annotation_phase__phase=phase_type) - - campaign_id = self.data.get("annotation_campaign") - if campaign_id: file_ranges = file_ranges.filter( - annotation_phase__annotation_campaign_id=campaign_id + annotation_phase__annotation_campaign=filter_data["annotation_campaign"] ) - tasks = tasks.filter(annotation_phase__annotation_campaign_id=campaign_id) annotations = annotations.filter( - annotation_phase__annotation_campaign_id=campaign_id + annotation_phase__annotation_campaign=filter_data["annotation_campaign"] ) - spectrograms = spectrograms.filter( - analysis__annotation_campaigns__id=campaign_id + queryset = queryset.filter( + analysis__annotation_campaigns=filter_data["annotation_campaign"] ) - - if phase_type and campaign_id: - if phase_type == AnnotationPhase.Type.ANNOTATION: + if filter_data["phase"]: + file_ranges = file_ranges.filter( + annotation_phase__phase=filter_data["phase"] + ) + if filter_data["annotation_campaign"]: + phase = ( + filter_data["annotation_campaign"] + .phases.filter(phase=filter_data["phase"]) + .first() + ) + if phase: + annotator_can_see_all = ( + annotator_can_see_all + or phase.created_by_id == filter_data["annotator"].id + ) + if filter_data["phase"] == AnnotationPhase.Type.ANNOTATION: + annotations = annotations.filter( + annotation_phase__phase=filter_data["phase"] + ) + if filter_data["annotator"]: + annotations = annotations.filter(annotator=filter_data["annotator"]) + elif filter_data["annotator"]: annotations = annotations.filter( - annotation_phase__phase=phase_type, - annotation_phase__annotation_campaign_id=campaign_id, + ~Q( + annotator=filter_data["annotator"], + annotation_phase__phase=AnnotationPhase.Type.ANNOTATION, + ) ) - annotator_id = self.data.get("annotator") - if annotator_id: - user = User.objects.get(pk=annotator_id) - file_ranges = file_ranges.filter(annotator=user) - tasks = tasks.filter(annotator=user) - if phase_type == AnnotationPhase.Type.ANNOTATION: - annotations = annotations.filter(annotator=user) - if user.is_superuser or user.is_staff: - can_see_unassigned = True - if campaign_id: - campaign = AnnotationCampaign.objects.get(pk=campaign_id) - if campaign.owner_id == user.id: - can_see_unassigned = True - if ( - phase_type - and campaign.phases.get(phase=phase_type).created_by_id == user.id - ): - can_see_unassigned = True - - if only_assigned or not can_see_unassigned: - # Filter through existing file range - spectrograms = spectrograms.filter( + # Filter assigned spectrograms + if filter_only_assigned or not annotator_can_see_all: + queryset = queryset.filter( Exists( file_ranges.filter( from_datetime__lte=OuterRef("start"), @@ -194,4 +175,67 @@ def _get_querysets_for_filter( ) ) - return spectrograms, file_ranges, tasks, annotations + # Filter on task status + if filter_data["annotation_tasks__status"]: + tasks_ids = [] + for fr in file_ranges: + tasks_ids += fr.tasks.values_list("id", flat=True) + tasks: QuerySet[AnnotationTask] = AnnotationTask.objects.filter( + id__in=tasks_ids + ) + finished_task_spectrogram_ids = tasks.filter( + status=AnnotationTask.Status.FINISHED + ).values_list("spectrogram_id", flat=True) + query = Q(id__in=finished_task_spectrogram_ids) + if ( + filter_data["annotation_tasks__status"] + == AnnotationTask.Status.FINISHED + ): + queryset = queryset.filter(query) + if filter_data["annotation_tasks__status"] == AnnotationTask.Status.CREATED: + queryset = queryset.filter(~query) # Created task may not exist at all + + return queryset, annotations + + def filter_queryset(self, queryset: QuerySet[Spectrogram]): + queryset: QuerySet[Spectrogram] = super().filter_queryset(queryset) + queryset, annotations = self.filter_on_file_ranges(queryset) + filter_data = self.get_filter_data() + + if filter_data["annotations__exists"] is not None: + if filter_data["annotations__exists"]: + if filter_data["annotations__label"]: + annotations = annotations.filter( + label=filter_data["annotations__label"] + ) + if filter_data["annotations__confidence"]: + annotations = annotations.filter( + confidence=filter_data["annotations__confidence"] + ) + if filter_data["annotations__annotator"]: + annotations = annotations.filter( + annotator=filter_data["annotations__annotator"] + ) + if filter_data["annotations__detector"]: + annotations = annotations.filter( + detector_configuration__detector=filter_data[ + "annotations__detector" + ] + ) + if filter_data["annotations__acoustic_features__exists"] is not None: + annotations = annotations.filter( + acoustic_features__isnull=not filter_data[ + "annotations__acoustic_features__exists" + ] + ) + + annotations_spectrogram_ids = annotations.values_list( + "spectrogram_id", flat=True + ).distinct() + queryset = queryset.filter(id__in=annotations_spectrogram_ids) + else: + queryset = queryset.filter( + ~Q(id__in=annotations.values_list("spectrogram_id", flat=True)) + ) + + return queryset.distinct() diff --git a/backend/api/tests/schema/annotation_spectrogram/all_annotation_spectrograms.py b/backend/api/tests/schema/annotation_spectrogram/all_annotation_spectrograms.py index decc75d51..983665aef 100644 --- a/backend/api/tests/schema/annotation_spectrogram/all_annotation_spectrograms.py +++ b/backend/api/tests/schema/annotation_spectrogram/all_annotation_spectrograms.py @@ -343,22 +343,6 @@ def test_connected_admin__label(self): content = json.loads(response.content)["data"]["allAnnotationSpectrograms"] self.assertEqual(content["totalCount"], 1) - def test_connected_admin__confidence_empty(self): - response = self.gql_query( - QUERY, - user=User.objects.get(username="admin"), - variables={ - **VARIABLES, - "annotatorID": 1, - "withAnnotations": True, - "annotationConfidence": 3, - }, - ) - self.assertResponseNoErrors(response) - - content = json.loads(response.content)["data"]["allAnnotationSpectrograms"] - self.assertEqual(content["totalCount"], 0) - def test_connected_admin__confidence(self): response = self.gql_query( QUERY, diff --git a/frontend/docs/dev/installation/docker.md b/frontend/docs/dev/installation/docker.md index ccd078c2c..f20866d4a 100644 --- a/frontend/docs/dev/installation/docker.md +++ b/frontend/docs/dev/installation/docker.md @@ -48,7 +48,7 @@ In the `docker-compose.yml` file: To access the audio files and spectrogram, APLOSE mount the volume where the data is located inside its containers (see `osmose_back` and `osmose_front` services volumes). -For development purpose we use a `/volumes/datawork` folder in the project root folder. This can be changed at any moment. Just be sure to update it both in front and back services. +For development purpose we use a `/volumes/datawork/dataset` folder in the project root folder. This can be changed at any moment. Just be sure to update it both in front and back services. ::: info Note The format for volume mount is [local mount]:[container mount], only the local mount should be changed. diff --git a/frontend/src/components/base/Form/index.tsx b/frontend/src/components/base/Form/index.tsx index 30ac70576..13bb075e7 100644 --- a/frontend/src/components/base/Form/index.tsx +++ b/frontend/src/components/base/Form/index.tsx @@ -12,15 +12,15 @@ export type FormProps = Omit & { gqlErrors?: (ErrorType | null)[] | null }; -export const Form: React.FC = React.memo(({ - className, - horizontal, - center, - gqlErrors, - errors, - children, - ...props - }) => { +export const Form = React.forwardRef(({ + className, + horizontal, + center, + gqlErrors, + errors, + children, + ...props + }, ref) => { const computedErrors = useMemo(() => errors || cleanGqlErrors(gqlErrors), [ errors, gqlErrors ]) const classes = useMemo(() => { @@ -31,7 +31,8 @@ export const Form: React.FC = React.memo(({ return classes }, [ className, horizontal, center ]) - return { children } diff --git a/frontend/src/features/AnnotationTask/AnnotationsFilter.tsx b/frontend/src/features/AnnotationTask/AnnotationsFilter.tsx index cd7f6614d..ec96a4074 100644 --- a/frontend/src/features/AnnotationTask/AnnotationsFilter.tsx +++ b/frontend/src/features/AnnotationTask/AnnotationsFilter.tsx @@ -1,4 +1,4 @@ -import React, { type FormEvent, Fragment, useCallback, useState } from 'react'; +import React, { type FormEvent, Fragment, useCallback, useRef, useState } from 'react'; import { AnnotationPhaseType } from '@/api'; import { ConfidenceComponent } from '@/features/Confidence'; import { LabelComponent } from '@/features/Labels'; @@ -41,10 +41,13 @@ export const AnnotationsFilterModal: React.FC = () => { withAcousticFeatures, }), }); + const { user } = useLoaderData({from: '/_authenticated'}) + const formRef = useRef(null) const routeParams = Route.useParams() const navigate = useNavigate(); const { campaign, labels, confidences } = useLoaderData({ from: '/_authenticated/annotation-campaign/$campaignID' }) + const [ tmpWithAF, setTmpWithAF ] = useState(withAcousticFeatures ?? null); const [ tmpWithAnnotations, setTmpWithAnnotations ] = useState(withAnnotations ?? null); const update = useCallback((data: Pick) => { @@ -60,18 +63,18 @@ export const AnnotationsFilterModal: React.FC = () => { }) }, [ navigate, routeParams ]) - const onSubmit = useCallback((event: BaseUIEvent>) => { - event.preventDefault(); - const formData = new FormData(event.currentTarget); + const onSubmitRef = useCallback(() => { + if (!formRef.current) return + const formData = new FormData(formRef.current); if (tmpWithAnnotations == true) { const withAcousticFeatures = formData.get('withAcousticFeatures') as string || undefined update({ withAnnotations: true, withAcousticFeatures: withAcousticFeatures === undefined ? undefined : withAcousticFeatures === 'true', - annotationLabel: formData.get('annotationLabel') as string, - annotationConfidence: formData.get('annotationConfidence') as string, - annotationDetector: formData.get('annotationDetector') as string, - annotationAnnotator: formData.get('annotationAnnotator') as string, + annotationLabel: formData.get('annotationLabel') as string || undefined, + annotationConfidence: formData.get('annotationConfidence') as string || undefined, + annotationDetector: formData.get('annotationDetector') as string || undefined, + annotationAnnotator: formData.get('annotationAnnotator') as string || undefined, }) } else { update({ @@ -83,9 +86,10 @@ export const AnnotationsFilterModal: React.FC = () => { annotationAnnotator: undefined, }) } - }, [ update, labels, tmpWithAnnotations, confidences ]) + }, [ update, labels, tmpWithAnnotations, confidences, formRef ]) const onReset = useCallback((event: BaseUIEvent>) => { + console.debug('onReset!!!') event.preventDefault(); update({ withAnnotations: undefined, @@ -98,13 +102,12 @@ export const AnnotationsFilterModal: React.FC = () => { }, [ update ]) return -
+ With annotations - + Unset With Without @@ -137,7 +140,7 @@ export const AnnotationsFilterModal: React.FC = () => { Filter by annotator - a.id !== user.id) } disabled={ tmpWithAnnotations !== true } defaultValueString={ annotationAnnotator ?? undefined }/> @@ -146,7 +149,8 @@ export const AnnotationsFilterModal: React.FC = () => { Acoustic features - Unset With @@ -156,7 +160,7 @@ export const AnnotationsFilterModal: React.FC = () => { Reset - Filter + Filter
diff --git a/frontend/src/routes/_authenticated/annotation-campaign/$campaignID/_detailLayout/phase.$phaseType.tsx b/frontend/src/routes/_authenticated/annotation-campaign/$campaignID/_detailLayout/phase.$phaseType.tsx index 8292e9f72..83a6941ab 100644 --- a/frontend/src/routes/_authenticated/annotation-campaign/$campaignID/_detailLayout/phase.$phaseType.tsx +++ b/frontend/src/routes/_authenticated/annotation-campaign/$campaignID/_detailLayout/phase.$phaseType.tsx @@ -119,7 +119,7 @@ const AnnotationCampaignPhaseDetail: React.FC = () => { - { search.withAnnotations + { search.withAnnotations !== undefined && search.withAnnotations !== null ? : } diff --git a/poetry.lock b/poetry.lock index 15518cf18..f9aef96f3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.5.1 and should not be changed by hand. [[package]] name = "anyio" @@ -1511,7 +1511,7 @@ comm = ">=0.1.1" debugpy = ">=1.6.5" ipython = ">=7.23.1" jupyter-client = ">=8.0.0" -jupyter-core = ">=4.12,<5.0.dev0 || >=5.1.dev0" +jupyter-core = ">=4.12,<5.0 || >=5.1.dev0" matplotlib-inline = ">=0.1" nest-asyncio = ">=1.4" packaging = ">=22" @@ -1687,7 +1687,7 @@ fqdn = {version = "*", optional = true, markers = "extra == \"format-nongpl\""} idna = {version = "*", optional = true, markers = "extra == \"format-nongpl\""} isoduration = {version = "*", optional = true, markers = "extra == \"format-nongpl\""} jsonpointer = {version = ">1.13", optional = true, markers = "extra == \"format-nongpl\""} -jsonschema-specifications = ">=2023.03.6" +jsonschema-specifications = ">=2023.3.6" referencing = ">=0.28.4" rfc3339-validator = {version = "*", optional = true, markers = "extra == \"format-nongpl\""} rfc3986-validator = {version = ">0.1.0", optional = true, markers = "extra == \"format-nongpl\""} @@ -1727,7 +1727,7 @@ files = [ ] [package.dependencies] -jupyter-core = ">=4.12,<5.0.dev0 || >=5.1.dev0" +jupyter-core = ">=4.12,<5.0 || >=5.1.dev0" python-dateutil = ">=2.8.2" pyzmq = ">=23.0" tornado = ">=6.2" @@ -1816,7 +1816,7 @@ anyio = ">=3.1.0" argon2-cffi = ">=21.1" jinja2 = ">=3.0.3" jupyter-client = ">=7.4.4" -jupyter-core = ">=4.12,<5.0.dev0 || >=5.1.dev0" +jupyter-core = ">=4.12,<5.0 || >=5.1.dev0" jupyter-events = ">=0.11.0" jupyter-server-terminals = ">=0.4.4" nbconvert = ">=6.4.4" @@ -1870,7 +1870,7 @@ files = [ [package.dependencies] async-lru = ">=1.0.0" httpx = ">=0.25.0,<1" -ipykernel = ">=6.5.0,<6.30.0 || >6.30.0" +ipykernel = ">=6.5.0,!=6.30.0" jinja2 = ">=3.0.3" jupyter-core = "*" jupyter-lsp = ">=2.0.0" @@ -2293,7 +2293,7 @@ files = [ [package.dependencies] jupyter-client = ">=6.1.12" -jupyter-core = ">=4.12,<5.0.dev0 || >=5.1.dev0" +jupyter-core = ">=4.12,<5.0 || >=5.1.dev0" nbformat = ">=5.1" traitlets = ">=5.4" @@ -2354,7 +2354,7 @@ files = [ [package.dependencies] fastjsonschema = ">=2.15" jsonschema = ">=2.6" -jupyter-core = ">=4.12,<5.0.dev0 || >=5.1.dev0" +jupyter-core = ">=4.12,<5.0 || >=5.1.dev0" traitlets = ">=5.1" [package.extras] @@ -3060,7 +3060,7 @@ files = [ astroid = ">=3.3.8,<=3.4.0.dev0" colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} dill = {version = ">=0.3.7", markers = "python_version >= \"3.12\""} -isort = ">=4.2.5,<5.13 || >5.13,<7" +isort = ">=4.2.5,!=5.13,<7" mccabe = ">=0.6,<0.8" platformdirs = ">=2.2" tomlkit = ">=0.10.1"