Skip to content
Merged
Show file tree
Hide file tree
Changes from 23 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):
"""
Serializer for user identification counts.

Mirrors the public fields of UserNestedSerializer (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):
"""Envelope for /occurrences/stats/top-identifiers/.

Declares the response shape so drf-spectacular can autodoc it. Kept
identical to the legacy /users/identifications/top/ envelope to avoid
frontend churn during the migration.
"""

project_id = serializers.IntegerField()
Comment thread
mihow marked this conversation as resolved.
top_identifiers = UserIdentificationCountSerializer(many=True)
43 changes: 42 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,8 @@
TaxonListSerializer,
TaxonSearchResultSerializer,
TaxonSerializer,
TopIdentifiersResponseSerializer,
UserIdentificationCountSerializer,
)

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


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

Convention (see docs/claude/reference/api-stats-pattern.md):
- URL: /<entity>/stats/<kind>/?project_id=X[&limit=N&...]
- Every action MUST call `self.get_active_project()` — `require_project=True`
only enforces through that call path, NOT automatically per request.
- Every action MUST declare its response via @extend_schema(responses=...)
with a serializer. No raw Response({...}) shapes.
- Query params validated via SingleParamSerializer (ami/base/serializers.py).
"""

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):
project = self.get_active_project()
assert project is not None # require_project=True guarantees this

# Draft projects must not leak identifier names/photos to non-members.
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,
)
queryset = top_identifiers_for_project(project, limit=limit)
user_serializer = UserIdentificationCountSerializer(queryset, many=True, context={"request": request})
return Response({"project_id": project.id, "top_identifiers": user_serializer.data})
Comment thread
mihow marked this conversation as resolved.
Outdated


class TaxonTaxaListFilter(filters.BaseFilterBackend):
"""
Filters taxa based on a TaxaList.
Expand Down
26 changes: 24 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,23 @@ 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, limit: int = 5) -> QuerySet[User]:
"""Users ranked by distinct occurrences they identified in this project.

Counts distinct occurrences, not raw Identification rows: a user revising
their own ID on the same occurrence is one occurrence-identification, not two.
"""
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")[:limit]
)
101 changes: 101 additions & 0 deletions ami/main/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -4677,3 +4677,104 @@ 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 TestOccurrenceStatsTopIdentifiers(APITestCase):
"""Covers /api/v2/occurrences/stats/top-identifiers/.

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

endpoint = "/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_happy_path_returns_ranked_list(self):
occurrences = list(Occurrence.objects.filter(project=self.project))
# alice IDs 3 distinct occurrences, bob 2, carol 1
for occ in occurrences[:3]:
self._id(self.alice, occ)
for occ in occurrences[:2]:
self._id(self.bob, occ)
self._id(self.carol, occurrences[0])

response = self.client.get(f"{self.endpoint}?project_id={self.project.pk}")
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertEqual(body["project_id"], self.project.pk)
counts = [(row["id"], row["identification_count"]) for row in body["top_identifiers"]]
self.assertEqual(
counts,
[(self.alice.pk, 3), (self.bob.pk, 2), (self.carol.pk, 1)],
)

def test_limit_caps_results(self):
occurrences = list(Occurrence.objects.filter(project=self.project))
self._id(self.alice, occurrences[0])
self._id(self.bob, occurrences[1])
self._id(self.carol, occurrences[2])

response = self.client.get(f"{self.endpoint}?project_id={self.project.pk}&limit=2")
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json()["top_identifiers"]), 2)

def test_limit_below_min_returns_400(self):
response = self.client.get(f"{self.endpoint}?project_id={self.project.pk}&limit=0")
self.assertEqual(response.status_code, 400)
self.assertIn("limit", response.json())

def test_limit_above_max_returns_400(self):
response = self.client.get(f"{self.endpoint}?project_id={self.project.pk}&limit=51")
self.assertEqual(response.status_code, 400)
self.assertIn("limit", response.json())

def test_no_project_id_returns_400(self):
response = self.client.get(self.endpoint)
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.endpoint}?project_id={self.project.pk}")
self.assertEqual(response.status_code, 404)

def test_counts_distinct_occurrences_not_identification_rows(self):
occurrence = Occurrence.objects.filter(project=self.project).first()
# Same user, same occurrence, two Identification rows (e.g. revised ID).
# Leaderboard should count this as 1 distinct occurrence, not 2.
self._id(self.alice, occurrence)
self._id(self.alice, occurrence)

response = self.client.get(f"{self.endpoint}?project_id={self.project.pk}")
self.assertEqual(response.status_code, 200)
rows = response.json()["top_identifiers"]
alice_row = next(r for r in rows if r["id"] == self.alice.pk)
self.assertEqual(alice_row["identification_count"], 1)

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"`. This
regression test asserts the stats subtree still resolves AND that
retrieving a real occurrence works.
"""
occurrence = Occurrence.objects.filter(project=self.project).first()
stats_response = self.client.get(f"{self.endpoint}?project_id={self.project.pk}")
self.assertEqual(stats_response.status_code, 200, "stats subtree must resolve")
retrieve_response = self.client.get(f"/api/v2/occurrences/{occurrence.pk}/?project_id={self.project.pk}")
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