Skip to content
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
52bcb51
feat: exclude score threshold filter from occurrence view
annavik Aug 28, 2025
da6cece
feat: exclude score threshold filter from species view
annavik Aug 28, 2025
135408a
feat: exclude score threshold filter from playback view
annavik Aug 28, 2025
e52c804
chore: remove unused code
annavik Aug 28, 2025
0dfacf9
feat: inform and link to default filters from list views
annavik Aug 28, 2025
9069b6b
Add get_default_classification_threshold function to apply default sc…
mohamedelabbas1996 Sep 5, 2025
d7858db
Add get_default_classification_threshold function to apply default sc…
mohamedelabbas1996 Sep 5, 2025
383146d
Apply default classification threshold filter across OccurrenceViewSe…
mohamedelabbas1996 Sep 5, 2025
23a7819
Remove debug logs
mohamedelabbas1996 Sep 5, 2025
8f73de1
Apply default classification threshold filter to the TaxonViewSet lis…
mohamedelabbas1996 Sep 5, 2025
bda0c77
fix: remove OccurrenceDeterminationScoreFilter from filter_backends
mohamedelabbas1996 Sep 8, 2025
db69e28
fix: modified the default threshold qs filter method to handle projec…
mohamedelabbas1996 Sep 9, 2025
1090eb8
chore: always show default filters form, but hide taxa controls unles…
annavik Sep 12, 2025
e310b34
feat: add default classification threshold filtering to SourceImageCo…
mohamedelabbas1996 Sep 16, 2025
8968940
Merge branch 'main' into feat/apply-score-filter - stack visible_for_…
mihow Sep 18, 2025
cab5ef6
fix: apply score threshold filter when include_unobserved is False
mohamedelabbas1996 Sep 18, 2025
8ebf62e
Merge branch 'feat/apply-score-filter' of https://github.com/RolnickL…
mohamedelabbas1996 Sep 18, 2025
b0dfee6
test: add tests for project default score threshold filter
mohamedelabbas1996 Sep 18, 2025
48bfa18
test: add coverage for default threshold filtering in SourceImageView…
mohamedelabbas1996 Sep 18, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 19 additions & 17 deletions ami/main/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
from ami.base.serializers import FilterParamsSerializer, SingleParamSerializer
from ami.base.views import ProjectMixin
from ami.main.api.serializers import TagSerializer
from ami.utils.requests import get_active_classification_threshold, project_id_doc_param
from ami.utils.requests import get_default_classification_threshold, project_id_doc_param
from ami.utils.storages import ConnectionTestResult

from ..models import (
Expand Down Expand Up @@ -336,7 +336,9 @@ def get_queryset(self) -> QuerySet:
"occurrences__determination",
distinct=True,
filter=models.Q(
occurrences__determination_score__gte=get_active_classification_threshold(self.request),
occurrences__determination_score__gte=get_default_classification_threshold(
project, self.request
),
),
),
)
Expand Down Expand Up @@ -493,9 +495,10 @@ def get_serializer_context(self):

def get_queryset(self) -> QuerySet:
queryset = super().get_queryset()
project = self.get_active_project()
with_detections_default = False

classification_threshold = get_active_classification_threshold(self.request)
classification_threshold = get_default_classification_threshold(project, self.request)
queryset = queryset.with_occurrences_count( # type: ignore
classification_threshold=classification_threshold
).with_taxa_count( # type: ignore
Expand Down Expand Up @@ -652,9 +655,9 @@ class SourceImageCollectionViewSet(DefaultViewSet, ProjectMixin):
]

