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}
/>
)
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
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.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,21 @@ import { useSort } from 'utils/useSort'
import { columns } from './species-columns'
import { SpeciesGallery } from './species-gallery'

// Taxa-list filters that also apply to the occurrence list. When the user drills
// into a taxon's occurrences via a bubble link, these are carried over so the
// occurrence list stays scoped to the same station / session / device / site /
// verification selection instead of resetting to every occurrence of the taxon.
const CARRY_OVER_FILTER_FIELDS = [
'event',
'deployment',
'deployment__device',
'deployment__research_site',
'verified',
'taxa_list_id',
'not_taxa_list_id',
'apply_defaults',
]

export const Species = () => {
const { projectId, id } = useParams()
const { project } = useProjectDetails(projectId as string, true)
Expand All @@ -58,6 +73,16 @@ export const Species = () => {
const { selectedView, setSelectedView } = useSelectedView('table')
const { taxaLists = [] } = useTaxaLists({ projectId: projectId as string })
const { tags = [] } = useTags({ projectId: projectId as string })
const carryFilters = useMemo(
() =>
filters.reduce<Record<string, string>>((acc, filter) => {
if (filter.value && CARRY_OVER_FILTER_FIELDS.includes(filter.field)) {
acc[filter.field] = filter.value
}
return acc
}, {}),
[filters]
)
const pageTitle = useMemo(() => {
const taxaListFilter = filters.find(
(filter) => filter.field === 'taxa_list_id'
Expand All @@ -77,6 +102,8 @@ export const Species = () => {
<FilterSection defaultOpen>
<FilterControl field="event" readonly />
<FilterControl field="deployment" />
<FilterControl field="deployment__device" />
<FilterControl field="deployment__research_site" />
<FilterControl field="taxon" />
{taxaLists.length > 0 && (
<>
Expand Down Expand Up @@ -135,6 +162,7 @@ export const Species = () => {
columns={columns({
projectId: projectId as string,
featureFlags: project?.featureFlags,
carryFilters,
}).filter((column) => !!columnSettings[column.id])}
error={error}
isLoading={!id && isLoading}
Expand Down
5 changes: 5 additions & 0 deletions ui/src/utils/getAppRoute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,15 @@ type FilterType =
| 'capture'
| 'collection'
| 'collections'
| 'date_end'

@mihow mihow Jun 24, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see date params and not_taxa_list_id are being added in addition to the device & site params. are those additional ones missing/broken on live currently?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude says: These are type-only entries in the FilterType union — adding a key here changes no runtime behavior, it just lets a link reference that param in a URL. None of them are missing or broken on live: date_start/date_end are the existing date-range filter in the occurrence "More filters" section, and not_taxa_list_id is "Exclude taxa from list" — all already shipped and working.

They showed up here because I extended the union while wiring the filter carry-over, but they turned out unnecessary: carry-over spreads a string-keyed record into getAppRoute({ filters }), which is already assignable without per-field union entries — only fields used as explicit literal keys (e.g. taxon, verified) need to be in the union. So I've trimmed date_start / date_end / not_taxa_list_id in 25d66a7. deployment__device / deployment__research_site stay, since those are the new filter params this PR introduces.

| 'date_start'
| 'deployment'
| 'deployment__device'
| 'deployment__research_site'
| 'detections__source_image'
| 'event'
| 'include_unobserved'
| 'not_taxa_list_id'
| 'occurrence'
| 'source_image_collection'
| 'source_image_single'
Expand Down
8 changes: 8 additions & 0 deletions ui/src/utils/useFilters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,14 @@ export const AVAILABLE_FILTERS = (projectId: string): FilterConfig[] => [
},
},
},
{
label: 'Device',
field: 'deployment__device',
},
{
label: 'Site',
field: 'deployment__research_site',
},
{
label: 'End date',
field: 'date_end',
Expand Down
Loading