Skip to content

Commit

Permalink
Merge branch 'main' into feat/more-predictions-data
Browse files Browse the repository at this point in the history
  • Loading branch information
mihow authored Dec 19, 2024
2 parents 04d1ea4 + 894eb4d commit 9d7285a
Show file tree
Hide file tree
Showing 57 changed files with 572 additions and 423 deletions.
81 changes: 81 additions & 0 deletions ami/base/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import urllib.parse

from django.db import models
from rest_framework import exceptions as api_exceptions
from rest_framework import serializers
from rest_framework.request import Request
from rest_framework.reverse import reverse
Expand Down Expand Up @@ -91,3 +92,83 @@ def to_representation(self, instance):
def create_for_model(cls, model: type[models.Model]) -> type["MinimalNestedModelSerializer"]:
class_name = f"MinimalNestedModelSerializer_{model.__name__}"
return type(class_name, (cls,), {"Meta": type("Meta", (), {"model": model, "fields": cls.Meta.fields})})


T = typing.TypeVar("T")


class SingleParamSerializer(serializers.Serializer, typing.Generic[T]):
"""
A serializer for validating individual GET parameters in DRF views/filters.
This class provides a reusable way to validate single parameters using DRF's
serializer fields, while maintaining type hints and clean error handling.
Example:
>>> field = serializers.IntegerField(required=True, min_value=1)
>>> value = SingleParamSerializer[int].validate_param('page', field, request.query_params)
"""

@classmethod
def clean(
cls,
param_name: str,
field: serializers.Field,
data: dict[str, typing.Any],
) -> T:
"""
Validate a single parameter using the provided field configuration.
Args:
param_name: The name of the parameter to validate
field: The DRF Field instance to use for validation
data: Dictionary containing the parameter value (typically request.query_params)
Returns:
The validated and transformed parameter value
Raises:
ValidationError: If the parameter value is invalid according to the field rules
"""
instance = cls(param_name, field, data=data)
if instance.is_valid(raise_exception=True):
return typing.cast(T, instance.validated_data.get(param_name))

# This shouldn't be reached due to raise_exception=True, but keeps type checker happy
raise api_exceptions.ValidationError(f"Invalid value for parameter: {param_name}")

def __init__(
self,
param_name: str,
field: serializers.Field,
*args: typing.Any,
**kwargs: typing.Any,
) -> None:
"""
Initialize the serializer with a single field for the given parameter.
Args:
param_name: The name of the parameter to validate
field: The DRF Field instance to use for validation
*args: Additional positional arguments passed to parent
**kwargs: Additional keyword arguments passed to parent
"""
super().__init__(*args, **kwargs)
self.fields[param_name] = field


class FilterParamsSerializer(serializers.Serializer):
"""
Serializer for validating query parameters in DRF views.
Typically in filters for list views.
A normal serializer with one helpful method to:
1) run .is_valid()
2) raise any validation exceptions
3) then return the cleaned data.
"""

def clean(self) -> dict[str, typing.Any]:
if self.is_valid(raise_exception=True):
return self.validated_data
raise api_exceptions.ValidationError("Invalid filter parameters")
43 changes: 29 additions & 14 deletions ami/main/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from django.db import models
from django.db.models import Prefetch
from django.db.models.query import QuerySet
from django.forms import BooleanField, CharField, DateField, IntegerField
from django.forms import BooleanField, CharField, IntegerField
from django.utils import timezone
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import exceptions as api_exceptions
Expand All @@ -23,6 +23,7 @@
from ami.base.filters import NullsLastOrderingFilter
from ami.base.pagination import LimitOffsetPaginationWithPermissions
from ami.base.permissions import IsActiveStaffOrReadOnly
from ami.base.serializers import FilterParamsSerializer, SingleParamSerializer
from ami.utils.requests import get_active_classification_threshold
from ami.utils.storages import ConnectionTestResult

Expand Down Expand Up @@ -595,15 +596,14 @@ def populate(self, request, pk=None):

