Skip to content
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
d18aea8
feat: setup endpoint for top identifiers
annavik May 7, 2026
4ba75dd
feat: add overview section to project summary with top identifiers
annavik May 7, 2026
8dbf006
feat: add top taxa to overview section
annavik May 7, 2026
f450128
feat: add latest occurrences to overview section
annavik May 7, 2026
b9a2c3e
feat: handle loading, error and empty states
annavik May 7, 2026
f42c462
layout: adjust layout for small screens
annavik May 7, 2026
f39ea6c
fix: replace hard coded strings
annavik May 7, 2026
fe1862e
feat: add links to occurrences and taxa
annavik May 7, 2026
2a3a997
Merge branch 'main' into feat/project-overview
annavik May 7, 2026
0f9f1c0
fix: cleanup
annavik May 7, 2026
8494ad3
fix(project-overview): address PR review feedback
mihow May 13, 2026
7758499
fix(api): gate top-identifiers endpoint on project visibility
mihow May 13, 2026
b671d69
fix(api): count distinct occurrences in top-identifiers leaderboard
mihow May 13, 2026
253d5dc
refactor(summary): extract SummaryColumn to dedupe overview columns
mihow May 13, 2026
86d2f90
docs: plan for /occurrences/stats/ endpoint convention + top-identifi…
mihow May 14, 2026
5d20619
docs: lock decisions on /occurrences/stats/ pattern after 2nd review
mihow May 14, 2026
ba00cdd
docs: snapshot Anna's PR #1296 body before adding Claude commits
mihow May 14, 2026
c83ebae
refactor(stats): extract top_identifiers query into models_future
mihow May 14, 2026
a50abd3
feat(stats): add /occurrences/stats/top-identifiers/ endpoint
mihow May 14, 2026
1a176c3
refactor(stats): flip FE to /occurrences/stats/, delete legacy backend
mihow May 14, 2026
de2ddc5
docs(api): convention reference for /<entity>/stats/<kind>/ endpoints
mihow May 14, 2026
d649c1f
Merge remote-tracking branch 'origin/main' into worktree-project-over…
mihow May 14, 2026
e4a0d29
docs(api): expand future-stats examples (deployments, agreement, spec…
mihow May 14, 2026
4c9acd5
refactor(stats): use paginator + add scalar-shape placeholder
mihow May 14, 2026
fbf4382
refactor(stats): simplify to scalar shape, defer paginator pattern
mihow May 14, 2026
7209708
chore(api_router): collapse stats-route ordering comment to one line
mihow May 14, 2026
1da2943
chore(docs): archive PR #1296 stats migration plan [no ci]
mihow May 14, 2026
db0157a
ci: trigger checks on current head
mihow May 14, 2026
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
7 changes: 7 additions & 0 deletions ami/main/api/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,10 @@
required=False,
type=int,
)

limit_doc_param = OpenApiParameter(
name="limit",
description="Maximum number of items to return (1-50, default 5).",
required=False,
type=int,
)
32 changes: 32 additions & 0 deletions ami/main/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1717,3 +1717,35 @@ class Meta:
"total_size",
"last_checked",
]


class UserIdentificationCountSerializer(DefaultSerializer):
"""One row of the top-identifiers leaderboard.

Mirrors UserNestedSerializer's public fields (no email) and adds the
annotated `identification_count`.
"""

identification_count = serializers.IntegerField(read_only=True)

class Meta:
model = User
fields = [
"id",
"name",
"image",
"identification_count",
]


class TopIdentifiersResponseSerializer(serializers.Serializer):
"""Scalar response for /occurrences/stats/top-identifiers/.

Wraps the leaderboard in a project-scoped envelope so the kind owns its
response shape (vs. the generic DRF paginator envelope). Future stats
kinds declare their own response serializer the same way — see
docs/claude/reference/api-stats-pattern.md.
"""

project_id = serializers.IntegerField()
Comment thread
mihow marked this conversation as resolved.
top_identifiers = UserIdentificationCountSerializer(many=True)
60 changes: 59 additions & 1 deletion ami/main/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@
from ami.base.permissions import IsActiveStaffOrReadOnly, IsProjectMemberOrReadOnly, ObjectPermission
from ami.base.serializers import FilterParamsSerializer, SingleParamSerializer
from ami.base.views import ProjectMixin
from ami.main.api.schemas import project_id_doc_param
from ami.main.api.schemas import limit_doc_param, project_id_doc_param
from ami.main.api.serializers import TagSerializer
from ami.main.models_future.occurrence import top_identifiers_for_project
from ami.utils.requests import get_default_classification_threshold
from ami.utils.storages import ConnectionTestResult