def get_queryset(self) -> QuerySet:
classification_threshold = get_active_classification_threshold(self.request)
query_set: QuerySet = super().get_queryset()
project = self.get_active_project()
classification_threshold = get_default_classification_threshold(project, self.request)
if project:
query_set = query_set.filter(project=project)
queryset = query_set.with_occurrences_count( # type: ignore
Expand Down Expand Up @@ -1052,11 +1055,6 @@ def filter_queryset(self, request, queryset, view):
return queryset


OccurrenceDeterminationScoreFilter = ThresholdFilter.create(
query_param="classification_threshold", filter_param="determination_score"
)


class OccurrenceViewSet(DefaultViewSet, ProjectMixin):
"""
API endpoint that allows occurrences to be viewed or edited.
Expand All @@ -1074,7 +1072,6 @@ class OccurrenceViewSet(DefaultViewSet, ProjectMixin):
OccurrenceVerified,
OccurrenceVerifiedByMeFilter,
OccurrenceTaxaListFilter,
OccurrenceDeterminationScoreFilter,
]
filterset_fields = [
"event",
Expand Down Expand Up @@ -1118,7 +1115,7 @@ def get_queryset(self) -> QuerySet["Occurrence"]:
)
qs = qs.with_detections_count().with_timestamps() # type: ignore
qs = qs.with_identifications() # type: ignore

qs = qs.filter_by_score_threshold(project, self.request) # type: ignore
if self.action != "list":
qs = qs.prefetch_related(
Prefetch(
Expand Down Expand Up @@ -1360,10 +1357,12 @@ def get_queryset(self) -> QuerySet:
qs = qs.prefetch_related(
Prefetch(
"occurrences",
queryset=Occurrence.objects.filter(self.get_occurrence_filters(project))[:1],
queryset=Occurrence.objects.filter_by_score_threshold(project, self.request).filter(
self.get_occurrence_filters(project)
)[:1],
to_attr="example_occurrences",
)
)
) # type: ignore
else:
# Add empty occurrences list to make the response consistent
qs = qs.annotate(example_occurrences=models.Value([], output_field=models.JSONField()))
Expand All @@ -1385,6 +1384,7 @@ def get_taxa_observed(self, qs: QuerySet, project: Project, include_unobserved=F
occurrence_filters,
determination_id=models.OuterRef("id"),
)
.filter_by_score_threshold(project, self.request) # type: ignore
.values("determination_id")
.annotate(count=models.Count("id"))
.values("count"),
Expand Down Expand Up @@ -1564,13 +1564,15 @@ def get(self, request):
"captures_count": SourceImage.objects.visible_for_user(user) # type: ignore
.filter(deployment__project=project)
.count(),
"occurrences_count": Occurrence.objects.valid()
"occurrences_count": Occurrence.objects.valid() # type: ignore
.visible_for_user(user)
.filter(project=project)
.count(), # type: ignore
"taxa_count": Occurrence.objects.visible_for_user(user)
.filter_by_score_threshold(project, self.request) # type: ignore
.count(),
"taxa_count": Occurrence.objects.visible_for_user(user) # type: ignore
.filter_by_score_threshold(project, self.request)
.unique_taxa(project=project)
.count(), # type: ignore
.count(),
}
else:
data = {
Expand Down
8 changes: 8 additions & 0 deletions ami/main/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from django.utils import timezone
from django_pydantic_field import SchemaField
from guardian.shortcuts import get_perms
from rest_framework.request import Request

import ami.tasks
import ami.utils
Expand All @@ -36,6 +37,7 @@
from ami.ml.schemas import BoundingBox
from ami.users.models import User
from ami.utils.media import calculate_file_checksum, extract_timestamp
from ami.utils.requests import get_default_classification_threshold
from ami.utils.schemas import OrderedEnum

if typing.TYPE_CHECKING:
Expand Down Expand Up @@ -2459,6 +2461,12 @@ def unique_taxa(self, project: Project | None = None):
)
return qs

def filter_by_score_threshold(self, project: Project | None = None, request: Request | None = None):
if project is None:
return self
score_threshold = get_default_classification_threshold(project, request)
return self.filter(determination_score__gte=score_threshold)


class OccurrenceManager(models.Manager.from_queryset(OccurrenceQuerySet)):
def get_queryset(self):
Expand Down
26 changes: 26 additions & 0 deletions ami/utils/requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,32 @@ def get_active_classification_threshold(request: Request) -> float:
return classification_threshold


def get_default_classification_threshold(project, request: Request | None = None) -> float:
"""
Get the classification threshold from project settings by default,
or from request query parameters if `apply_defaults=false` is set in the request.

Args:
project: A Project instance.
request: The incoming request object (optional).

Returns:
The classification threshold value from project settings by default,
or from request if `apply_defaults=false` is provided.
"""
# If request exists and apply_defaults is explicitly false, get from request
if request is not None:
apply_defaults = request.query_params.get("apply_defaults", "true").lower()
if apply_defaults == "false":
return get_active_classification_threshold(request)

# Otherwise, get from project
if project is None:
return 0.0
Comment on lines +79 to +80
Copy link
Preview

Copilot AI Sep 17, 2025

Choose a reason for hiding this comment

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

This condition will never be reached because the function signature shows project as a required parameter without a default value, but the docstring and logic suggest it should handle None values. Either make project optional with a default of None or remove this check.

Copilot uses AI. Check for mistakes.


return getattr(project, "default_filters_score_threshold", 0.0) or 0.0


project_id_doc_param = OpenApiParameter(
name="project_id",
description="Filter by project ID",
Expand Down
84 changes: 84 additions & 0 deletions ui/src/components/filtering/default-filter-control.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import {
FormActions,
FormRow,
FormSection,
} from 'components/form/layout/layout'
import { useProjectDetails } from 'data-services/hooks/projects/useProjectDetails'
import { ProjectDetails } from 'data-services/models/project-details'
import {
IconButton,
IconButtonTheme,
} from 'design-system/components/icon-button/icon-button'
import { IconType } from 'design-system/components/icon/icon'
import { InputValue } from 'design-system/components/input/input'
import { ChevronRightIcon } from 'lucide-react'
import { buttonVariants, Popover } from 'nova-ui-kit'
import { Link, useParams } from 'react-router-dom'
import { APP_ROUTES } from 'utils/constants'
import { STRING, translate } from 'utils/language'

export const DefaultFiltersControl = () => {
const { projectId } = useParams()
const { project } = useProjectDetails(projectId as string, true)

return (
<div className="flex items-center justify-between pl-2">
<div className="flex items-center gap-1">
<span className="text-muted-foreground body-overline-small font-bold pt-0.5">
{translate(STRING.NAV_ITEM_DEFAULT_FILTERS)}
</span>
{project ? <InfoPopover project={project} /> : null}
</div>
</div>
)
}

const InfoPopover = ({ project }: { project: ProjectDetails }) => (
<Popover.Root>
<Popover.Trigger asChild>
<IconButton icon={IconType.Info} theme={IconButtonTheme.Plain} />
</Popover.Trigger>
<Popover.Content className="p-0">
<FormSection
title={translate(STRING.NAV_ITEM_DEFAULT_FILTERS)}
description="Data is filtered by default based on global project configuration."
>
<FormRow>
<InputValue
label="Score threshold"
value={project.settings.scoreThreshold}
/>
</FormRow>
<FormRow
style={
project.featureFlags.default_filters
? undefined
: { display: 'none' }
}
>
<InputValue
label="Include taxa"
value={project.settings.includeTaxa
.map((taxon) => taxon.name)
.join(', ')}
/>
<InputValue
label="Exclude taxa"
value={project.settings.excludeTaxa
.map((taxon) => taxon.name)
.join(', ')}
/>
</FormRow>
</FormSection>
<FormActions>
<Link
className={buttonVariants({ size: 'small', variant: 'outline' })}
to={APP_ROUTES.DEFAULT_FILTERS({ projectId: project.id })}
>
<span>Configure</span>
<ChevronRightIcon className="w-4 h-4" />
</Link>
</FormActions>
</Popover.Content>
</Popover.Root>
)
11 changes: 4 additions & 7 deletions ui/src/components/filtering/filter-control.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { X } from 'lucide-react'
import { XIcon } from 'lucide-react'
import { Button } from 'nova-ui-kit'
import { useFilters } from 'utils/useFilters'
import { AlgorithmFilter, NotAlgorithmFilter } from './filters/algorithm-filter'
Expand All @@ -7,7 +7,6 @@ import { CollectionFilter } from './filters/collection-filter'
import { DateFilter } from './filters/date-filter'
import { ImageFilter } from './filters/image-filter'
import { PipelineFilter } from './filters/pipeline-filter'
import { ScoreFilter } from './filters/score-filter'
import { SessionFilter } from './filters/session-filter'
import { StationFilter } from './filters/station-filter'
import { StatusFilter } from './filters/status-filter'
Expand All @@ -23,8 +22,6 @@ const ComponentMap: {
[key: string]: (props: FilterProps) => JSX.Element
} = {
algorithm: AlgorithmFilter,
best_determination_score: ScoreFilter,
classification_threshold: ScoreFilter,
collection: CollectionFilter,
collections: CollectionFilter,
date_end: DateFilter,
Expand Down Expand Up @@ -87,12 +84,12 @@ export const FilterControl = ({
/>
{clearable && filter.value && (
<Button
size="icon"
className="shrink-0 text-muted-foreground"
variant="ghost"
onClick={() => clearFilter(field)}
size="icon"
variant="ghost"
>
<X className="w-4 h-4" />
<XIcon className="w-4 h-4" />
</Button>
)}
</div>
Expand Down
38 changes: 0 additions & 38 deletions ui/src/components/filtering/filters/score-filter.tsx

This file was deleted.

13 changes: 5 additions & 8 deletions ui/src/pages/occurrences/occurrences.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { DefaultFiltersControl } from 'components/filtering/default-filter-control'
import { FilterControl } from 'components/filtering/filter-control'
import { FilterSection } from 'components/filtering/filter-section'
import { someActive } from 'components/filtering/utils'
Expand Down Expand Up @@ -28,7 +29,6 @@ import { useColumnSettings } from 'utils/useColumnSettings'
import { useFilters } from 'utils/useFilters'
import { usePagination } from 'utils/usePagination'
import { useUser } from 'utils/user/userContext'
import { useUserPreferences } from 'utils/userPreferences/userPreferencesContext'
import { useSelectedView } from 'utils/useSelectedView'
import { useSort } from 'utils/useSort'
import { OccurrenceActions } from './occurrence-actions'
Expand All @@ -38,7 +38,6 @@ import { OccurrenceNavigation } from './occurrence-navigation'

export const Occurrences = () => {
const { user } = useUser()
const { userPreferences } = useUserPreferences()
const { projectId, id } = useParams()
const { columnSettings, setColumnSettings } = useColumnSettings(
'occurrences',
Expand All @@ -59,9 +58,7 @@ export const Occurrences = () => {
order: 'desc',
})
const { pagination, setPage } = usePagination()
const { activeFilters, filters } = useFilters({
classification_threshold: `${userPreferences.scoreThreshold}`,
})
const { activeFilters, filters } = useFilters()
const { occurrences, total, isLoading, isFetching, error } = useOccurrences({
projectId,
pagination,
Expand Down Expand Up @@ -94,15 +91,13 @@ export const Occurrences = () => {
<FilterSection defaultOpen>
<FilterControl field="detections__source_image" readonly />
<FilterControl field="event" readonly />
<FilterControl field="date_start" />
<FilterControl field="date_end" />
<FilterControl field="taxon" />
{taxaLists.length > 0 && (
<FilterControl data={taxaLists} field="taxa_list_id" />
)}
<FilterControl clearable={false} field="classification_threshold" />
<FilterControl field="verified" />
{user.loggedIn && <FilterControl field="verified_by_me" />}
<DefaultFiltersControl />
</FilterSection>
<FilterSection
title="More filters"
Expand All @@ -111,6 +106,8 @@ export const Occurrences = () => {
activeFilters
)}
>
<FilterControl field="date_start" />
<FilterControl field="date_end" />
<FilterControl field="collection" />
<FilterControl field="deployment" />
<FilterControl field="algorithm" />
Expand Down
Loading