Skip to content
15 changes: 15 additions & 0 deletions ami/main/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -1350,6 +1350,8 @@ def filter_queryset(self, request, queryset, view):
OCCURRENCE_FILTERSET_FIELDS = (
"event",
"deployment",
"deployment__device",
"deployment__research_site",
"determination__rank",
"detections__source_image",
)
Expand Down Expand Up @@ -1744,6 +1746,8 @@ def get_occurrence_filters(self, project: Project, accessor: str = "") -> models
deployment_id = self.request.query_params.get("deployment") or self.request.query_params.get(
"occurrences__deployment"
)
device_id = self.request.query_params.get("deployment__device")
site_id = self.request.query_params.get("deployment__research_site")
event_id = self.request.query_params.get("event") or self.request.query_params.get("occurrences__event")
collection_id = self.request.query_params.get("collection")

Expand All @@ -1765,6 +1769,12 @@ def field(path: str) -> str:
if deployment_id:
Deployment.objects.get(id=deployment_id)
filters &= models.Q(**{field("deployment"): deployment_id})
if device_id:
Device.objects.get(id=device_id)
filters &= models.Q(**{field("deployment__device"): device_id})
if site_id:
Site.objects.get(id=site_id)
filters &= models.Q(**{field("deployment__research_site"): site_id})
Comment on lines +1765 to +1777
if event_id:
Event.objects.get(id=event_id)
filters &= models.Q(**{field("event"): event_id})
Expand All @@ -1774,6 +1784,11 @@ def field(path: str) -> str:
except exceptions.ObjectDoesNotExist as e:
# Raise a 404 if any of the related objects don't exist
raise NotFound(detail=str(e))
except (ValueError, TypeError):
# A non-integer id (e.g. ?deployment__device=abc) is a client error. Return a
# 400 instead of letting the .get(id=...) lookup surface an unhandled 500, so
# this endpoint matches the 400 the occurrence list returns for the same input.
raise api_exceptions.ValidationError(detail="Filter ids must be integers.")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

return filters

Expand Down
86 changes: 86 additions & 0 deletions ami/main/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -6011,6 +6011,92 @@ def test_verified_count_not_inflated_by_collection_join(self):
self.assertEqual(rows["Vanessa cardui"]["verified_count"], 2)


class TestDeviceAndSiteFilters(APITestCase):
"""Filtering occurrences and taxa by the deployment's device and research site.

A deployment records both a ``device`` (the camera/hardware configuration) and a
``research_site``. Users can scope the occurrence and taxa lists to one device or
one site so they can, for example, build a presence matrix per site. These tests
pin that the occurrence list filters exactly to the chosen device/site, that the
taxa list restricts membership the same way (not just its annotated counts), and
that an unknown device/site id is rejected with a 404 on the taxa endpoint.
"""

def setUp(self):
self.project, self.deployment_a = setup_test_project(reuse=False)
create_taxa(self.project)
self.cardui = Taxon.objects.get(name="Vanessa cardui")
self.atalanta = Taxon.objects.get(name="Vanessa atalanta")

# Two devices and two sites, each pinned to its own deployment.
self.device_a = Device.objects.create(name="Device A", project=self.project)
self.device_b = Device.objects.create(name="Device B", project=self.project)
self.site_a = Site.objects.create(name="Site A", project=self.project)
self.site_b = Site.objects.create(name="Site B", project=self.project)

self.deployment_a.device = self.device_a
self.deployment_a.research_site = self.site_a
self.deployment_a.save()
self.deployment_b = Deployment.objects.create(
name="Deployment B",
project=self.project,
device=self.device_b,
research_site=self.site_b,
)

# Deployment A only sees cardui; deployment B only sees atalanta. This lets the
# device/site filter be checked by both the occurrence count and the taxa membership.
create_captures(deployment=self.deployment_a, num_nights=1, images_per_night=2)
create_captures(deployment=self.deployment_b, num_nights=1, images_per_night=2)
create_occurrences(deployment=self.deployment_a, num=3, taxon=self.cardui, determination_score=0.9)
create_occurrences(deployment=self.deployment_b, num=2, taxon=self.atalanta, determination_score=0.9)