def _get_source_image(self):
"""
Allow parameter to be passed as a GET query param or in the request body.
Get source image from either GET query param or in the PUT/POST request body.
"""
key = "source_image"
try:
source_image_id = IntegerField(required=True, min_value=0).clean(
self.request.data.get(key) or self.request.query_params.get(key)
)
except Exception as e:
raise api_exceptions.ValidationError from e
source_image_id = SingleParamSerializer[int].clean(
key,
field=serializers.IntegerField(required=True, min_value=0),
data=dict(self.request.data, **self.request.query_params),
)

try:
return SourceImage.objects.get(id=source_image_id)
Expand Down Expand Up @@ -831,18 +831,33 @@ def filter_queryset(self, request: Request, queryset, view):
return queryset


class DateRangeFilterSerializer(FilterParamsSerializer):
date_start = serializers.DateField(required=False)
date_end = serializers.DateField(required=False)

def validate(self, data):
"""
Additionally validate that the start date is before the end date.
"""
start_date = data.get("date_start")
end_date = data.get("date_end")
if start_date and end_date and start_date > end_date:
raise api_exceptions.ValidationError({"date_start": "Start date must be before end date"})
return data


class OccurrenceDateFilter(filters.BaseFilterBackend):
"""
Filter occurrences within a date range that their detections were observed.
"""

query_param_start = "date_start"
query_param_end = "date_end"

def filter_queryset(self, request, queryset, view):
# Validate and clean the query params. They should be in ISO format.
start_date = DateField(required=False).clean(request.query_params.get(self.query_param_start))
end_date = DateField(required=False).clean(request.query_params.get(self.query_param_end))
cleaned_data = DateRangeFilterSerializer(data=request.query_params).clean()

# Access the validated dates
start_date = cleaned_data.get("date_start")
end_date = cleaned_data.get("date_end")

if start_date:
queryset = queryset.filter(detections__timestamp__date__gte=start_date)
Expand Down Expand Up @@ -954,7 +969,7 @@ class TaxonViewSet(DefaultViewSet):

queryset = Taxon.objects.all()
serializer_class = TaxonSerializer
filter_backends = DefaultViewSetMixin.filter_backends + [CustomTaxonFilter]
filter_backends = DefaultViewSetMixin.filter_backends + [CustomTaxonFilter, TaxonCollectionFilter]
filterset_fields = [
"name",
"rank",
Expand Down
1 change: 1 addition & 0 deletions ui/src/app.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
height: 100dvh;
overflow-x: auto;
overflow-y: auto;
scroll-padding: 64px 0;
}

