Skip to content
Merged
Show file tree
Hide file tree
Changes from 24 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 IdentificationsSummarySerializer(serializers.Serializer):
"""Scalar response for /occurrences/stats/identifications-summary/.

Demonstrates pattern 2 (non-list-shaped stats) — the response is a
fixed-shape dict with declared field types so drf-spectacular autodocs it.
"""

total_identifications = serializers.IntegerField()
distinct_identifiers = serializers.IntegerField()
distinct_identified_occurrences = serializers.IntegerField()
97 changes: 97 additions & 0 deletions ami/main/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from rest_framework.exceptions import NotFound, PermissionDenied
from rest_framework.filters import SearchFilter
from rest_framework.generics import GenericAPIView
from rest_framework.pagination import LimitOffsetPagination
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
Expand All @@ -32,6 +33,7 @@
from ami.base.views import ProjectMixin
from ami.main.api.schemas import 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 @@ -71,6 +73,7 @@
EventSerializer,
EventTimelineSerializer,
IdentificationSerializer,
IdentificationsSummarySerializer,
OccurrenceListSerializer,
OccurrenceSerializer,
PageListSerializer,
Expand All @@ -90,6 +93,7 @@
TaxonListSerializer,
TaxonSearchResultSerializer,
TaxonSerializer,
UserIdentificationCountSerializer,
)

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


class StatsPagination(LimitOffsetPagination):
"""Default 5, max 50 — sane defaults for stats list-kinds."""

default_limit = 5
max_limit = 50


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

Two response shapes coexist on this viewset by design — see
docs/claude/reference/api-stats-pattern.md.

1. **List-shaped kinds** (rows of entities with annotations) ride the
standard list-endpoint rails: build the queryset, call
`self.paginate_queryset()` + `self.get_paginated_response()`. Response
is DRF's `{count, next, previous, results: [...]}`. Free: `?limit=N`
and `?offset=M` from the paginator, `?ordering=-field` from
`NullsLastOrderingFilter`. Example: `top_identifiers`.

2. **Scalar / chart-shaped kinds** (single dict, time series, Plotly
JSON, etc.) build the structure, serialize with a kind-specific
`Serializer` + `@extend_schema(responses=...)`, return
`Response(serializer.data)`. No paginator. Example:
`identifications_summary`.

Conventions for every action regardless of shape:

- URL: `/<entity>/stats/<kind>/?project_id=X[&...]`
- MUST call `self.get_active_project()` first — `require_project=True`
only enforces through that call path, not automatically per request.
- MUST declare response via `@extend_schema(responses=...)` so
drf-spectacular autodocs the shape.
- Query params beyond paginator/ordering go through
`SingleParamSerializer[T].clean(...)` for strict 400 validation.
"""

queryset = User.objects.none() # hint for LimitOffsetPaginationWithPermissions._get_current_model
permission_classes = [IsActiveStaffOrReadOnly]
pagination_class = StatsPagination
filter_backends = [NullsLastOrderingFilter]
ordering_fields = ["identification_count"]
require_project = True

def _gate_project(self, request):
"""Resolve + visibility-check the active project; 404 anonymous on drafts."""
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.")
return project

@extend_schema(
parameters=[project_id_doc_param],
responses=UserIdentificationCountSerializer(many=True),
)
@action(detail=False, methods=["get"], url_path="top-identifiers")
def top_identifiers(self, request):
"""Pattern 1 — list-shaped. Users ranked by distinct occurrences they identified.

Always filters `identification_count >= 1` (in `top_identifiers_for_project`)
so an empty / anonymous call never leaks the full user list. Non-configurable.
"""
project = self._gate_project(request)
queryset = top_identifiers_for_project(project)
queryset = self.filter_queryset(queryset)
page = self.paginate_queryset(queryset)
serializer = UserIdentificationCountSerializer(page, many=True, context={"request": request})
return self.get_paginated_response(serializer.data)

@extend_schema(
parameters=[project_id_doc_param],
responses=IdentificationsSummarySerializer,
)
@action(detail=False, methods=["get"], url_path="identifications-summary")
def identifications_summary(self, request):
"""Pattern 2 — scalar kind. Placeholder demonstrating the non-list shape.