Expand Down Expand Up @@ -90,6 +91,7 @@
TaxonListSerializer,
TaxonSearchResultSerializer,
TaxonSerializer,
TopIdentifiersResponseSerializer,
)

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -1263,6 +1265,62 @@ def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)


class OccurrenceStatsViewSet(viewsets.GenericViewSet, ProjectMixin):
"""Aggregate stats over Occurrences. Each @action == one stats kind.

Response shape per kind is declared via a DRF serializer + `@extend_schema`
so drf-spectacular autodocs it. Most kinds will be small scalar dicts;
when a kind genuinely needs `?limit / ?offset / ?ordering` rails (a paginated
leaderboard of thousands of entities), opt into `viewsets.GenericViewSet`'s
paginator + filter_backends on a per-action basis. See
docs/claude/reference/api-stats-pattern.md and
docs/claude/planning/stats-list-pattern.md.

Conventions for every action:

- URL: `/<entity>/stats/<kind>/?project_id=X[&...]`
- Resolve project on the first line; we use the inline 2-line pattern below
so visibility (draft → 404) is gated explicitly. `ProjectMixin` only
enforces project presence (`require_project=True` → 400/404 on missing
or unknown id), not draft visibility.
- Query params (beyond `project_id`) go through
`SingleParamSerializer[T].clean(...)` for strict 400 validation —
no silent clamps.
"""

permission_classes = [IsActiveStaffOrReadOnly]
require_project = True

@extend_schema(
parameters=[project_id_doc_param, limit_doc_param],
responses=TopIdentifiersResponseSerializer,
)
@action(detail=False, methods=["get"], url_path="top-identifiers")
def top_identifiers(self, request):
"""Users ranked by distinct occurrences they identified.

`top_identifiers_for_project` bakes in `identification_count >= 1` —
non-configurable, so an empty / anonymous call can't leak the full
project user list.
"""
project = self.get_active_project()
assert project is not None # require_project=True guarantees this
if not Project.objects.visible_for_user(request.user).filter(pk=project.pk).exists():
raise NotFound("Project not found.")

limit = SingleParamSerializer[int].clean(
Comment thread
mihow marked this conversation as resolved.
param_name="limit",
field=serializers.IntegerField(required=False, min_value=1, max_value=50, default=5),
data=request.query_params,
)
top_users = list(top_identifiers_for_project(project)[:limit])
serializer = TopIdentifiersResponseSerializer(
{"project_id": project.pk, "top_identifiers": top_users},
context={"request": request},
)
return Response(serializer.data)


class TaxonTaxaListFilter(filters.BaseFilterBackend):
"""
Filters taxa based on a TaxaList.
Expand Down
30 changes: 28 additions & 2 deletions ami/main/models_future/occurrence.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""
Reusable Prefetch factories for Occurrence list/detail rendering.
Reusable Prefetch factories and aggregate queries for Occurrence rendering.

The serializer trusts the prefetch contract — the viewset is the single place
that wires it up. Don't gate serializer methods on `_prefetched_objects_cache`
Expand All @@ -12,7 +12,9 @@

from typing import TYPE_CHECKING

from django.db.models import Prefetch
from django.db.models import Count, Prefetch, Q, QuerySet

from ami.main.models import Project, User

if TYPE_CHECKING:
from ami.main.models import Classification, Identification, Occurrence
Expand Down Expand Up @@ -129,3 +131,27 @@ def detection_image_urls_from_prefetch(occurrence: Occurrence, limit: int | None
if limit is not None:
detections = detections[:limit]
return [get_media_url(det.path) for det in detections]


def top_identifiers_for_project(project: Project) -> QuerySet[User]:
"""Project users ranked by distinct occurrences they identified.

Counts distinct occurrences, not raw Identification rows: a user revising
their own ID on the same occurrence is one occurrence-identification, not two.

Always filters `identification_count >= 1` so anonymous / empty calls never
leak the full project user list. **Non-configurable** — callers (paginator,
list slicing) get to choose how many rows to return, but never which rows.
"""
return (
User.objects.filter(identifications__occurrence__project=project)
.annotate(
identification_count=Count(
"identifications__occurrence",
filter=Q(identifications__occurrence__project=project),
distinct=True,
)
)
.filter(identification_count__gt=0)
.order_by("-identification_count")
)
84 changes: 84 additions & 0 deletions ami/main/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -4677,3 +4677,87 @@ def test_source_image_cached_counts_refresh_on_threshold_change(self):
f"SourceImage {image.pk} cache stale after raising threshold: "
f"cache={image.detections_count}, fresh={image.get_detections_count()}",
)


class TestOccurrenceStatsViewSet(APITestCase):
"""Covers /api/v2/occurrences/stats/top-identifiers/.