.main {
Expand Down
31 changes: 31 additions & 0 deletions ui/src/components/error-state/error-state.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { AlertCircleIcon } from 'lucide-react'
import { useMemo } from 'react'

interface ErrorStateProps {
error?: any
}

export const ErrorState = ({ error }: ErrorStateProps) => {
const title = error?.message ?? 'Unknown error'
const data = error?.response?.data

const description = useMemo(() => {
const entries = data ? Object.entries(data) : undefined

if (entries?.length) {
const [key, value] = entries[0]

return `${key}: ${value}`
}
}, [error])

return (
<div className="flex flex-col items-center py-24">
<AlertCircleIcon className="w-8 h-8 text-destructive mb-8" />
<span className="body-large font-medium mb-2">{title}</span>
{description ? (
<span className="body-base text-muted-foreground">{description}</span>
) : null}
</div>
)
}
23 changes: 13 additions & 10 deletions ui/src/components/filtering/filter-control.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { X } from 'lucide-react'
import { Button } from 'nova-ui-kit'
import { AVAILABLE_FILTERS, useFilters } from 'utils/useFilters'
import { useFilters } from 'utils/useFilters'
import { AlgorithmFilter, NotAlgorithmFilter } from './filters/algorithm-filter'
import { CollectionFilter } from './filters/collection-filter'
import { DateFilter } from './filters/date-filter'
Expand Down Expand Up @@ -50,32 +50,30 @@ export const FilterControl = ({
readonly,
}: FilterControlProps) => {
const { filters, addFilter, clearFilter } = useFilters()
const label = AVAILABLE_FILTERS.find(
(filter) => filter.field === field
)?.label
const value = filters.find((filter) => filter.field === field)?.value
const filter = filters.find((filter) => filter.field === field)
const FilterComponent = ComponentMap[field]

if (!label || !FilterComponent) {
if (!filter || !FilterComponent) {
return null
}

if (readonly && !value) {
if (readonly && !filter?.value) {
return null
}

return (
<div>
<label className="flex pl-2 pb-3 text-muted-foreground body-overline-small font-bold">
{label}
{filter.label}
</label>
<div className="flex items-center justify-between gap-2">
<FilterComponent
value={value}
isValid={!filter.error}
onAdd={(value) => addFilter(field, value)}
onClear={() => clearFilter(field)}
value={filter.value}
/>
{clearable && value && (
{clearable && filter.value && (
<Button
size="icon"
className="shrink-0 text-muted-foreground"
Expand All @@ -86,6 +84,11 @@ export const FilterControl = ({
</Button>
)}
</div>
{filter.error ? (
<span className="flex pl-2 pt-3 body-small text-destructive italic">
{filter.error}
</span>
) : null}
</div>
)
}
30 changes: 24 additions & 6 deletions ui/src/components/filtering/filters/date-filter.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,45 @@
import { format } from 'date-fns'
import { Calendar as CalendarIcon } from 'lucide-react'
import { AlertCircleIcon, Calendar as CalendarIcon } from 'lucide-react'
import { Button, Calendar, Popover } from 'nova-ui-kit'
import { useState } from 'react'
import { FilterProps } from './types'

const dateToLabel = (date: Date) => format(date, 'yyyy-MM-dd')
const dateToLabel = (date: Date) => {
try {
return format(date, 'yyyy-MM-dd')
} catch {
return 'Invalid date'
}
}

export const DateFilter = ({ value, onAdd, onClear }: FilterProps) => {
export const DateFilter = ({ isValid, onAdd, onClear, value }: FilterProps) => {
const [open, setOpen] = useState(false)
const selected = value ? new Date(value) : undefined

const triggerLabel = (() => {
if (!value) {
return 'Select a date'
}

return value
})()

return (
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-72 justify-between text-muted-foreground font-normal"
className="w-full justify-between text-muted-foreground font-normal"
>
<>
<span>{selected ? dateToLabel(selected) : 'Select a date'}</span>
<CalendarIcon className="w-4 w-4" />
<span>{triggerLabel}</span>
{selected && !isValid ? (
<AlertCircleIcon className="w-4 w-4 text-destructive" />
) : (
<CalendarIcon className="w-4 w-4" />
)}
</>
</Button>
</Popover.Trigger>
Expand Down
4 changes: 2 additions & 2 deletions ui/src/components/filtering/filters/type-filter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import { Select } from 'nova-ui-kit'
import { FilterProps } from './types'

const OPTIONS = SERVER_JOB_TYPES.map((key) => {
const typrInfo = Job.getJobTypeInfo(key)
const typeInfo = Job.getJobTypeInfo(key)

return {
...typrInfo,
...typeInfo,
}
})

Expand Down
3 changes: 2 additions & 1 deletion ui/src/components/filtering/filters/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export interface FilterProps {
value: string | undefined
isValid?: boolean
onAdd: (value: string) => void
onClear: () => void
value: string | undefined
}
2 changes: 1 addition & 1 deletion ui/src/components/filtering/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,5 @@ export const booleanToString = (value?: boolean) =>
// Help function to decide if a filter section should be open or not on page load
export const someActive = (
fields: string[],
activeFilters: { field: string; value: string }[]
activeFilters: { field: string }[]
) => activeFilters.some(({ field }) => fields.includes(field))
7 changes: 3 additions & 4 deletions ui/src/components/gallery/gallery.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
gap: 16px;
width: 100%;
min-height: 320px;
padding-top: 24px;

&.loading {
margin: 0;
Expand All @@ -20,11 +21,8 @@
}

.loadingWrapper {
position: absolute;
width: 100%;
height: 100%;
top: 0;
right: 0;
min-height: 320px;
display: flex;
align-items: center;
justify-content: center;
Expand All @@ -45,5 +43,6 @@
@media only screen and (max-width: $small-screen-breakpoint) {
.gallery {
grid-template-columns: 1fr !important;
padding-top: 16px;
}
}
Loading

0 comments on commit 9d7285a

Please sign in to comment.