A real consumer might want verification rate, model-agreement rate,
or per-week activity. Kept minimal here: three counts, one DB hit
each, serializer-declared response shape.
"""
project = self._gate_project(request)
ids = Identification.objects.filter(occurrence__project=project, withdrawn=False)
data = {
"total_identifications": ids.count(),
"distinct_identifiers": ids.values("user_id").exclude(user__isnull=True).distinct().count(),
"distinct_identified_occurrences": ids.values("occurrence_id").distinct().count(),
}
serializer = IdentificationsSummarySerializer(data)
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")
)
170 changes: 170 additions & 0 deletions ami/main/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -4677,3 +4677,173 @@ 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/* — both response shapes.

- `top_identifiers` → pattern 1 (list, paginated, ordering)
- `identifications_summary` → pattern 2 (scalar, serializer-declared shape)

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

top_url = "/api/v2/occurrences/stats/top-identifiers/"
summary_url = "/api/v2/occurrences/stats/identifications-summary/"

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.
# -------- pattern 1: top_identifiers (list-shaped, paginated) --------

def test_top_identifiers_returns_ranked_paginated_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.top_url}?project_id={self.project.pk}")
self.assertEqual(response.status_code, 200)
body = response.json()
# Standard DRF paginator envelope — no `project_id` / `top_identifiers` wrapper.
self.assertEqual(set(body.keys()), {"count", "next", "previous", "results"})
self.assertEqual(body["count"], 3)
counts = [(row["id"], row["identification_count"]) for row in body["results"]]
self.assertEqual(counts, [(self.alice.pk, 3), (self.bob.pk, 2), (self.carol.pk, 1)])

def test_top_identifiers_default_limit_is_five(self):
"""Without `?limit=`, StatsPagination defaults to 5 rows."""
# Make 6 distinct identifiers each with 1 ID
users = [User.objects.create_user(email=f"u{i}@insectai.org") for i in range(6)] # type: ignore
occurrences = list(Occurrence.objects.filter(project=self.project))
for u, occ in zip(users, occurrences):
self._id(u, occ)
# Reuse occurrence 0 for users 4 & 5 since we only have 4 occurrences
self._id(users[4], occurrences[0])
self._id(users[5], occurrences[1])

response = self.client.get(f"{self.top_url}?project_id={self.project.pk}")
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json()["results"]), 5)

def test_top_identifiers_limit_param_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.top_url}?project_id={self.project.pk}&limit=2")
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json()["results"]), 2)
self.assertEqual(response.json()["count"], 3) # total still 3

def test_top_identifiers_ordering_param_overrides_default(self):
"""`?ordering=identification_count` (ascending) flips the rank."""
occurrences = list(Occurrence.objects.filter(project=self.project))
for occ in occurrences[:3]:
self._id(self.alice, occ)
self._id(self.carol, occurrences[0])

response = self.client.get(f"{self.top_url}?project_id={self.project.pk}&ordering=identification_count")
self.assertEqual(response.status_code, 200)
results = response.json()["results"]
# Ascending: carol (1) before alice (3)
self.assertEqual([r["id"] for r in results], [self.carol.pk, self.alice.pk])

def test_top_identifiers_excludes_users_with_zero_count(self):
"""`identification_count >= 1` is non-configurable.

Anonymous / empty calls must never leak the full 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}")
self.assertEqual(response.status_code, 200)
ids = [r["id"] for r in response.json()["results"]]
self.assertIn(self.alice.pk, ids)
self.assertIn(self.bob.pk, ids)
self.assertNotIn(self.carol.pk, ids)

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

def test_top_identifiers_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_top_identifiers_counts_distinct_occurrences_not_id_rows(self):
occurrence = Occurrence.objects.filter(project=self.project).first()
# Same user, same occurrence, two Identification rows (revised ID).
# Leaderboard counts this as 1 distinct occurrence, not 2.
self._id(self.alice, occurrence)
self._id(self.alice, occurrence)

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

# -------- pattern 2: identifications_summary (scalar) --------

def test_summary_returns_scalar_dict(self):
occurrences = list(Occurrence.objects.filter(project=self.project))
self._id(self.alice, occurrences[0]) # alice → occ 0
self._id(self.alice, occurrences[1]) # alice → occ 1
self._id(self.bob, occurrences[0]) # bob → occ 0 (same occ as alice)

response = self.client.get(f"{self.summary_url}?project_id={self.project.pk}")
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertEqual(body["total_identifications"], 3) # 3 Identification rows
self.assertEqual(body["distinct_identifiers"], 2) # alice + bob
self.assertEqual(body["distinct_identified_occurrences"], 2) # occ 0 + occ 1

def test_summary_no_project_id_returns_400(self):
response = self.client.get(self.summary_url)
self.assertEqual(response.status_code, 400)

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

# -------- shared / regression --------

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 both stats subroutes still resolve AND that
retrieving a real occurrence works.
"""
occurrence = Occurrence.objects.filter(project=self.project).first()
for url in (self.top_url, self.summary_url):
self.assertEqual(
self.client.get(f"{url}?project_id={self.project.pk}").status_code,
200,
f"{url} 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")
Loading
Loading