def _occurrence_count(self, **params):
query = "&".join(f"{k}={v}" for k, v in params.items())
res = self.client.get(f"/api/v2/occurrences/?project_id={self.project.pk}&{query}")
self.assertEqual(res.status_code, status.HTTP_200_OK)
return res.json()["count"]

def _taxa_names(self, **params):
query = "&".join(f"{k}={v}" for k, v in params.items())
res = self.client.get(f"/api/v2/taxa/?project_id={self.project.pk}&limit=1000&{query}")
self.assertEqual(res.status_code, status.HTTP_200_OK)
return {row["name"] for row in res.json()["results"]}

def test_occurrences_filtered_by_device(self):
self.assertEqual(self._occurrence_count(), 5)
self.assertEqual(self._occurrence_count(deployment__device=self.device_a.pk), 3)
self.assertEqual(self._occurrence_count(deployment__device=self.device_b.pk), 2)

def test_occurrences_filtered_by_site(self):
self.assertEqual(self._occurrence_count(deployment__research_site=self.site_a.pk), 3)
self.assertEqual(self._occurrence_count(deployment__research_site=self.site_b.pk), 2)

def test_taxa_membership_restricted_by_device(self):
# The taxa list must drop to only the taxa observed on the chosen device, not
# merely re-scope the counts of the full taxa set.
self.assertEqual(self._taxa_names(deployment__device=self.device_a.pk), {"Vanessa cardui"})
self.assertEqual(self._taxa_names(deployment__device=self.device_b.pk), {"Vanessa atalanta"})

def test_taxa_membership_restricted_by_site(self):
self.assertEqual(self._taxa_names(deployment__research_site=self.site_a.pk), {"Vanessa cardui"})
self.assertEqual(self._taxa_names(deployment__research_site=self.site_b.pk), {"Vanessa atalanta"})

def test_unknown_device_or_site_returns_404_on_taxa(self):
for param in ("deployment__device", "deployment__research_site"):
res = self.client.get(f"/api/v2/taxa/?project_id={self.project.pk}&{param}=999999")
self.assertEqual(res.status_code, status.HTTP_404_NOT_FOUND, param)

def test_non_integer_id_returns_400_not_500(self):
# A malformed id must be a client error on both endpoints, not an unhandled 500.
# The taxa view validates the id before the existence lookup; the occurrence view
# gets the same 400 from django-filter. Both must agree.
for endpoint in ("taxa", "occurrences"):
for param in ("deployment__device", "deployment__research_site"):
res = self.client.get(f"/api/v2/{endpoint}/?project_id={self.project.pk}&{param}=abc")
self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST, f"{endpoint}?{param}=abc")


