diff --git a/ami/main/api/schemas.py b/ami/main/api/schemas.py index 6937133a0..c9835b2fb 100644 --- a/ami/main/api/schemas.py +++ b/ami/main/api/schemas.py @@ -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, +) diff --git a/ami/main/api/serializers.py b/ami/main/api/serializers.py index 354df5459..a34e7d561 100644 --- a/ami/main/api/serializers.py +++ b/ami/main/api/serializers.py @@ -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() + top_identifiers = UserIdentificationCountSerializer(many=True) diff --git a/ami/main/api/views.py b/ami/main/api/views.py index c4ca76da8..5a6d5aece 100644 --- a/ami/main/api/views.py +++ b/ami/main/api/views.py @@ -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 @@ -90,6 +91,7 @@ TaxonListSerializer, TaxonSearchResultSerializer, TaxonSerializer, + TopIdentifiersResponseSerializer, ) logger = logging.getLogger(__name__) @@ -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: `//stats//?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( + 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. diff --git a/ami/main/models_future/occurrence.py b/ami/main/models_future/occurrence.py index 4592ec8c9..6f599cbe0 100644 --- a/ami/main/models_future/occurrence.py +++ b/ami/main/models_future/occurrence.py @@ -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` @@ -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 @@ -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") + ) diff --git a/ami/main/tests.py b/ami/main/tests.py index c31d56faa..517c4c87b 100644 --- a/ami/main/tests.py +++ b/ami/main/tests.py @@ -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) + + 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[^/.]+)/$` + 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") diff --git a/config/api_router.py b/config/api_router.py index 49475f726..d90452423 100644 --- a/config/api_router.py +++ b/config/api_router.py @@ -38,6 +38,8 @@ router.register(r"captures/upload", views.SourceImageUploadViewSet) router.register(r"captures", views.SourceImageViewSet) router.register(r"detections", views.DetectionViewSet) +# Register before r"occurrences" — see docs/claude/reference/api-stats-pattern.md (guarded by regression test). +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/ diff --git a/docs/claude/archive/pr-1296-occurrence-stats-migration.md b/docs/claude/archive/pr-1296-occurrence-stats-migration.md new file mode 100644 index 000000000..3e870cefd --- /dev/null +++ b/docs/claude/archive/pr-1296-occurrence-stats-migration.md @@ -0,0 +1,277 @@ +# PR #1296 — Migrate top-identifiers to /occurrences/stats/ + Stats API Convention + +**Status**: Plan written 2026-05-13. Implementation pending. +**Branch**: `feat/project-overview` (`worktree-project-overview` worktree) +**PR**: https://github.com/RolnickLab/antenna/pull/1296 + +## Where we are + +4 commits already pushed to `origin/feat/project-overview`: + +1. `8494ad34` — `fix(project-overview): address PR review feedback` (drop email field, refactor view to use serializer, set `require_project=True`, `Count(filter=Q)`, list-item img cropping fix) +2. `7758499c` — `fix(api): gate top-identifiers endpoint on project visibility` (Project.visible_for_user 404 gate) +3. `b671d699` — `fix(api): count distinct occurrences in top-identifiers leaderboard` (`Count("identifications__occurrence", distinct=True)`) +4. `253d5dcf` — `refactor(summary): extract SummaryColumn to dedupe overview columns` (#7 from takeaway review, also drops dead `?ordering=` on per-row detail-page links) + +Stack still running with worktree code bind-mounted into main project compose. UI dev server on port 4000. Backend on 8000. + +## Decision: stats endpoint convention + +After comparing iNat / GitHub / GitLab / Stripe / eBird / GBIF conventions, and probing the current route table: + +``` +/users/... handled by djoser router (registered as r"users") +/users/identifications/top/ handled by explicit path() listed BEFORE router.urls → + works today because Django URL resolver matches in order; + not actually claimed by djoser, but bypasses the router + → pollutes /users/ hierarchy + no autodoc grouping +/identifications/ our IdentificationViewSet +/occurrences/ our OccurrenceViewSet +/occurrences/stats/ currently dispatches to OccurrenceViewSet.retrieve(pk="stats") + → 404 via Occurrence.objects.get(pk="stats") ValueError. + NOT clean greenfield — slot must be claimed by registering + r"occurrences/stats" BEFORE r"occurrences" +``` + +Migration motivation: `/users/identifications/top/` lives outside the router → no autodoc, no +consistency with `/projects//...` and `/captures/...`. Also: this PR ships the first +aggregate-stats endpoint, so the route shape sets precedent for many future ones. + +**Chosen pattern**: `//stats//?project_id=X` + +- **Entity-rooted** (iNat domain precedent): path tells caller what universe is being aggregated +- **Explicit `stats/` 2nd segment** (GitHub-flavored): discoverable, distinct from raw list endpoints +- **Nested under entity prefix** (not sibling `r"occurrence-stats"`): reads hierarchically. Cost: + must register `r"occurrences/stats"` BEFORE `r"occurrences"` else DRF's retrieve route eats it. + Annotate the registration in `config/api_router.py` with a `# NB:` comment for future readers +- **Noun = `stats`** (not `metrics`): "metrics" carries observability/Prometheus connotation + (request rates, latency, ops dashboards). "Stats" reads as product-domain aggregates (counts, + rankings, distributions). Pairs cleanly with existing `/status/summary/` global-scalar endpoint +- **`?project_id=X` scope param**: consistent with existing `ProjectMixin` query-param scoping + across deployments/events/captures/taxa. `require_project=True` enforces at framework level + via `self.get_active_project()` — but only fires when the action calls it. Every `@action` + on a stats viewset MUST call `self.get_active_project()`; docstring reminds +- **Kebab-kind URL slugs**: matches existing router convention (`source-images`, `data-exports`) +- **Org-future**: `?organization_id=Y` query param. Same URL shape. Stats functions take `scope` + and dispatch +- **Module layout** (M2 colocated): stats query functions live near the entity + (`ami/main/models_future/occurrence.py`), viewsets thin compose +- **Response shape is per-kind, always declared via DRF serializer**: ranked top-N returns + envelope `{project_id, : [...]}` matching the existing top-identifiers shape; scalar + aggregates return `{...}`; charts return Plotly-shaped objects modelled with nested + serializers. NEVER `Response({"some": "dict"})` with no declared schema. Use + `@extend_schema(responses=...)` so OpenAPI captures shape. Pydantic NOT used — DRF + serializers integrate with drf-spectacular natively; Pydantic would introduce a parallel + type system for no benefit +- **Query-param validation via `SingleParamSerializer`** (existing convention, + `ami/base/serializers.py:108`): every query param gets a typed field. Bad values 400, not 500. + See `ami/jobs/views.py:321` for the rationale comment + +**Specifically for this PR**: `/occurrences/stats/top-identifiers/?project_id=X&limit=5` + +## Future sibling endpoints under this pattern + +- `/occurrences/stats/species-counts/?project_id=X` — top species (alternative to current Taxon viewset ordering) +- `/occurrences/stats/by-algorithm/?project_id=X` — occurrence distribution by ML pipeline +- `/captures/stats/processing-progress/?project_id=X` or `?deployment_id=Y` — % images processed +- `/classifications/stats/accuracy/?project_id=X` — species-wise accuracy from predictions+verifications +- Org-level same paths, swap `?organization_id=Y` + +## Implementation plan (3 commits, on top of `253d5dcf`) + +### Commit 1: extract query into models_future, no behavior change + +**File**: `ami/main/models_future/occurrence.py` (new) + +```python +from django.db.models import Count, Q +from ami.main.models import User, Project + + +def top_identifiers_for_project(project: Project, limit: int = 5): + """Return a queryset of Users annotated with their identification_count + (distinct occurrences identified) for the given project, ordered desc. + """ + 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] + ) +``` + +**File**: `ami/main/api/views.py::UserIdentificationCountsView.get` — call the new function instead of inlining. Keeps existing route working. + +### Commit 2: add /occurrences/stats/ viewset with top-identifiers action + tests + +**File**: `ami/main/api/views.py` + +```python +class TopIdentifiersResponseSerializer(serializers.Serializer): + """Declared response shape for /occurrences/stats/top-identifiers/. + + Envelope keeps the existing FE contract Anna built (project_id + named list). + Future stats actions either keep an envelope keyed by their kind name, OR + return a scalar dict / nested Plotly-shaped object — but ALWAYS via a + declared serializer so OpenAPI captures the shape. No raw Response({...}). + """ + project_id = serializers.IntegerField() + top_identifiers = UserIdentificationCountSerializer(many=True) + + +class OccurrenceStatsViewSet(viewsets.ViewSet, ProjectMixin): + """Aggregate stats over Occurrences. Each @action = one stats kind. + + Convention (see docs/claude/planning/pr-1296-occurrence-stats-migration.md): + - URL: //stats//?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 + + if not Project.objects.visible_for_user(request.user).filter(pk=project.pk).exists(): + raise NotFound("Project not found.") + + limit = SingleParamSerializer[int].clean( + 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}) +``` + +**Note**: `limit_doc_param` = new `OpenApiParameter("limit", int, required=False, default=5, +description="1-50; 400 if out of range")`. Add alongside `project_id_doc_param`. + +**File**: `config/api_router.py` + +```python +# NB: r"occurrences/stats" MUST register BEFORE r"occurrences" — DRF's DefaultRouter +# preserves registration order and the OccurrenceViewSet's retrieve route +# `^occurrences/(?P[^/.]+)/$` would otherwise capture `/occurrences/stats/`. +router.register(r"occurrences/stats", views.OccurrenceStatsViewSet, basename="occurrence-stats") +router.register(r"occurrences", views.OccurrenceViewSet) # existing line, just moved below +``` + +**File**: `ami/main/tests/test_api.py` (or wherever the existing UserIdentificationCounts tests live) + +Tests required in this commit (NOT a follow-up): +- `test_top_identifiers_happy_path` — `?project_id=18` returns 200 + envelope shape + + ordered desc by `identification_count` +- `test_top_identifiers_with_limit` — `?project_id=18&limit=2` returns 200 + 2 entries +- `test_top_identifiers_limit_out_of_range` — `?project_id=18&limit=0` → 400; + `?project_id=18&limit=99` → 400 +- `test_top_identifiers_no_project_id` — no `project_id` → 400 (require_project) +- `test_top_identifiers_invisible_project_anon` — draft project as anonymous → 404 +- `test_top_identifiers_distinct_count` — user with 2 identifications on same occurrence + counts as 1 (the b671d699 fix) +- `test_registration_order` — assert `/occurrences/stats/` resolves to + `OccurrenceStatsViewSet` not `OccurrenceViewSet.retrieve` (catches accidental + re-registration order changes) + +### Commit 3: flip UI hook + drop old route/view + +**File**: move `ui/src/data-services/hooks/identifications/useTopIdentifiers.ts` → +`ui/src/data-services/hooks/occurrences/stats/useTopIdentifiers.ts` + +- Change URL to `${API_URL}/occurrences/stats/top-identifiers/?project_id=...` +- Optionally add `&limit=5` (default matches backend) +- **Response interface UNCHANGED**: `{project_id?, top_identifiers: [...]}` envelope kept + (Anna's existing shape). Only the URL flips +- Update import sites in `ui/src/pages/project/summary/summary.tsx` to new path + +**File**: `ui/src/pages/project/summary/summary.tsx::MostIdentifications` + +- No data-shape changes (envelope unchanged), just the import path update from + `data-services/hooks/identifications/useTopIdentifiers` → + `data-services/hooks/occurrences/stats/useTopIdentifiers` + +**File**: `ami/main/api/views.py` + +- Delete `UserIdentificationCountsView` class +- Serializer `UserIdentificationCountSerializer` stays (used by new viewset) + +**File**: `config/api_router.py` + +- Remove `path("users/identifications/top/", ...)` registration + +**Verification**: +- `curl /api/v2/occurrences/stats/top-identifiers/?project_id=18` → 200 envelope +- `curl /api/v2/occurrences/stats/top-identifiers/?project_id=18&limit=2` → 200, 2 entries +- `curl /api/v2/occurrences/stats/top-identifiers/` → 400 (project_id required) +- `curl /api/v2/occurrences/stats/top-identifiers/?project_id=18&limit=99` → 400 +- `curl /api/v2/occurrences/stats/top-identifiers/?project_id=99999` → 404 +- `curl /api/v2/users/identifications/top/?project_id=18` → 404 (old route gone) +- Browser: project 18 summary → "Most identifications" panel renders top 5 with same + numbers as before (Kent 31, Mohamed 5, etc) + +### Commit 4: convention reference doc + +**File**: `docs/claude/reference/api-stats-pattern.md` (new) + +One page. Sections: +1. **Pattern**: `//stats//?project_id=X[&...]` with kebab-kind, scope as query +2. **Why nested + the registration-order trap** (link back to this planning doc) +3. **Why `stats` not `metrics`** (metrics = observability connotation; stats = product aggregates) +4. **Response shapes per kind** (envelope vs scalar vs Plotly-nested), all serializer-declared +5. **Query param validation** via `SingleParamSerializer` +6. **`get_active_project()` discipline** (require_project=True only fires when called) +7. **Org-future**: same URL, `?organization_id=Y` swaps in + +PR description links to this ref doc. Future stats endpoints follow it. + +## After this PR — convention propagation tasks + +1. Open follow-up issue: "Migrate /status/summary/ to /projects//stats/ or /summary/ pattern + (decide later)" — note current `SummaryView` is the scalar-counts special case, doesn't need + urgent migration +2. Next stats endpoint that lands (species-accuracy, processing-progress, etc) confirms the + convention. If the 2nd endpoint resists the entity-rooted shape, revisit +3. Add convention pointer to `docs/claude/INDEX.md` after Commit 4 + +## Closed concerns (from second review pass 2026-05-13) + +- ~~Sibling vs nested route shape~~ — nested won (hierarchy reads better, ordering trap accepted) +- ~~`stats` vs `metrics` noun~~ — `stats` won (no observability/Prometheus connotation) +- ~~Pydantic vs DRF serializer for response shape~~ — DRF (drf-spectacular integration native) +- ~~Drop envelope vs keep Anna's envelope~~ — keep (FE doesn't change, future stats endpoints + follow per-kind shape rules) +- ~~`limit` clamp vs strict validation~~ — strict via `SingleParamSerializer` (400 on out-of-range, + matches `ami/jobs/views.py:321` precedent) +- ~~Tests as open question~~ — required in Commit 2 +- ~~djoser-squat claim~~ — wrong; the path() works fine, real issue is router bypass + namespace + pollution +- ~~`/occurrences/stats/` clean greenfield claim~~ — wrong; falls into retrieve(pk="stats"), + registration order is mandatory + +## Resume instructions for next session + +1. Pull latest on `feat/project-overview` (HEAD after this plan-update commit) +2. Stack still bind-mounted into main compose (`/home/michael/Projects/AMI/antenna/docker-compose.override.yml` overrides django+celeryworker+ui-dev to point at worktree) +3. Apply Commit 1 (extract query into `ami/main/models_future/occurrence.py`). `docker compose restart django`. Verify old `/users/identifications/top/?project_id=18` still returns same envelope +4. Apply Commit 2 (new viewset + route + tests). Probe new URL via curl, confirm registration order won (registration-order test catches regressions) +5. Apply Commit 3 (UI hook move + path flip + old route/view delete). Verify in browser +6. Commit 4 (convention reference doc) +7. Push all four. Reply on PR thread `3232119836` (mihow's "Do you think we will have other identifications stats to expose?") citing the ref doc + the new URL diff --git a/docs/claude/planning/stats-list-pattern.md b/docs/claude/planning/stats-list-pattern.md new file mode 100644 index 000000000..0c5dfedbd --- /dev/null +++ b/docs/claude/planning/stats-list-pattern.md @@ -0,0 +1,130 @@ +# Stats endpoints — list / paginator pattern (deferred) + +Companion to `docs/claude/reference/api-stats-pattern.md`. + +The reference doc establishes the scalar pattern as the default for +`//stats//` endpoints. This doc captures the +list-paginator pattern that was prototyped and removed before PR #1296 +merged, ready to revive when a stats kind genuinely needs it. + +## When to opt in + +Use the scalar pattern unless the kind has both: + +1. **Many rows** the UI wants to page through (hundreds-to-thousands — + not a five-row leaderboard), AND +2. **`?ordering=` flexibility the UI actually exercises** (sort by + different annotated fields, ascending vs descending). + +If neither holds, the scalar `{project_id, top_identifiers: [...]}` +envelope with a single `?limit=N` slice is enough. Don't pay for the +paginator's `{count, next, previous, results}` overhead just because +the response happens to be a list. + +Indicators you've crossed the line: + +- A `?limit=N` query param with `N` in the hundreds. +- The UI's "view all" page wants `?offset=` paging. +- The UI lets the user re-sort the column (counts, names, dates). + +## What the opt-in looks like + +Add `StatsPagination` + filter backends + `ordering_fields` at the +viewset level, then the action returns the paginator envelope: + +```python +class StatsPagination(LimitOffsetPagination): + default_limit = 5 + max_limit = 50 + + +class OccurrenceStatsViewSet(viewsets.GenericViewSet, ProjectMixin): + 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 + + @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): + project = self.get_active_project() + assert project is not None + if not Project.objects.visible_for_user(request.user).filter(pk=project.pk).exists(): + raise NotFound("Project not found.") + + 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) +``` + +Response envelope: `{count, next, previous, results: [...]}` instead +of the scalar `{project_id, top_identifiers: [...]}`. The frontend +hook switches to reading `data?.results`. + +## Coexistence with scalar kinds + +The viewset can host both. A single action picks its shape: paginate + +`get_paginated_response` for list-kinds, or build a dict + serialize + +`Response(serializer.data)` for scalar-kinds. The viewset's +`pagination_class` and `filter_backends` are shared infrastructure, not +applied automatically — they only kick in for actions that call +`self.paginate_queryset()` / `self.filter_queryset()`. + +If a stats viewset ends up with both shapes, docstring should call out +which actions opt into the paginator and why. + +## Why this was deferred from PR #1296 + +The PR established the URL convention with one concrete action +(`top_identifiers`). Initial implementation used the paginator envelope +to demonstrate the "list" pattern. Review feedback: + +- The leaderboard is intentionally short (default 5, never more than 50) + — three extra `{count, next, previous}` fields per response add no value. +- Showing both patterns up front lent weight to a list-default + assumption that doesn't reflect how stats kinds actually shake out + (most are small fixed-shape blobs). +- A speculative second action (`identifications_summary`) was added + just to demonstrate the scalar shape — but the leaderboard itself + already wanted scalar, so the placeholder was scaffolding for a + pattern we hadn't validated. + +Reverting to scalar-only for the merged PR removed the placeholder +action and dropped the paginator/filter machinery, then captured the +opt-in design here so the next stats endpoint can adopt it without +re-deriving anything. + +## Tests to add when adopting + +On top of the standard stats-endpoint test set (happy path, missing +project_id, invalid params, draft 404, registration order): + +- default pagination (no `?limit=`) returns `default_limit` rows +- `?limit=N` caps `results` length, `count` still reflects the total +- `?offset=N` skips into the queryset +- `?ordering=-field` flips the sort +- `?ordering=field` (ascending) matches default-but-reversed +- unknown ordering fields silently ignored (DRF default; we're not + custom-validating) + +## Open questions for when this gets picked up + +- Does the `LimitOffsetPaginationWithPermissions` machinery want a + `_get_current_model` hint via `queryset = X.objects.none()` here? + PR #1296's prototype set `queryset = User.objects.none()` because + the rows were Users; verify the pagination permissions layer + actually reads this. +- Should `max_limit` differ between leaderboard-style kinds (capped + at ~50) and timeline-style kinds (more flexible)? Probably define + per-action `pagination_class` rather than per-viewset if so. +- `NullsLastOrderingFilter` only — do we want the full + `DefaultViewSetMixin.filter_backends` (`DjangoFilterBackend` + + `SearchFilter`)? Stats kinds probably don't want free-text search; + keep it narrow. diff --git a/docs/claude/reference/api-stats-pattern.md b/docs/claude/reference/api-stats-pattern.md new file mode 100644 index 000000000..0a2cd20d2 --- /dev/null +++ b/docs/claude/reference/api-stats-pattern.md @@ -0,0 +1,244 @@ +# API stats endpoints — `//stats//` + +How to add an aggregate / leaderboard / chart endpoint that doesn't fit +the standard CRUD shape of an entity ViewSet. + +## TL;DR + +- URL: `/api/v2//stats//?project_id=X[&...]` +- Backed by `viewsets.GenericViewSet` (action-based, no implicit CRUD), + namespaced under the entity the stats are computed *over*. +- One `@action(detail=False, methods=["get"], url_path="")` per + stats kind. +- Response declared via a kind-specific `Serializer` + `@extend_schema(responses=...)` + so drf-spectacular autodocs the shape. +- Default response shape is a **scalar dict** owned by the kind. The + generic DRF paginator envelope is a fallback we'll opt into per-action + when a kind genuinely needs `?limit / ?offset / ?ordering` rails — see + `docs/claude/planning/stats-list-pattern.md` for the deferred design. +- Project gated via `ProjectMixin` (`require_project = True`) + + `Project.objects.visible_for_user(request.user)` for draft visibility. +- Pure querysets live in `ami/main/models_future/.py`, view-free. + +Reference implementation: `OccurrenceStatsViewSet` at +`ami/main/api/views.py`. The pure query lives in +`ami/main/models_future/occurrence.py::top_identifiers_for_project()`. + +## The scalar pattern + +```python +class TopIdentifiersResponseSerializer(serializers.Serializer): + project_id = serializers.IntegerField() + top_identifiers = UserIdentificationCountSerializer(many=True) + + +class OccurrenceStatsViewSet(viewsets.GenericViewSet, ProjectMixin): + 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 + if not Project.objects.visible_for_user(request.user).filter(pk=project.pk).exists(): + raise NotFound("Project not found.") + + limit = SingleParamSerializer[int].clean( + 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]) + return Response( + TopIdentifiersResponseSerializer( + {"project_id": project.pk, "top_identifiers": top_users}, + context={"request": request}, + ).data + ) +``` + +The kind owns its envelope. A summary stat returns `{total: 42}`. A +leaderboard returns `{project_id, top_identifiers: [...]}`. A timeline +returns `{series: [{date, count}, ...]}`. There's no shared meta layer — +each kind's serializer is the contract. + +## Why scalar by default + +Most stats are small fixed-shape blobs the frontend renders into a +specific widget. Reaching for the paginator envelope before knowing the +consumer's needs gets you `count: 5, next: null, previous: null, results: [...]` +for a five-row leaderboard that the UI was always going to show in +full — three fields of noise to thread through every consumer. + +When a kind genuinely needs paginated rails (e.g. a deployment +leaderboard with hundreds of entries that the UI wants to sort and +page through), see `docs/claude/planning/stats-list-pattern.md` for the +opt-in design. + +## Don't leak entity lists + +For kinds that rank entities by activity (top identifiers, top species, +top deployments), the underlying queryset MUST exclude entities with +zero activity — non-configurable, baked into the `models_future` function. + +Otherwise an anonymous client can call the stats endpoint without any +data in the project and get back the full project user list (or full +taxon list, etc). With `identification_count >= 1` baked in, the +endpoint returns an empty list instead. + +```python +def top_identifiers_for_project(project: Project) -> QuerySet[User]: + return ( + User.objects.filter(identifications__occurrence__project=project) + .annotate(identification_count=Count(..., distinct=True)) + .filter(identification_count__gt=0) # <-- non-configurable + .order_by("-identification_count") + ) +``` + +The `?limit=N` param decides *how many* rows to return. The query +function decides *which rows are eligible* — and zero-count rows +never are. + +## Registration order matters + +In `config/api_router.py`: + +```python +router.register(r"occurrences/stats", views.OccurrenceStatsViewSet, basename="occurrence-stats") +router.register(r"occurrences", views.OccurrenceViewSet) # AFTER stats +``` + +DRF's `DefaultRouter` walks routes in registration order. +`OccurrenceViewSet`'s detail route is `^occurrences/(?P[^/.]+)/$` — +register it first and `/occurrences/stats/` matches with `pk="stats"`, +then 404s on `Occurrence.objects.get(pk="stats")`. The stats URL never +reaches its ViewSet. + +Guard with a regression test — see +`TestOccurrenceStatsViewSet.test_registration_order_preserves_occurrence_retrieve`. + +## Why `stats`, not `metrics` + +"Metrics" carries observability connotations (Prometheus, StatsD, +infra). "Stats" reads as product-domain aggregates — what a user sees +on a dashboard. Reserve `metrics` for ops/infra; use `stats` for +user-facing aggregations. + +## Query parameters + +Use `SingleParamSerializer[T].clean(...)` from `ami/base/serializers.py`. +It runs a DRF `serializers.IntegerField` / etc. through the standard +validation pipeline and raises `ValidationError` → DRF returns 400 with +the field-level error body the frontend expects. + +```python +limit = SingleParamSerializer[int].clean( + param_name="limit", + field=serializers.IntegerField(required=False, min_value=1, max_value=50, default=5), + data=request.query_params, +) +``` + +Do **not** clamp silently — bad input should fail loud at the boundary. +Precedent: `ami/jobs/views.py:321` (cutoff_hours), `ami/jobs/views.py:250` +(logs_limit). + +## `get_active_project()` discipline + +`ProjectMixin` with `require_project = True` enforces project +**presence** through `self.get_active_project()` — missing `project_id` +raises 400, an id pointing at a nonexistent project raises 404. It does +NOT gate **draft visibility** — drafts must be filtered explicitly: + +```python +project = self.get_active_project() +assert project is not None +if not Project.objects.visible_for_user(request.user).filter(pk=project.pk).exists(): + raise NotFound("Project not found.") +``` + +Two lines, inline at the top of each action. Don't extract a helper +until a viewset has 3+ actions sharing the gate — at that point a +private method is justified, but with one or two actions it's just +indirection. + +## Permissions + +```python +permission_classes = [IsActiveStaffOrReadOnly] +``` + +`IsActiveStaffOrReadOnly` allows anon reads. The inline `visible_for_user` +check above is what filters draft projects for non-members. + +## Backend layer split + +- `ami/main/models_future/.py` — pure querysets / aggregation + functions, no view dependencies. Reusable from jobs, exports, scripts. + Security-baked filters (e.g. `identification_count >= 1`) live here. +- `ami/main/api/views.py` — viewset wires HTTP concerns (params, + permissions, serialization, visibility gate) to the query function. + +## Frontend hook location + +Mirror the backend URL hierarchy: + +``` +ui/src/data-services/hooks//stats/.ts +``` + +e.g. `ui/src/data-services/hooks/occurrences/stats/useTopIdentifiers.ts`. + +The hook's `Response` interface matches the kind-specific serializer. + +## Tests required (in the same commit) + +For every new stats action: + +- happy path — returns the expected shape with correct values +- missing `project_id` → 400 (via `require_project`) +- invalid query param (e.g. out-of-range `limit`) → 400 +- draft project, anonymous user → 404 (visibility gate) +- correctness invariant specific to the kind (e.g. distinct-vs-raw + count, zero-count entities excluded) + +Plus, for the viewset overall (once): + +- registration order — both the stats URL and the entity retrieve URL + resolve, in case someone reorders router registrations later + +See `TestOccurrenceStatsViewSet` in `ami/main/tests.py` (6 tests). + +## What this convention replaces + +The earlier `/users/identifications/top/` pattern: an explicit `path()` +registration on a `GenericAPIView` sitting outside the DRF router. It +worked, but it bypassed router autodoc, polluted the `/users/` namespace, +and gave no place to put a sibling stats action. + +The new pattern is a router-registered `GenericViewSet` with one +`@action` per kind, autodoc'd through drf-spectacular, namespaced under +the entity the stats are computed *over*. + +## Future stats actions + +Naming examples that fit the convention (all scalar by default — opt +into pagination only if the kind genuinely needs it): + +- `GET /occurrences/stats/top-identifiers/` — done (this PR) +- `GET /occurrences/stats/identifications-summary/` — total / distinct / verified counts +- `GET /occurrences/stats/human-model-agreement/` — model agreement rate +- `GET /occurrences/stats/identifications-by-species/` — per-taxon ID counts +- `GET /occurrences/stats/timeline/` — Plotly-shaped time series +- `GET /deployments/stats/processed-images/` — processed images per station +- `GET /deployments/stats/by-status/` — capture counts per processing state +- `GET /taxa/stats/most-observed/` — top species by occurrence count +- `GET /jobs/stats/by-status/` — counts per JobState + +When adding a new entity's stats viewset, copy the docstring from +`OccurrenceStatsViewSet` verbatim — it's the contract. diff --git a/docs/claude/sessions/pr-1296-anna-original-body.md b/docs/claude/sessions/pr-1296-anna-original-body.md new file mode 100644 index 000000000..4bd110447 --- /dev/null +++ b/docs/claude/sessions/pr-1296-anna-original-body.md @@ -0,0 +1,40 @@ +# PR #1296 — Anna's original PR body (snapshot 2026-05-13) + +Preserved verbatim for post-compact reference. When updating PR description with +new commits, DO NOT clobber this content. Anna's words stay; add a section +underneath if needed. + +## Title +Add live data to project summary + +## Body (verbatim) + +``` +## Summary + +In this PR we include some live data in the project summary. This is to make the project dashboard feel a bit more alive and interactive. The information about top identifiers is new and not something we have been able to see before. Maybe this could be motivating for team members to see? + +### List of Changes + +* Setup new backend endpoint for top identifiers +* Add overview section to project summary +* Present latest occurrences in overview section +* Present top identifiers in overview section +* Present top taxa in overview section + +## Detailed Description + +### How to Test the Changes + +@mihow could you help test and review the backend changes I included, since I'm no expert here? + +### Screenshots + +Screenshot 2026-05-07 at 13 26 56 + +### Next Steps + +Include a stats section in the occurrence view where we can see total number of identifications, percentage of occurrences verified and maybe even human-model agreement rate? +``` + +(CodeRabbit auto-summary follows in actual PR body — that's owned by CR, leave it alone too.) diff --git a/ui/src/components/error-state/error-state.tsx b/ui/src/components/error-state/error-state.tsx index 104efdcc0..8358eb7ae 100644 --- a/ui/src/components/error-state/error-state.tsx +++ b/ui/src/components/error-state/error-state.tsx @@ -26,7 +26,7 @@ export const ErrorState = ({ compact, error }: ErrorStateProps) => { return (
- {title} + {title}
) } diff --git a/ui/src/data-services/hooks/occurrences/stats/useTopIdentifiers.ts b/ui/src/data-services/hooks/occurrences/stats/useTopIdentifiers.ts new file mode 100644 index 000000000..9538e876e --- /dev/null +++ b/ui/src/data-services/hooks/occurrences/stats/useTopIdentifiers.ts @@ -0,0 +1,40 @@ +import { API_ROUTES, API_URL } from 'data-services/constants' +import { useAuthorizedQuery } from '../../auth/useAuthorizedQuery' + +interface TopIdentifier { + id: number + name?: string + image?: string + identification_count: number +} + +interface Response { + project_id: number + top_identifiers: TopIdentifier[] +} + +export const useTopIdentifiers = (projectId?: string, limit = 5) => { + const url = `${API_URL}/${API_ROUTES.OCCURRENCES}/stats/top-identifiers/` + + const params = new URLSearchParams() + if (projectId) params.set('project_id', projectId) + params.set('limit', String(limit)) + + const { data, isLoading, isFetching, error } = useAuthorizedQuery({ + queryKey: [ + API_ROUTES.OCCURRENCES, + 'stats', + 'top-identifiers', + projectId, + limit, + ], + url: `${url}?${params.toString()}`, + }) + + return { + data, + isLoading, + isFetching, + error, + } +} diff --git a/ui/src/data-services/hooks/occurrences/useLatestOccurrences.ts b/ui/src/data-services/hooks/occurrences/useLatestOccurrences.ts new file mode 100644 index 000000000..7c31fd478 --- /dev/null +++ b/ui/src/data-services/hooks/occurrences/useLatestOccurrences.ts @@ -0,0 +1,11 @@ +import { useOccurrences } from './useOccurrences' + +export const useLatestOccurrences = (projectId: string) => { + const result = useOccurrences({ + pagination: { page: 0, perPage: 5 }, + projectId, + sort: { field: 'first_appearance_timestamp', order: 'desc' }, + }) + + return result +} diff --git a/ui/src/data-services/hooks/species/useTopSpecies.ts b/ui/src/data-services/hooks/species/useTopSpecies.ts new file mode 100644 index 000000000..b51480fbb --- /dev/null +++ b/ui/src/data-services/hooks/species/useTopSpecies.ts @@ -0,0 +1,11 @@ +import { useSpecies } from './useSpecies' + +export const useTopSpecies = (projectId: string) => { + const result = useSpecies({ + pagination: { page: 0, perPage: 5 }, + projectId, + sort: { field: 'occurrences_count', order: 'desc' }, + }) + + return result +} diff --git a/ui/src/pages/project/sidebar/sidebar.tsx b/ui/src/pages/project/sidebar/sidebar.tsx index 943e9ef37..96be9bda5 100644 --- a/ui/src/pages/project/sidebar/sidebar.tsx +++ b/ui/src/pages/project/sidebar/sidebar.tsx @@ -25,7 +25,7 @@ export const Sidebar = ({ project }: { project: ProjectDetails }) => { }, [activeItem]) return ( -
+
{project.image ? ( ) : ( diff --git a/ui/src/pages/project/summary/list-item.tsx b/ui/src/pages/project/summary/list-item.tsx new file mode 100644 index 000000000..eea329b9d --- /dev/null +++ b/ui/src/pages/project/summary/list-item.tsx @@ -0,0 +1,52 @@ +import { ImageIcon, UserIcon } from 'lucide-react' + +export const ListItem = ({ + count, + item, +}: { + count?: number | string + item: { + image: { src?: string; variant?: 'default' | 'user' } + text?: string + title?: string + } +}) => ( +
+ {item.image.variant === 'user' ? ( + + ) : ( + + )} +
+ {item.title ? ( + {item.title} + ) : null} + {item.text ? {item.text} : null} +
+ {count !== undefined ? ( + + {count.toLocaleString()} + + ) : null} +
+) + +const Image = ({ image }: { image?: string }) => ( +
+ {image ? ( + + ) : ( + + )} +
+) + +const UserImage = ({ image }: { image?: string }) => ( +
+ {image ? ( + + ) : ( + + )} +
+) diff --git a/ui/src/pages/project/summary/summary.tsx b/ui/src/pages/project/summary/summary.tsx index 9b5b9ff1d..1446691b3 100644 --- a/ui/src/pages/project/summary/summary.tsx +++ b/ui/src/pages/project/summary/summary.tsx @@ -1,5 +1,9 @@ +import classNames from 'classnames' import { ErrorState } from 'components/error-state/error-state' +import { useTopIdentifiers } from 'data-services/hooks/occurrences/stats/useTopIdentifiers' +import { useLatestOccurrences } from 'data-services/hooks/occurrences/useLatestOccurrences' import { useProjectCharts } from 'data-services/hooks/projects/useProjectCharts' +import { useTopSpecies } from 'data-services/hooks/species/useTopSpecies' import { useStatus } from 'data-services/hooks/useStatus' import { ProjectDetails } from 'data-services/models/project-details' import { Box } from 'design-system/components/box/box' @@ -7,11 +11,15 @@ import { LoadingSpinner } from 'design-system/components/loading-spinner/loading import { PlotGrid } from 'design-system/components/plot-grid/plot-grid' import { Plot } from 'design-system/components/plot/lazy-plot' import * as Tabs from 'design-system/components/tabs/tabs' +import { buttonVariants } from 'nova-ui-kit' import { UploadImagesDialog } from 'pages/captures/upload-images-dialog/upload-images-dialog' import { useState } from 'react' -import { useOutletContext } from 'react-router-dom' +import { Link, useOutletContext } from 'react-router-dom' +import { APP_ROUTES } from 'utils/constants' +import { STRING, translate } from 'utils/language' import { UserPermission } from 'utils/user/types' import { DeploymentsMap } from './deployments-map' +import { ListItem } from './list-item' export const Summary = () => { const { project } = useOutletContext<{ @@ -23,7 +31,7 @@ export const Summary = () => { const showUpload = status && status.numCaptures === 0 && canUpload return ( -
+
{showUpload || isOpen ? (

Welcome!

@@ -41,30 +49,194 @@ export const Summary = () => { ) : ( <> - +
+

{translate(STRING.OVERVIEW)}

+
+
+

+ {translate(STRING.LATEST_OCCURRENCES)} +

+ +
+
+

+ {translate(STRING.MOST_IDENTIFICATIONS)} +

+ +
+
+

+ {translate(STRING.MOST_OBSERVED_TAXA)} +

+ +
+
+
+
+

{translate(STRING.CHARTS)}

+ +
)}
) } -const ProjectCharts = ({ projectId }: { projectId: string }) => { - const { projectCharts, isLoading, error } = useProjectCharts(projectId) - +const SummaryColumn = ({ + isLoading, + error, + isEmpty, + viewAllHref, + children, +}: { + isLoading: boolean + error: unknown + isEmpty: boolean + viewAllHref: string + children: React.ReactNode +}) => { if (isLoading) { + return + } + + if (error) { + return + } + + if (isEmpty) { return ( -
- -
+

+ {translate(STRING.MESSAGE_NO_RESULTS_TO_SHOW)} +

) } + return ( +
+
{children}
+ + {translate(STRING.VIEW_ALL)} + +
+ ) +} + +const LatestOccurrences = ({ projectId }: { projectId: string }) => { + const { occurrences, isLoading, error } = useLatestOccurrences(projectId) + + return ( + + {occurrences?.map((occurrence) => ( + + + + ))} + + ) +} + +const MostIdentifications = ({ projectId }: { projectId: string }) => { + const { data, isLoading, error } = useTopIdentifiers(projectId) + + return ( + + {data?.top_identifiers.map((user) => ( +
+ +
+ ))} +
+ ) +} + +const MostObservedTaxa = ({ projectId }: { projectId: string }) => { + const { species, isLoading, error } = useTopSpecies(projectId) + + return ( + + {species?.map((species) => ( + + + + ))} + + ) +} + +const Charts = ({ projectId }: { projectId: string }) => { + const { projectCharts, isLoading, error } = useProjectCharts(projectId) + + if (isLoading) { + return + } + if (error) { - return + return } if (!projectCharts?.length) { - return null + return ( +

+ {translate(STRING.MESSAGE_NO_RESULTS_TO_SHOW)} +

+ ) } return ( diff --git a/ui/src/pages/species-details/species-details.tsx b/ui/src/pages/species-details/species-details.tsx index b599b8642..d6677033c 100644 --- a/ui/src/pages/species-details/species-details.tsx +++ b/ui/src/pages/species-details/species-details.tsx @@ -133,7 +133,7 @@ export const SpeciesDetails = ({ {hasChildren ? (