diff --git a/ami/main/api/serializers.py b/ami/main/api/serializers.py index cda4e7e94..a1920cc04 100644 --- a/ami/main/api/serializers.py +++ b/ami/main/api/serializers.py @@ -841,7 +841,10 @@ class Meta: "width", "height", "size", + "size_display", "detections_count", + "occurrences_count", + "taxa_count", "detections", ] @@ -986,6 +989,8 @@ class Meta: "source_images", "source_images_count", "source_images_with_detections_count", + "occurrences_count", + "taxa_count", "jobs", "created_at", "updated_at", diff --git a/ami/main/api/views.py b/ami/main/api/views.py index 611386dd2..cd41d88bf 100644 --- a/ami/main/api/views.py +++ b/ami/main/api/views.py @@ -375,7 +375,12 @@ class SourceImageViewSet(DefaultViewSet): GET /captures/1/ """ - queryset = SourceImage.objects.all() + queryset = ( + SourceImage.objects.all() + .with_occurrences_count() # type: ignore + .with_taxa_count() + # .with_detections_count() + ) serializer_class = SourceImageSerializer filterset_fields = ["event", "deployment", "deployment__project", "collections"] @@ -385,6 +390,8 @@ class SourceImageViewSet(DefaultViewSet): "timestamp", "size", "detections_count", + "occurrences_count", + "taxa_count", "deployment__name", "event__start", ] @@ -536,6 +543,8 @@ class SourceImageCollectionViewSet(DefaultViewSet): SourceImageCollection.objects.all() .with_source_images_count() # type: ignore .with_source_images_with_detections_count() + .with_occurrences_count() + .with_taxa_count() .prefetch_related("jobs") ) serializer_class = SourceImageCollectionSerializer @@ -548,6 +557,7 @@ class SourceImageCollectionViewSet(DefaultViewSet): "method", "source_images_count", "source_images_with_detections_count", + "occurrences_count", ] @action(detail=True, methods=["post"], name="populate") @@ -721,6 +731,38 @@ def filter_queryset(self, request, queryset, view): return queryset +class OccurrenceCollectionFilter(filters.BaseFilterBackend): + """ + Filter occurrences by the collection their detections source images belong to. + """ + + query_param = "collection" + + def filter_queryset(self, request, queryset, view): + collection_id = IntegerField(required=False).clean(request.query_params.get(self.query_param)) + if collection_id: + # Here the queryset is the Occurrence queryset + return queryset.filter(detections__source_image__collections=collection_id) + else: + return queryset + + +class TaxonCollectionFilter(filters.BaseFilterBackend): + """ + Filter taxa by the collection their occurrences belong to. + """ + + query_param = "collection" + + def filter_queryset(self, request, queryset, view): + collection_id = IntegerField(required=False).clean(request.query_params.get(self.query_param)) + if collection_id: + # Here the queryset is the Taxon queryset + return queryset.filter(occurrences__detections__source_image__collections=collection_id) + else: + return queryset + + class OccurrenceViewSet(DefaultViewSet): """ API endpoint that allows occurrences to be viewed or edited. @@ -730,8 +772,17 @@ class OccurrenceViewSet(DefaultViewSet): serializer_class = OccurrenceSerializer # filter_backends = [CustomDeterminationFilter, DjangoFilterBackend, NullsLastOrderingFilter, SearchFilter] - filter_backends = DefaultViewSetMixin.filter_backends + [CustomOccurrenceDeterminationFilter] - filterset_fields = ["event", "deployment", "project", "determination__rank"] + filter_backends = DefaultViewSetMixin.filter_backends + [ + CustomOccurrenceDeterminationFilter, + OccurrenceCollectionFilter, + ] + filterset_fields = [ + "event", + "deployment", + "project", + "determination__rank", + "detections__source_image", + ] ordering_fields = [ "created_at", "updated_at", @@ -874,29 +925,37 @@ def filter_taxa_by_observed(self, queryset: QuerySet) -> tuple[QuerySet, bool]: "occurrences__deployment" ) event_id = self.request.query_params.get("event") or self.request.query_params.get("occurrences__event") + collection_id = self.request.query_params.get("collection") - filter_active = any([occurrence_id, project_id, deployment_id, event_id]) + filter_active = any([occurrence_id, project_id, deployment_id, event_id, collection_id]) + if not project_id: + # Raise a 400 if no project is specified + raise api_exceptions.ValidationError(detail="A project must be specified") + + queryset = super().get_queryset() try: + if project_id: + project = Project.objects.get(id=project_id) + queryset = queryset.filter(occurrences__project=project) if occurrence_id: occurrence = Occurrence.objects.get(id=occurrence_id) # This query does not need the same filtering as the others - return queryset.filter(occurrences=occurrence).distinct(), True - elif project_id: - project = Project.objects.get(id=project_id) - queryset = super().get_queryset().filter(occurrences__project=project) - elif deployment_id: + queryset = queryset.filter(occurrences=occurrence) + if deployment_id: deployment = Deployment.objects.get(id=deployment_id) - queryset = super().get_queryset().filter(occurrences__deployment=deployment) - elif event_id: + queryset = queryset.filter(occurrences__deployment=deployment) + if event_id: event = Event.objects.get(id=event_id) - queryset = super().get_queryset().filter(occurrences__event=event) + queryset = queryset.filter(occurrences__event=event) + if collection_id: + queryset = queryset.filter(occurrences__detections__source_image__collections=collection_id) except exceptions.ObjectDoesNotExist as e: # Raise a 404 if any of the related objects don't exist raise NotFound(detail=str(e)) # @TODO need to return the models.Q filter used, so we can use it for counts and related occurrences. - return queryset, filter_active + return queryset.distinct(), filter_active def filter_by_classification_threshold(self, queryset: QuerySet) -> QuerySet: """ diff --git a/ami/main/models.py b/ami/main/models.py index 5fb9c9456..65c35c1d4 100644 --- a/ami/main/models.py +++ b/ami/main/models.py @@ -1144,6 +1144,35 @@ def delete_source_image(sender, instance, **kwargs): instance.deployment.save() +class SourceImageQuerySet(models.QuerySet): + def with_occurrences_count(self): + return self.annotate( + occurrences_count=models.Count( + "detections__occurrence", + filter=models.Q( + detections__occurrence__determination_score__gte=settings.DEFAULT_CONFIDENCE_THRESHOLD + ), + distinct=True, + ) + ) + + def with_taxa_count(self): + return self.annotate( + taxa_count=models.Count( + "detections__occurrence__determination", + filter=models.Q( + detections__occurrence__determination_score__gte=settings.DEFAULT_CONFIDENCE_THRESHOLD + ), + distinct=True, + ) + ) + + +class SourceImageManager(models.Manager): + def get_queryset(self) -> SourceImageQuerySet: + return SourceImageQuerySet(self.model, using=self._db) + + @final class SourceImage(BaseModel): """A single image captured during a monitoring session""" @@ -1177,6 +1206,8 @@ class SourceImage(BaseModel): detections: models.QuerySet["Detection"] collections: models.QuerySet["SourceImageCollection"] + objects = SourceImageManager() + def __str__(self) -> str: return f"{self.__class__.__name__} #{self.pk} {self.path}" @@ -1224,6 +1255,15 @@ def public_url(self, raise_errors=False) -> str | None: # backwards compatibility url = public_url + def size_display(self) -> str: + """ + Return the size of the image in human-readable format. + """ + if self.size is None: + return filesizeformat(0) + else: + return filesizeformat(self.size) + def get_detections_count(self) -> int: return self.detections.distinct().count() @@ -1318,6 +1358,14 @@ def get_dimensions(self) -> tuple[int | None, int | None]: return self.width, self.height return None, None + def occurrences_count(self) -> int | None: + # This should always be pre-populated using queryset annotations + return None + + def taxa_count(self) -> int | None: + # This should always be pre-populated using queryset annotations + return None + def update_calculated_fields(self, save=False): if self.path and not self.timestamp: self.timestamp = self.extract_timestamp() @@ -2615,6 +2663,25 @@ def with_source_images_with_detections_count(self): ) ) + def with_occurrences_count(self): + return self.annotate( + occurrences_count=models.Count( + "images__detections__occurrence", + filter=models.Q( + images__detections__occurrence__determination_score__gte=settings.DEFAULT_CONFIDENCE_THRESHOLD + ), + distinct=True, + ) + ) + + def with_taxa_count(self): + return self.annotate( + taxa_count=models.Count( + "images__detections__occurrence__determination", + distinct=True, + ) + ) + class SourceImageCollectionManager(models.Manager): def get_queryset(self) -> SourceImageCollectionQuerySet: @@ -2665,6 +2732,14 @@ def source_images_with_detections_count(self) -> int | None: # return self.images.filter(detections__isnull=False).count() return None + def occurrences_count(self) -> int | None: + # This should always be pre-populated using queryset annotations + return None + + def taxa_count(self) -> int | None: + # This should always be pre-populated using queryset annotations + return None + def get_queryset(self): return SourceImage.objects.filter(project=self.project) diff --git a/ui/src/data-services/models/capture-details.ts b/ui/src/data-services/models/capture-details.ts index 9f3702a64..4a5d0c105 100644 --- a/ui/src/data-services/models/capture-details.ts +++ b/ui/src/data-services/models/capture-details.ts @@ -46,7 +46,7 @@ export class CaptureDetails extends Capture { } get sizeLabel(): string { - return `${this._capture.size} B` + return `${this._capture.size_display}` } get totalCaptures(): number | undefined { diff --git a/ui/src/data-services/models/capture.ts b/ui/src/data-services/models/capture.ts index 38490ea21..5557998ef 100644 --- a/ui/src/data-services/models/capture.ts +++ b/ui/src/data-services/models/capture.ts @@ -101,6 +101,14 @@ export class Capture { return this._capture.detections_count ?? 0 } + get numOccurrences(): number { + return this._capture.occurrences_count ?? 0 + } + + get numTaxa(): number { + return this._capture.taxa_count ?? 0 + } + get sessionId(): string | undefined { return this._capture.event?.id } diff --git a/ui/src/data-services/models/collection.ts b/ui/src/data-services/models/collection.ts index 3faf1c80b..984f7ab5e 100644 --- a/ui/src/data-services/models/collection.ts +++ b/ui/src/data-services/models/collection.ts @@ -50,4 +50,12 @@ export class Collection extends Entity { 0 )}%)` } + + get numOccurrences(): number { + return this._data.occurrences_count + } + + get numTaxa(): number { + return this._data.taxa_count + } } diff --git a/ui/src/pages/collection-details/capture-columns.tsx b/ui/src/pages/collection-details/capture-columns.tsx index 3dd7fa743..8516f09cf 100644 --- a/ui/src/pages/collection-details/capture-columns.tsx +++ b/ui/src/pages/collection-details/capture-columns.tsx @@ -83,7 +83,7 @@ export const columns: (projectId: string) => TableColumn[] = ( }, { id: 'detections', - name: translate(STRING.FIELD_LABEL_DETECTIONS), + name: 'Detections', sortField: 'detections_count', styles: { textAlign: TextAlign.Right, @@ -92,4 +92,31 @@ export const columns: (projectId: string) => TableColumn[] = ( ), }, + { + id: 'occurrences', + name: 'Occurrences', + sortField: 'occurrences_count', + styles: { + textAlign: TextAlign.Right, + }, + renderCell: (item: Capture) => ( + + + + ), + }, + { + id: 'taxa', + name: 'Taxa', + sortField: 'taxa_count', + styles: { + textAlign: TextAlign.Right, + }, + renderCell: (item: Capture) => , + }, ] diff --git a/ui/src/pages/overview/collections/collection-columns.tsx b/ui/src/pages/overview/collections/collection-columns.tsx index 879a40e0b..d26ad76c9 100644 --- a/ui/src/pages/overview/collections/collection-columns.tsx +++ b/ui/src/pages/overview/collections/collection-columns.tsx @@ -11,6 +11,7 @@ import { UpdateEntityDialog } from 'pages/overview/entities/entity-details-dialo import styles from 'pages/overview/entities/styles.module.scss' import { Link } from 'react-router-dom' import { APP_ROUTES } from 'utils/constants' +import { getAppRoute } from 'utils/getAppRoute' import { STRING, translate } from 'utils/language' import { PopulateCollection } from './collection-actions' import { editableSamplingMethods } from './constants' @@ -53,10 +54,31 @@ export const columns: (projectId: string) => TableColumn[] = ( ), }, { - id: 'created-at', - name: translate(STRING.FIELD_LABEL_CREATED_AT), - sortField: 'created_at', - renderCell: (item: Collection) => , + id: 'occurrences', + name: translate(STRING.FIELD_LABEL_OCCURRENCES), + sortField: 'occurrences_count', + styles: { + textAlign: TextAlign.Right, + }, + renderCell: (item: Collection) => ( + + + + ), + }, + { + id: 'taxa', + name: translate(STRING.FIELD_LABEL_SPECIES), + sortField: 'taxa_count', + styles: { + textAlign: TextAlign.Right, + }, + renderCell: (item: Collection) => , }, { id: 'updated-at', diff --git a/ui/src/pages/session-details/playback/capture-details/capture-details.tsx b/ui/src/pages/session-details/playback/capture-details/capture-details.tsx index 57fc60c94..e713d7bb7 100644 --- a/ui/src/pages/session-details/playback/capture-details/capture-details.tsx +++ b/ui/src/pages/session-details/playback/capture-details/capture-details.tsx @@ -55,12 +55,6 @@ export const CaptureDetails = ({ {capture.dateTimeLabel} -
- - {translate(STRING.FIELD_LABEL_DETECTIONS)} - - {capture.numDetections} -
{translate(STRING.FIELD_LABEL_SIZE)} @@ -75,6 +69,24 @@ export const CaptureDetails = ({
)} +
+ + {translate(STRING.FIELD_LABEL_DETECTIONS)} + + {capture.numDetections} +
+
+ + {translate(STRING.FIELD_LABEL_OCCURRENCES)} + + {capture.numOccurrences} +
+
+ + {translate(STRING.FIELD_LABEL_TAXA)} + + {capture.numTaxa} +
) diff --git a/ui/src/utils/getAppRoute.ts b/ui/src/utils/getAppRoute.ts index 13c28a0bc..5c05c465e 100644 --- a/ui/src/utils/getAppRoute.ts +++ b/ui/src/utils/getAppRoute.ts @@ -5,8 +5,10 @@ type FilterType = | 'occurrences__event' | 'occurrence' | 'capture' + | 'detections__source_image' | 'taxon' | 'timestamp' + | 'collection' export const getAppRoute = ({ to, diff --git a/ui/src/utils/language.ts b/ui/src/utils/language.ts index f27c32253..92f69b7e4 100644 --- a/ui/src/utils/language.ts +++ b/ui/src/utils/language.ts @@ -108,6 +108,7 @@ export enum STRING { FIELD_LABEL_STARTED_AT, FIELD_LABEL_STATUS, FIELD_LABEL_TAXON, + FIELD_LABEL_TAXA, FIELD_LABEL_THUMBNAIL, FIELD_LABEL_TIME, FIELD_LABEL_TIME_OBSERVED, @@ -325,6 +326,7 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { [STRING.FIELD_LABEL_STARTED_AT]: 'Started at', [STRING.FIELD_LABEL_STATUS]: 'Status', [STRING.FIELD_LABEL_TAXON]: 'Taxon', + [STRING.FIELD_LABEL_TAXA]: 'Taxa', [STRING.FIELD_LABEL_THUMBNAIL]: 'Thumbnail', [STRING.FIELD_LABEL_TIME]: 'Local time', [STRING.FIELD_LABEL_TIME_OBSERVED]: 'Local time observed', diff --git a/ui/src/utils/useFilters.ts b/ui/src/utils/useFilters.ts index 2efa1652c..ef1f42d6c 100644 --- a/ui/src/utils/useFilters.ts +++ b/ui/src/utils/useFilters.ts @@ -21,6 +21,14 @@ const AVAILABLE_FILTERS = [ label: 'Taxon', field: 'taxon', }, + { + label: 'Capture collection', + field: 'collection', + }, + { + label: 'Capture', + field: 'detections__source_image', + }, ] export const useFilters = () => {