See docs/claude/reference/api-stats-pattern.md for the broader convention.
"""

top_url = "/api/v2/occurrences/stats/top-identifiers/"

def setUp(self) -> None:
project, deployment = setup_test_project()
create_taxa(project=project)
create_captures(deployment=deployment)
create_occurrences(deployment=deployment, num=4)
self.project = project
self.deployment = deployment
self.taxon = Taxon.objects.filter(projects=project).first()
self.alice = User.objects.create_user(email="alice@insectai.org") # type: ignore
self.bob = User.objects.create_user(email="bob@insectai.org") # type: ignore
self.carol = User.objects.create_user(email="carol@insectai.org") # type: ignore
return super().setUp()

def _id(self, user: User, occurrence: Occurrence) -> Identification:
return Identification.objects.create(user=user, taxon=self.taxon, occurrence=occurrence)

Comment thread
mihow marked this conversation as resolved.
def test_returns_ranked_list_in_envelope(self):
"""Happy path: envelope shape + ranking + distinct-occurrence count + limit slice."""
occurrences = list(Occurrence.objects.filter(project=self.project))
# alice IDs 3 distinct occurrences (one of them twice — counts as 1)
for occ in occurrences[:3]:
self._id(self.alice, occ)
self._id(self.alice, occurrences[0]) # revised ID, same occurrence
# bob IDs 2, carol IDs 1
for occ in occurrences[:2]:
self._id(self.bob, occ)
self._id(self.carol, occurrences[0])

response = self.client.get(f"{self.top_url}?project_id={self.project.pk}&limit=2")
self.assertEqual(response.status_code, 200)
body = response.json()
# Scalar envelope, NOT DRF paginator's `{count, next, previous, results}`.
self.assertEqual(set(body.keys()), {"project_id", "top_identifiers"})
self.assertEqual(body["project_id"], self.project.pk)
# limit=2 caps to top 2 by identification_count; alice's revised ID counts as 1 occurrence.
counts = [(row["id"], row["identification_count"]) for row in body["top_identifiers"]]
self.assertEqual(counts, [(self.alice.pk, 3), (self.bob.pk, 2)])

def test_excludes_users_with_zero_count(self):
"""`identification_count >= 1` is non-configurable so anon calls can't leak the project user list."""
# carol has no identifications in this project
self._id(self.alice, Occurrence.objects.filter(project=self.project).first())
self._id(self.bob, Occurrence.objects.filter(project=self.project).first())

response = self.client.get(f"{self.top_url}?project_id={self.project.pk}")
ids = [r["id"] for r in response.json()["top_identifiers"]]
self.assertEqual(set(ids), {self.alice.pk, self.bob.pk})

def test_no_project_id_returns_400(self):
response = self.client.get(self.top_url)
self.assertEqual(response.status_code, 400)

def test_invalid_limit_returns_400(self):
"""Strict validation via SingleParamSerializer — no silent clamps."""
response = self.client.get(f"{self.top_url}?project_id={self.project.pk}&limit=999")
self.assertEqual(response.status_code, 400)

def test_draft_project_404_for_anon(self):
self.project.draft = True
self.project.save()
response = self.client.get(f"{self.top_url}?project_id={self.project.pk}")
self.assertEqual(response.status_code, 404)

def test_registration_order_preserves_occurrence_retrieve(self):
"""`r"occurrences/stats"` MUST register before `r"occurrences"`.

If swapped, OccurrenceViewSet.retrieve's `^occurrences/(?P<pk>[^/.]+)/$`
captures `/occurrences/stats/` first and 404s on `pk="stats"`.
"""
occurrence = Occurrence.objects.filter(project=self.project).first()
stats_response = self.client.get(f"{self.top_url}?project_id={self.project.pk}")
retrieve_response = self.client.get(f"/api/v2/occurrences/{occurrence.pk}/?project_id={self.project.pk}")
self.assertEqual(stats_response.status_code, 200, "stats URL must resolve")
self.assertEqual(retrieve_response.status_code, 200, "occurrence retrieve must still work")
4 changes: 4 additions & 0 deletions config/api_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@
router.register(r"captures/upload", views.SourceImageUploadViewSet)
router.register(r"captures", views.SourceImageViewSet)
router.register(r"detections", views.DetectionViewSet)
# NB: r"occurrences/stats" MUST register BEFORE r"occurrences". DRF's DefaultRouter
# preserves registration order; OccurrenceViewSet.retrieve's `^occurrences/(?P<pk>[^/.]+)/$`
# would otherwise capture `/occurrences/stats/` first and 404 on `pk="stats"`.
router.register(r"occurrences/stats", views.OccurrenceStatsViewSet, basename="occurrence-stats")
router.register(r"occurrences", views.OccurrenceViewSet)
router.register(r"taxa/lists", views.TaxaListViewSet)
# NESTED: /taxa/lists/{taxalist_id}/taxa/
Expand Down
Loading
Loading