Skip to content

Commit

Permalink
Merge branch 'main' into 387-add-top-species-chart-to-overview-page
Browse files Browse the repository at this point in the history
  • Loading branch information
mihow authored Dec 19, 2024
2 parents c49176c + 894eb4d commit 15aa4e5
Show file tree
Hide file tree
Showing 83 changed files with 808 additions and 633 deletions.
10 changes: 9 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,16 @@
"typescript.format.enable": true,
"prettier.requireConfig": true,
"[python]": {
"editor.defaultFormatter": "ms-python.black-formatter"
"editor.defaultFormatter": "ms-python.black-formatter",
"editor.tabSize": 4,
"editor.rulers": [
119
]
},
"black-formatter.args": ["--line-length", "119"],
"flake8.args": [
"--max-line-length", "119"
],
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ Antenna uses [Docker](https://docs.docker.com/get-docker/) & [Docker Compose](ht

1) Install Docker for your host operating (Linux, macOS, Windows)

2) Add the following to your `/etc/hosts` file in order to see and process the demo source images. This makes the hostname `minio` and alias for `localhost` so the same image URLs can be viewed in the host machine's web browser and be processed by the ML services. This can be skipped if you are using an external image storage service.
2) Add the following to your `/etc/hosts` file in order to see and process the demo source images. This makes the hostname `minio` and `django` alias for `localhost` so the same image URLs can be viewed in the host machine's web browser and be processed by the ML services. This can be skipped if you are using an external image storage service.

```
127.0.0.1 minio
127.0.0.1 django
```

2) The following commands will build all services, run them in the background, and then stream the logs.
Expand Down Expand Up @@ -200,10 +201,11 @@ Bucket: ami
- Upload some test images to a subfolder in the `ami` bucket (one subfolder per deployment)
- Give the bucket or folder anonymous access using the "Anonymous access" button in the Minio web interface.
- Both public and private buckets with presigned URLs should work.
- Add an entry to your local `/etc/hosts` file to map the `minio` hostname to localhost so the same image URLs can be viewed in your host machine's browser and processed in the backend containers.
- Add entries to your local `/etc/hosts` file to map the `minio` and `django` hostnames to localhost so the same image URLs can be viewed in your host machine's browser and processed in the backend containers.

```
127.0.0.1 minio
127.0.0.1 django
```

## Email
Expand Down
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")
2 changes: 2 additions & 0 deletions ami/jobs/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@ class JobViewSet(DefaultViewSet):
"project",
"deployment",
"source_image_collection",
"source_image_single",
"pipeline",
"job_type_key",
]
ordering_fields = [
"name",
Expand Down
8 changes: 5 additions & 3 deletions ami/main/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1031,6 +1031,7 @@ class OccurrenceListSerializer(DefaultSerializer):
event = EventNestedSerializer(read_only=True)
# first_appearance = TaxonSourceImageNestedSerializer(read_only=True)
determination_details = serializers.SerializerMethodField()
identifications = OccurrenceIdentificationSerializer(many=True, read_only=True)

class Meta:
model = Occurrence
Expand All @@ -1055,11 +1056,14 @@ class Meta:
"detection_images",
"determination_score",
"determination_details",
"identifications",
"created_at",
]

def get_determination_details(self, obj: Occurrence):
# @TODO add an equivalent method to the Occurrence model
# @TODO convert this to query methods to avoid N+1 queries.
# Currently at 100+ queries per page of 10 occurrences.
# Add a reusable method to the OccurrenceQuerySet class and call it from the ViewSet.

context = self.context

Expand Down Expand Up @@ -1089,7 +1093,6 @@ def get_determination_details(self, obj: Occurrence):
class OccurrenceSerializer(OccurrenceListSerializer):
determination = CaptureTaxonSerializer(read_only=True)
detections = DetectionNestedSerializer(many=True, read_only=True)
identifications = OccurrenceIdentificationSerializer(many=True, read_only=True)
predictions = OccurrenceClassificationSerializer(many=True, read_only=True)
deployment = DeploymentNestedSerializer(read_only=True)
event = EventNestedSerializer(read_only=True)
Expand All @@ -1100,7 +1103,6 @@ class Meta:
fields = OccurrenceListSerializer.Meta.fields + [
"determination_id",
"detections",
"identifications",
"predictions",
]
read_only_fields = [
Expand Down
45 changes: 31 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 @@ -925,6 +940,8 @@ def get_queryset(self) -> QuerySet:
"event",
)
qs = qs.with_detections_count().with_timestamps() # type: ignore
qs = qs.with_identifications() # type: ignore

if self.action == "list":
qs = (
qs.all()
Expand Down Expand Up @@ -952,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
7 changes: 7 additions & 0 deletions ami/main/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1947,6 +1947,13 @@ def with_timestamps(self) -> models.QuerySet:
),
)

def with_identifications(self) -> models.QuerySet:
return self.prefetch_related(
"identifications",
"identifications__taxon",
"identifications__user",
)


class OccurrenceManager(models.Manager):
def get_queryset(self) -> OccurrenceQuerySet:
Expand Down
2 changes: 1 addition & 1 deletion config/settings/local.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"0.0.0.0",
"127.0.0.1",
"django",
]
] + env.list("DJANGO_ALLOWED_HOSTS", default=[])

# CACHES
# ------------------------------------------------------------------------------
Expand Down
5 changes: 5 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ services:
env_file:
- ./.envs/.local/.django
- ./.envs/.local/.postgres
- path: .env
required: false
ports:
- "8000:8000"
command: /start
Expand Down Expand Up @@ -124,13 +126,16 @@ services:
- "9000:9000"
volumes:
- ./compose/local/minio/nginx.conf:/etc/nginx/nginx.conf
depends_on:
- minio

minio-init:
image: minio/mc
env_file:
- ./.envs/.local/.django
depends_on:
- minio
- minio-proxy
volumes:
- ./compose/local/minio/init.sh:/etc/minio/init.sh
entrypoint: /etc/minio/init.sh
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
8 changes: 4 additions & 4 deletions ui/src/components/empty-state/empty-state.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,24 @@ import { STRING, translate } from 'utils/language'
import { useFilters } from 'utils/useFilters'

export const EmptyState = () => {
const { filters, isActive: filtersActive, clearFilter } = useFilters()
const { filters, activeFilters, clearFilter } = useFilters()

return (
<div className="flex flex-col gap-6 items-center py-24">
<span className="body-base text-muted-foreground">
{translate(
filtersActive
activeFilters.length
? STRING.MESSAGE_NO_RESULTS_FOR_FILTERING
: STRING.MESSAGE_NO_RESULTS
)}
</span>
{filtersActive && (
{activeFilters.length ? (
<Button
onClick={() => filters.map((filter) => clearFilter(filter.field))}
>
{translate(STRING.CLEAR_FILTERS)}
</Button>
)}
) : null}
</div>
)
}
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>
)
}
Loading

0 comments on commit 15aa4e5

Please sign in to comment.