class TestDetectionNullMarker(TestCase):
"""
Covers the null-marker abstraction added for Issue #1310 follow-up:
Expand Down
4 changes: 4 additions & 0 deletions ui/src/components/filtering/filter-control.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import { AlgorithmFilter, NotAlgorithmFilter } from './filters/algorithm-filter'
import { BooleanFilter } from './filters/boolean-filter'
import { CaptureSetFilter } from './filters/capture-set-filter'
import { DateFilter } from './filters/date-filter'
import { DeviceFilter } from './filters/device-filter'
import { ImageFilter } from './filters/image-filter'
import { PipelineFilter } from './filters/pipeline-filter'
import { SessionFilter } from './filters/session-filter'
import { SiteFilter } from './filters/site-filter'
import { StationFilter } from './filters/station-filter'
import { StatusFilter } from './filters/status-filter'
import { TagFilter } from './filters/tag-filter'
Expand All @@ -29,6 +31,8 @@ const ComponentMap: {
date_end: DateFilter,
date_start: DateFilter,
deployment: StationFilter,
deployment__device: DeviceFilter,
deployment__research_site: SiteFilter,
detections__source_image: ImageFilter,
event: SessionFilter,
processed: ProcessingStatusFilter,
Expand Down
17 changes: 17 additions & 0 deletions ui/src/components/filtering/filters/device-filter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { API_ROUTES } from 'data-services/constants'
import { EntityPicker } from 'nova-ui-kit'
import { FilterProps } from './types'

export const DeviceFilter = ({ onAdd, onClear, value }: FilterProps) => (
<EntityPicker
collection={API_ROUTES.DEVICES}
onValueChange={(value) => {
if (value) {
onAdd(value)
} else {
onClear()
}
}}
value={value}
/>
)
17 changes: 17 additions & 0 deletions ui/src/components/filtering/filters/site-filter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { API_ROUTES } from 'data-services/constants'
import { EntityPicker } from 'nova-ui-kit'
import { FilterProps } from './types'

export const SiteFilter = ({ onAdd, onClear, value }: FilterProps) => (
<EntityPicker
collection={API_ROUTES.SITES}
onValueChange={(value) => {
if (value) {
onAdd(value)
} else {
onClear()
}
}}
value={value}
/>
)
33 changes: 33 additions & 0 deletions ui/src/pages/occurrences/occurrence-filters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// The occurrence list's filter carry-over contract: filter fields that another view may
// carry into the occurrence list (see useCarryOverFilters). A field belongs here only if
// both bounds hold:
//
// 1. The occurrence list backend honors it — keep this in sync by hand with the server
// filterset (OCCURRENCE_FILTERSET_FIELDS, the custom occurrence filter backends, and
// TaxonViewSet.get_occurrence_filters in ami/main/api/views.py).
// 2. The occurrence filter panel can display it — every field here has a control on the
// panel (a pickable control, or a readonly chip such as event/capture that appears
// once set), so a carried filter is always visible and clearable on arrival rather
// than silently shrinking the list.
//
// This is a curated subset, not "every field the panel renders" — a field can be on the
// panel yet deliberately left out of carry-over. The carryOverFilters test pins that every
// field here is a registered filter.
export const FILTERS_TO_OCCURRENCES = [
'detections__source_image',
'event',
'taxon',
'taxa_list_id',
'not_taxa_list_id',
'verified',
'verified_by_me',
'collection',
'date_start',
'date_end',
'deployment',
'deployment__device',
'deployment__research_site',
'algorithm',
'not_algorithm',
'apply_defaults',
]
11 changes: 10 additions & 1 deletion ui/src/pages/occurrences/occurrences.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,14 +113,23 @@ export const Occurrences = () => {
<FilterSection
title="More filters"
defaultOpen={someActive(
['collection', 'deployment', 'algorithm', 'not_algorithm'],
[
'collection',
'deployment',
'deployment__device',
'deployment__research_site',
'algorithm',
'not_algorithm',
],
activeFilters
)}
>
<FilterControl field="date_start" />
<FilterControl field="date_end" />
<FilterControl field="collection" />
<FilterControl field="deployment" />
<FilterControl field="deployment__device" />
<FilterControl field="deployment__research_site" />
<FilterControl field="algorithm" />
<FilterControl field="not_algorithm" />
</FilterSection>
Expand Down
15 changes: 12 additions & 3 deletions ui/src/pages/species-details/species-details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ import { APP_ROUTES } from 'utils/constants'
import { getFormatedDateTimeString } from 'utils/date/getFormatedDateTimeString/getFormatedDateTimeString'
import { getAppRoute } from 'utils/getAppRoute'
import { STRING, translate } from 'utils/language'
import { useCarryOverFilters } from 'utils/useFilters'
import { FILTERS_TO_OCCURRENCES } from 'pages/occurrences/occurrence-filters'
import { FILTERS_TO_TAXA } from 'pages/species/species-filters'
import { UserPermission } from 'utils/user/types'
import styles from './species-details.module.scss'

Expand All @@ -40,6 +43,8 @@ export const SpeciesDetails = ({
const { projectId } = useParams()
const navigate = useNavigate()
const { project } = useProjectDetails(projectId as string, true)
const occurrenceFilters = useCarryOverFilters(FILTERS_TO_OCCURRENCES)
const taxaFilters = useCarryOverFilters(FILTERS_TO_TAXA)
const canUpdate = species.userPermissions.includes(UserPermission.Update)
const hasChildren = species.rank !== 'SPECIES'

Expand Down Expand Up @@ -139,7 +144,7 @@ export const SpeciesDetails = ({
to: APP_ROUTES.TAXA({
projectId: projectId as string,
}),
filters: { taxon: species.id },
filters: { ...taxaFilters, taxon: species.id },
})}
/>
</InfoBlockField>
Expand All @@ -154,7 +159,7 @@ export const SpeciesDetails = ({
to: APP_ROUTES.OCCURRENCES({
projectId: projectId as string,
}),
filters: { taxon: species.id },
filters: { ...occurrenceFilters, taxon: species.id },
})}
/>
</InfoBlockField>
Expand All @@ -165,7 +170,11 @@ export const SpeciesDetails = ({
to: APP_ROUTES.OCCURRENCES({
projectId: projectId as string,
}),
filters: { taxon: species.id, verified: 'true' },
filters: {
...occurrenceFilters,
taxon: species.id,
verified: 'true',
},
})}
/>
</InfoBlockField>
Expand Down
14 changes: 11 additions & 3 deletions ui/src/pages/species/species-columns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,15 @@ import { STRING, translate } from 'utils/language'
export const columns: (project: {
projectId: string
featureFlags?: { [key: string]: boolean }
}) => TableColumn<Species>[] = ({ projectId, featureFlags }) => [
// Active taxa-list filters (station, verified, device, site, …) carried over
// when drilling into a taxon's occurrences so the occurrence list stays scoped
// to the same selection instead of showing every occurrence of the taxon.
carryFilters?: Record<string, string>
}) => TableColumn<Species>[] = ({
projectId,
featureFlags,
carryFilters = {},
}) => [
{
id: 'cover-image',
name: translate(STRING.FIELD_LABEL_IMAGE),
Expand Down Expand Up @@ -88,7 +96,7 @@ export const columns: (project: {
<Link
to={getAppRoute({
to: APP_ROUTES.OCCURRENCES({ projectId }),
filters: { taxon: item.id },
filters: { ...carryFilters, taxon: item.id },
})}
>
<BasicTableCell value={item.numOccurrences} theme={CellTheme.Bubble} />
Expand All @@ -106,7 +114,7 @@ export const columns: (project: {
<Link
to={getAppRoute({
to: APP_ROUTES.OCCURRENCES({ projectId }),
filters: { taxon: item.id, verified: 'true' },
filters: { ...carryFilters, taxon: item.id, verified: 'true' },
Comment thread
mihow marked this conversation as resolved.
})}
>
<BasicTableCell value={item.numVerified} theme={CellTheme.Bubble} />
Expand Down
28 changes: 28 additions & 0 deletions ui/src/pages/species/species-filters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// The taxa list's filter carry-over contract: filter fields that another view may carry
// into the taxa list (see useCarryOverFilters). A field belongs here only if both bounds
// hold:
//
// 1. The taxa list backend honors it — keep this in sync by hand with the server
// filterset (TaxonViewSet.filterset_fields and get_occurrence_filters in
// ami/main/api/views.py).
// 2. The taxa filter panel can display it — every field here has a control on the panel
// (pickable, or a readonly chip such as event that appears once set), so a carried
// filter is always visible and clearable on arrival.
//
// Differs from FILTERS_TO_OCCURRENCES where the lists differ: the taxa list carries
// "show unobserved taxa" and the tag filters (its own filters) but not the occurrence-only
// ones. The carryOverFilters test pins that every field here is a registered filter.
export const FILTERS_TO_TAXA = [
'event',
'taxon',
'taxa_list_id',
'not_taxa_list_id',
'verified',
'include_unobserved',
'deployment',
'deployment__device',
'deployment__research_site',
'tag_id',
'not_tag_id',
'apply_defaults',
]
Loading
Loading