Skip to content

Configurable taxa lists#1094

Merged
mihow merged 43 commits intomainfrom
feat/taxa-lists
Feb 27, 2026
Merged

Configurable taxa lists#1094
mihow merged 43 commits intomainfrom
feat/taxa-lists

Conversation

@annavik
Copy link
Member

@annavik annavik commented Jan 20, 2026

Background

Will be a help for the upcoming class masking feature, but also something we have been wanting in general. This PR is still in draft mode.

Related to #997.

Missing BE stuff

  • Make it possible to add and remove taxa from taxa lists using the API ✅
  • Make it possible to access direct taxa only (exclude children) when using the API ✅
  • Getting error 500 when creating new taxa list after the BE updates in this branch ✅
  • Remove unused nested taxa endpoint taxalists/id/taxa/
  • Fix extra project_id in URL ✅
  • Decide what to do about user permissions for many2many objects, for now ✅

Missing FE stuff

  • Make sure breadcrumbs are rendered correctly ✅
  • Show taxon details from taxa list detail view without changing route ✅
  • Update logic after API updates ✅

Notes

  • When we have decided on naming, setup new tab "Collections" and add "Taxa lists" as a sub view to this tab (can happen in follow up PR)
  • Expand taxa list detail view with more columns, or is it nice to keep it simple?
  • Fetching taxa is very slow in production, can we make it quicker by not consider related occurrences BE side? Similar to what we did for captures? Or is child taxa the problem?
  • If we can return more information about taxa lists in the taxa response, it would open up for more display options and quick actions in the generic taxa view

Screenshots

List view:
Screenshot 2026-01-20 at 15 03 48

Create view:
Screenshot 2026-01-20 at 15 03 56

Edit view:
Screenshot 2026-01-20 at 15 04 11

Delete view:
Screenshot 2026-01-20 at 15 04 21

Detail view:
Screenshot 2026-01-20 at 15 04 31

Summary by CodeRabbit

  • New Features

    • Full taxa-list management: create lists, view details, and add/remove taxa individually.
  • UI / Navigation

    • Sidebar, routes, pages, dialogs, popovers, and table columns for taxa-list workflows; localized labels.
  • API / Backend

    • Taxa lists now include taxa counts, project scoping, nested taxa endpoints, automatic project association, and descendant filtering.
  • Hooks

    • New hooks to fetch taxa-list details and to add/remove taxa; list hook includes total count.
  • Permissions

    • Project-member read/write permission guard applied to taxa-list actions.
  • Tests

    • New API tests covering taxa-list taxon add/remove and validation.

annavik and others added 13 commits January 14, 2026 12:07
* Initial plan

* Add sorting, timestamps and taxa_count to TaxaList API

Co-Authored-By: Claude <noreply@anthropic.com>

Co-authored-by: mihow <158175+mihow@users.noreply.github.com>

* Optimize taxa_count with query annotation

Co-Authored-By: Claude <noreply@anthropic.com>

Co-authored-by: mihow <158175+mihow@users.noreply.github.com>

* Format lists with trailing commas per black style

Co-Authored-By: Claude <noreply@anthropic.com>

Co-authored-by: mihow <158175+mihow@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: mihow <158175+mihow@users.noreply.github.com>
Co-authored-by: Anna Viklund <annamariaviklund@gmail.com>
@netlify
Copy link

netlify bot commented Jan 20, 2026

Deploy Preview for antenna-preview ready!

Name Link
🔨 Latest commit b94023d
🔍 Latest deploy log https://app.netlify.com/projects/antenna-preview/deploys/69a15aa270eb7a0009925ab7
😎 Deploy Preview https://deploy-preview-1094--antenna-preview.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
Lighthouse
Lighthouse
1 paths audited
Performance: 75 (🟢 up 9 from production)
Accessibility: 89 (🟢 up 9 from production)
Best Practices: 92 (🔴 down 8 from production)
SEO: 100 (🟢 up 8 from production)
PWA: 80 (no change from production)
View the detailed breakdown and full score reports

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify
Copy link

netlify bot commented Jan 20, 2026

Deploy Preview for antenna-ssec ready!

Name Link
🔨 Latest commit b94023d
🔍 Latest deploy log https://app.netlify.com/projects/antenna-ssec/deploys/69a15aa289d5700008c47adc
😎 Deploy Preview https://deploy-preview-1094--antenna-ssec.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 20, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds end-to-end taxa-list management: backend serializers, viewsets, permissions, nested routes, and tests; frontend routes, pages, hooks, components, models, and translations to list, add, and remove taxa from taxa lists.

Changes

Cohort / File(s) Summary
Backend — TaxaList serializers & input DTOs
ami/main/api/serializers.py
TaxaListSerializer now uses DefaultSerializer; adds taxa_count and projects SerializerMethodFields; adds TaxaListTaxonInputSerializer and TaxaListTaxonSerializer.
Backend — Views, filters, nested endpoints
ami/main/api/views.py, ami/main/api/...
TaxaListViewSetDefaultViewSet, annotates annotated_taxa_count, enforces require_project, auto-attaches active project on create; new TaxaListTaxonViewSet for list/create/delete_by_taxon; TaxaListFilter supports include_descendants flag.
Backend — Permissions & tests
ami/base/permissions.py, ami/main/tests.py
New IsProjectMemberOrReadOnly permission; ObjectPermission adjusted; extensive API tests for taxa-list taxa CRUD, validation, and M2M behavior added.
Backend — ML serializers & view tweaks
ami/ml/serializers.py, ami/ml/views.py
ProcessingServiceSerializer replaces write-only project with read-only projects field; ProcessingServiceViewSet adds require_project and perform_create to attach active project.
Backend — Routing
config/api_router.py
Adds nested router taxa_lists_router and registers TaxaListTaxonViewSet under /taxa/lists/{taxalist_id}/taxa/.
Frontend — Routes, constants, i18n
ui/src/app.tsx, ui/src/utils/constants.ts, ui/src/utils/language.ts
New routes and APP_ROUTES builders for taxa lists/taxa-in-list; added translation keys and strings.
Frontend — Pages & columns
ui/src/pages/taxa-lists/*, ui/src/pages/taxa-list-details/*, ui/src/pages/*
New TaxaLists and TaxaListDetails pages, column configs, pagination/sort integration, breadcrumb/sidebar updates, dialog integration for species/details.
Frontend — Add/Remove taxon components & hooks
ui/src/pages/taxa-list-details/add-taxa-list-taxon/*, ui/src/pages/taxa-list-details/remove-taxa-list-taxon/*, ui/src/data-services/hooks/taxa-lists/*
New AddTaxaListTaxon, AddTaxaListTaxonPopover, RemoveTaxaListTaxonDialog; hooks useTaxaListDetails, useAddTaxaListTaxon, useRemoveTaxaListTaxon (invalidate TAXA_LISTS & SPECIES).
Frontend — Data models & service signature changes
ui/src/data-services/models/taxa-list.ts, ui/src/data-services/models/entity.ts, ui/src/data-services/hooks/entities/useDeleteEntity.ts, ui/src/data-services/hooks/entities/utils.ts
ServerTaxaList adds taxa_count; TaxaList exposes taxaCount() and removes taxaUrl; Entity getters null-safe; convertToServerFieldValues uses project_id; useDeleteEntity signature changed to accept { collection, projectId, onSuccess? } and includes project_id query param.
Frontend — Misc UI tweaks
ui/src/components/..., ui/src/pages/species/*, ui/src/pages/occurrences/*
Localization of labels, preserved search params on links, minor prop-order and breadcrumb logic updates.

Sequence Diagram(s)

sequenceDiagram
    participant UI as Client (UI)
    participant Auth as Auth Service
    participant API as Django API
    participant DB as Database

    UI->>Auth: include auth token
    UI->>API: POST /taxa/lists/{taxaListId}/taxa/?project_id={pid} { taxon_id }
    API->>Auth: validate token
    Auth-->>API: token OK
    API->>DB: fetch TaxaList scoped to project
    DB-->>API: TaxaList record
    API->>DB: fetch Taxon by id
    DB-->>API: Taxon record / not found
    alt taxon exists & not duplicate
        API->>DB: create M2M association (taxalist.taxa.add)
        DB-->>API: association persisted
        API-->>UI: 201 Created + taxon payload
    else duplicate or invalid
        API-->>UI: 400 Bad Request / 404 Not Found
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Possibly related PRs

Suggested labels

backend

Suggested reviewers

  • mohamedelabbas1996

Poem

🐰 I hopped through serializers and routes with glee,

Nested lists now bloom for all to see.
Popovers pop, dialogs confirm the deed,
M2M connections planted like seed.
Tests hop in—now taxa-list is free.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Description check ❓ Inconclusive The PR description provides background and screenshots but lacks clear detail on changes, testing instructions, and specific deployment requirements. Add a structured 'List of Changes' section, include 'How to Test the Changes' with specific test cases, and clarify any deployment notes (e.g., database migrations, configuration changes). Ensure the description aligns with the provided template structure.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Configurable taxa lists' directly corresponds to the main feature implemented in this PR, which adds a comprehensive taxa lists feature with frontend UI and backend API integration.
Docstring Coverage ✅ Passed Docstring coverage is 85.19% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/taxa-lists

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Co-Authored-By: Claude <noreply@anthropic.com>
@mihow
Copy link
Collaborator

mihow commented Jan 22, 2026

Exciting! I will look into the missing BE parts. But would love to merge some version of this sooner and continue to improve

@annavik
Copy link
Member Author

annavik commented Jan 23, 2026

Exciting! I will look into the missing BE parts. But would love to merge some version of this sooner and continue to improve

Yes, let's try get something out! I'm fine leaving everything under "Notes" for later. I have updated the PR description with current status. We can now add and remove taxa lists members from UI! 🎉

I think these two backend issues would be nice to fix before we try get this out, at least the first one (listed under "Missing BE stuff")...

Screenshot 2026-01-23 at 12 58 39 Screenshot 2026-01-23 at 12 59 12

@annavik
Copy link
Member Author

annavik commented Jan 23, 2026

Some observations when using the new endpoints to add and remove taxa from lists:

  • The endpoint to add taxon to a list returns 200 even if the taxon is already added
  • The endpoint to remove taxon from a list returns 200 even if the taxon is not a member if the list
  • The endpoint to remove taxon from a list uses method POST, maybe should beDELETE?
  • Maybe these endpoints should follow same structure as the project member endpoints? I'm thinking we have a similar situation of "related objects"...

Just some thoughts, I'm fine leaving this as is for now! Thank you so much for the help @mohamedelabbas1996, it worked well to hook up with FE 🙏

@annavik
Copy link
Member Author

annavik commented Jan 23, 2026

Question: does it make sense to check "can update" permission for the taxa list, before rendering controls for "Add taxon" and "Remove taxon"? This is what I do know! :)

annavik and others added 5 commits January 23, 2026 15:38
… model)

Refactors taxa list endpoints to use nested routes and proper HTTP methods
while keeping the simple ManyToManyField (no through model).

**Backend changes:**
- Created TaxaListTaxonViewSet with nested routes under /taxa/lists/{id}/taxa/
- Simple serializers for input validation and output
- Removed deprecated add_taxon/remove_taxon actions from TaxaListViewSet
- Uses Django M2M .add() and .remove() methods directly

**Frontend changes:**
- Updated useAddTaxaListTaxon to POST to /taxa/ endpoint
- Updated useRemoveTaxaListTaxon to use DELETE method

**API changes:**
- POST /taxa/lists/{id}/taxa/ - Add taxon (returns 201, 400 for duplicates)
- DELETE /taxa/lists/{id}/taxa/by-taxon/{taxon_id}/ - Remove taxon (returns 204, 404 for non-existent)
- GET /taxa/lists/{id}/taxa/ - List taxa in list
- Removed POST /taxa/lists/{id}/add_taxon/ (deprecated)
- Removed POST /taxa/lists/{id}/remove_taxon/ (deprecated)

**Benefits:**
- Same API as project membership (consistency)
- No migration needed (keeps existing simple M2M)
- Proper HTTP semantics (POST=201, DELETE=204)
- RESTful nested resource design

**Tests:**
- Added comprehensive test suite (13 tests, all passing)
- Tests for CRUD operations, validation, and error cases

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
@mihow
Copy link
Collaborator

mihow commented Jan 30, 2026

@annavik @mohamedelabbas1996 I updated the endpoints to use the same pattern as the project-user membership API, but no through-model to keep it a little simpler. I have in a separate PR here until I test it. Feel free to take a look before then. #1104

* fix: entity createdAt guard, utils project field, taxa-list query type and cache key, table loading state

- entity.ts: createdAt getter was guarding on updated_at instead of created_at
- utils.ts: revert project_id back to project; existing serializers (Device, Site,
  StorageSource, SourceImageCollection) declare the field as project
- useTaxaListDetails: generic was TaxaList (client shape) instead of ServerTaxaList
  (raw API shape); added projectId to queryKey to avoid cross-project cache hits
- taxa-list-details: isLoading on Table was gated on !id which is always falsy
  (id is a route param), so the loading state never showed

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: restrict TaxaListTaxonViewSet to IsActiveStaffOrReadOnly; update tests

TaxaListTaxonViewSet had permission_classes = [] (open to everyone).
Set to IsActiveStaffOrReadOnly to match the parent TaxaListViewSet pattern.
Test setUp now creates users with is_staff=True so POST/DELETE pass the
permission check.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: backend cleanup in taxa-list views

- TaxonTaxaListFilter docstring: include_descendants default was documented
  as false but the code defaults to True; corrected docs to match
- get_taxa_list: added "from None" to suppress chained DoesNotExist traceback
  in the NotFound exception
- create: replaced Taxon.objects.get with get_object_or_404 to close the
  race between serializer validation and the lookup
- added get_object_or_404 import

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: UX — permission gating and double-submit guards on taxa-list pages

- taxa-list-details: wrap AddTaxaListTaxonPopover in canUpdate check so
  users without write permission do not see the add button
- add-taxa-list-taxon: disable the Add button while the mutation is in
  flight to prevent duplicate submissions
- remove-taxa-list-taxon-dialog: disable the Confirm button while the
  remove mutation is in flight
- taxa-lists: use the paginated total from the API instead of the length
  of the current page for the results count

Co-Authored-By: Claude <noreply@anthropic.com>

* chore: naming fixes, dead-code removal, missing useEffect dep

- language.ts: rename MESSAGE_MESSAGE_REMOVE_TAXA_LIST_TAXON_CONFIRM to
  MESSAGE_REMOVE_TAXA_LIST_TAXON_CONFIRM (duplicated prefix); update enum
  key, translation map entry, and the one usage site
- new-entity-dialog.tsx: remove unused API_ROUTES import
- useSidebarSections.tsx: remove expression that computes a value but
  never assigns or returns it
- breadcrumbs.tsx: add setMainBreadcrumb to useEffect dependency array

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: transfer focus to dialog after preventDefault in onOpenAutoFocus

When e.preventDefault() stops Radix's default focus behaviour the dialog
has no focused element, which breaks keyboard navigation and screen
readers. Explicitly focus the dialog content after preventing the default
in all three sites: taxa-list-details, species, and occurrences.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: gate TaxaListTaxonViewSet on project membership, not is_staff

IsActiveStaffOrReadOnly blocks any non-staff user from POST/DELETE, but
the intent of taxa lists is that any project member can manage them.

Added IsProjectMemberOrReadOnly to ami/base/permissions.py: safe methods
are open; unsafe methods check project.members via the active project
resolved by ProjectMixin.get_active_project().

Reverted test users back to plain create_user — Project.objects.create
with owner= auto-adds the owner as a member via ensure_owner_membership,
which is all the permission now requires.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: scope taxa list lookup to active project, add missing useEffect dep

- get_taxa_list() now validates that the taxa list belongs to the
  active project, preventing cross-project manipulation via URL params
- Added setDetailBreadcrumb to useEffect dependency array

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: cleanup UI fixes

* fix: allow superusers to add taxa

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Anna Viklund <annamariaviklund@gmail.com>
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (3)
ami/main/api/views.py (2)

1687-1691: list action bypasses pagination.

The list method returns all taxa in the list without pagination. While taxa lists are likely small, this is inconsistent with the rest of the API and could cause issues if a list grows large. Consider using the paginator from self.paginate_queryset or documenting that this endpoint is intentionally unpaginated.

Proposed fix using DRF pagination
     def list(self, request, taxalist_pk=None):
         """List all taxa in the taxa list."""
         queryset = self.get_queryset()
-        serializer = self.get_serializer(queryset, many=True)
-        return Response({"count": queryset.count(), "results": serializer.data})
+        page = self.paginate_queryset(queryset)
+        if page is not None:
+            serializer = self.get_serializer(page, many=True)
+            return self.get_paginated_response(serializer.data)
+        serializer = self.get_serializer(queryset, many=True)
+        return Response({"count": queryset.count(), "results": serializer.data})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ami/main/api/views.py` around lines 1687 - 1691, The list method currently
returns the full queryset bypassing DRF pagination; change it to call
self.paginate_queryset(queryset) and if that returns a page, serialize that page
with self.get_serializer(page, many=True) and return
self.get_paginated_response(serializer.data), otherwise fall back to the
existing Response for the full queryset; update the method that defines list in
the view (the list method on this view class) to use self.paginate_queryset,
self.get_serializer, and self.get_paginated_response accordingly.

1648-1661: Consider removing the silent fallback when project is None.

Since require_project = True is set, get_active_project() will raise Http404 before reaching this code if no project is provided. The if project: guard on line 1659 is therefore dead code, and silently skipping project association could mask bugs if require_project is ever changed to False.

Suggested tightening
     def perform_create(self, serializer):
-        """
-        Create a TaxaList and automatically assign it to the active project.
-
-        Users cannot manually assign taxa lists to projects for security reasons.
-        A taxa list is always created in the context of the active project.
-
-        `@TODO` Do we need to check permissions here? Is this user allowed to add taxa lists to this project?
-        """
         instance = serializer.save()
         project = self.get_active_project()
-        if project:
-            instance.projects.add(project)
+        assert project, "require_project=True should guarantee a project"
+        instance.projects.add(project)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ami/main/api/views.py` around lines 1648 - 1661, The current perform_create
method silently skips associating the new TaxaList when project is None even
though require_project = True makes get_active_project() raise Http404; remove
the dead if guard and directly call project = self.get_active_project() followed
by instance.projects.add(project) (or add an explicit assertion after
get_active_project to make the requirement explicit) so that missing project
conditions are not silently ignored; update perform_create, referencing
perform_create, serializer.save, get_active_project, and instance.projects.add.
ui/src/pages/taxa-lists/taxa-lists.tsx (1)

15-16: Consider clarifying the intent of destructuring id from route params.

On the taxa-lists list page, id appears to serve only as a guard in isLoading={!id && isLoading} (line 54) — presumably to suppress the table loading state when a detail-view is active in a master-detail layout. If this page is never rendered alongside a detail route that provides :id, the guard is a no-op. A brief comment would help future readers understand the intent.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ui/src/pages/taxa-lists/taxa-lists.tsx` around lines 15 - 16, Destructure
`id` from `useParams()` is used as a master/detail guard (see `TaxaLists` and
the `isLoading={!id && isLoading}` expression) to suppress the list table
loading state when a detail route with `:id` is active; add a short in-line
comment next to the `const { projectId, id } = useParams()` and/or the
`isLoading` calculation explaining that `id` may be present only in a split
master-detail layout and is used intentionally to avoid showing the list loading
spinner when a detail view is rendered alongside the list.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@ami/main/api/views.py`:
- Around line 1627-1661: TaxaListViewSet currently inherits the default
IsActiveStaffOrReadOnly via DefaultViewSet, which prevents non-staff project
members from creating/updating taxa lists; update TaxaListViewSet to set
permission_classes = [ObjectPermission] (or [IsProjectMemberOrReadOnly] if your
project uses that) to match other project-scoped viewsets (e.g., ProjectViewSet,
DeviceViewSet), and ensure the chosen permission class is imported at the top of
the file; keep the rest of TaxaListViewSet (get_queryset, perform_create,
get_active_project usage) unchanged.

In `@ui/src/pages/taxa-list-details/add-taxa-list-taxon/add-taxa-list-taxon.tsx`:
- Around line 34-36: Replace the hardcoded label 'Select a taxon' passed to the
TaxonSelect component's triggerLabel prop with a translated string using the app
i18n key (e.g., STRING.SELECT_A_TAXON) via the translation function (t).
Import/use the translation helper used in this file (or call useTranslation to
get t) and import the STRING constants if needed; if STRING.SELECT_A_TAXON does
not exist, add that key to the translation constants and translations. Update
the JSX so triggerLabel={taxon ? taxon.name : t(STRING.SELECT_A_TAXON)}.

In
`@ui/src/pages/taxa-list-details/remove-taxa-list-taxon/remove-taxa-list-taxon-dialog.tsx`:
- Around line 17-22: The dialog can briefly show stale success state if reopened
before the hook's onSuccess timeout clears it; update
remove-taxa-list-taxon-dialog.tsx to reset the mutation when the dialog opens by
calling the mutation's reset method (from useRemoveTaxaListTaxon) as soon as
isOpen becomes true (e.g., in an effect or the open handler) so
isSuccess/isLoading/error are cleared on open; if reset isn't exposed by the
hook, add and export a reset function from useRemoveTaxaListTaxon and use that
here.

In `@ui/src/pages/taxa-list-details/taxa-list-details.tsx`:
- Around line 40-48: The breadcrumb is using a hardcoded 'Loading...' string
inside the useEffect; update the useEffect that calls setDetailBreadcrumb (which
depends on taxaList and setDetailBreadcrumb) to use the existing translation
helper instead (call translate(STRING.LOADING_DATA) and append '...' like other
usages) when taxaList is falsy so the breadcrumb title is consistent with other
UI text.

---

Duplicate comments:
In `@ui/src/data-services/hooks/taxa-lists/useTaxaListDetails.ts`:
- Around line 17-20: useAuthorizedQuery should be typed with ServerTaxaList and
must include projectId in the cache key to avoid cross-project collisions;
ensure the call to useAuthorizedQuery<ServerTaxaList> uses queryKey:
[API_ROUTES.TAXA_LISTS, projectId, id] and the url still includes
?project_id=${projectId}, then remove the stray duplicate review/comment marker
left in this file so only the corrected query call remains (check
useAuthorizedQuery, API_ROUTES.TAXA_LISTS, projectId, id, ServerTaxaList).

In `@ui/src/pages/taxa-list-details/add-taxa-list-taxon/add-taxa-list-taxon.tsx`:
- Around line 44-45: The duplicate review comment can be removed—no code change
required because the double-submit guard is already implemented via the Button's
disabled prop (disabled={!taxon || isLoading}); keep that logic in
add-taxa-list-taxon.tsx and delete the redundant review comment or mark it
resolved so only one confirmation remains referencing the Button and the
taxon/isLoading guard.

In
`@ui/src/pages/taxa-list-details/remove-taxa-list-taxon/remove-taxa-list-taxon-dialog.tsx`:
- Around line 56-57: The disabled prop on the Button in
RemoveTaxaListTaxonDialog is correctly using disabled={isLoading || isSuccess}
to prevent double submissions; keep this logic in the Button component (the JSX
around Button in remove-taxa-list-taxon-dialog.tsx) and do not remove or alter
the isLoading or isSuccess checks so the mutation cannot be retriggered while
loading or after success.

In `@ui/src/pages/taxa-list-details/taxa-list-details.tsx`:
- Around line 69-71: The review is a duplicate noting the Add action is now
gated by taxaList?.canUpdate; remove the redundant/duplicate review comment and
keep the conditional render for AddTaxaListTaxonPopover (component
AddTaxaListTaxonPopover and the taxaList?.canUpdate check) as-is so only one
reviewers' note remains and no code changes are needed.
- Around line 79-81: Remove the stray "[duplicate_comment]" marker left in the
review/content and ensure the component props remain as: error={error},
isLoading={isLoading}, items={species}; specifically verify the isLoading prop
on the taxa list table (isLoading) isn’t inverted and delete the
duplicate_comment token from the PR comment to avoid confusion.

---

Nitpick comments:
In `@ami/main/api/views.py`:
- Around line 1687-1691: The list method currently returns the full queryset
bypassing DRF pagination; change it to call self.paginate_queryset(queryset) and
if that returns a page, serialize that page with self.get_serializer(page,
many=True) and return self.get_paginated_response(serializer.data), otherwise
fall back to the existing Response for the full queryset; update the method that
defines list in the view (the list method on this view class) to use
self.paginate_queryset, self.get_serializer, and self.get_paginated_response
accordingly.
- Around line 1648-1661: The current perform_create method silently skips
associating the new TaxaList when project is None even though require_project =
True makes get_active_project() raise Http404; remove the dead if guard and
directly call project = self.get_active_project() followed by
instance.projects.add(project) (or add an explicit assertion after
get_active_project to make the requirement explicit) so that missing project
conditions are not silently ignored; update perform_create, referencing
perform_create, serializer.save, get_active_project, and instance.projects.add.

In `@ui/src/pages/taxa-lists/taxa-lists.tsx`:
- Around line 15-16: Destructure `id` from `useParams()` is used as a
master/detail guard (see `TaxaLists` and the `isLoading={!id && isLoading}`
expression) to suppress the list table loading state when a detail route with
`:id` is active; add a short in-line comment next to the `const { projectId, id
} = useParams()` and/or the `isLoading` calculation explaining that `id` may be
present only in a split master-detail layout and is used intentionally to avoid
showing the list loading spinner when a detail view is rendered alongside the
list.

# Conflicts:
#	ami/main/tests.py
#	ami/ml/serializers.py
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
ami/ml/serializers.py (1)

161-168: create override is now dead code and can be removed.

Since "project" is no longer a declared serializer field, DRF will never include it in validated_data. The pop always returns None, the if project: branch is unreachable, and the project association is now handled by perform_create in the view. The entire override reduces to an unnecessary call to super().create(validated_data).

♻️ Proposed cleanup
-    def create(self, validated_data):
-        project = validated_data.pop("project", None)
-        instance = super().create(validated_data)
-
-        if project:
-            instance.projects.add(project)
-
-        return instance
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ami/ml/serializers.py` around lines 161 - 168, The create method override in
the serializer (def create(self, validated_data)) is dead code because "project"
is no longer a serializer field and project association is handled in the view's
perform_create; remove the entire create(self, validated_data) override
(including the pop, conditional add, and return) so the serializer uses the base
class implementation directly and avoid redundant code in the class.
ami/main/tests.py (1)

3448-3450: Suggest adding permission-enforcement tests.

All new tests authenticate as the project owner. Consider adding tests for:

  • Unauthenticated users (self.client.logout() / no force_authenticate) — expecting HTTP 401/403 on POST/DELETE.
  • Authenticated non-members — expecting HTTP 403 on write operations per IsProjectMemberOrReadOnly.
  • Cross-project access — taxa list belonging to a different project returning HTTP 404 (scoped by get_taxa_list()).

This ensures the permission layer added in TaxaListTaxonViewSet is actually exercised.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ami/main/tests.py` around lines 3448 - 3450, Add permission-enforcement tests
to TaxaListTaxonAPITestCase to exercise TaxaListTaxonViewSet and
IsProjectMemberOrReadOnly: add (1) an unauthenticated test that calls
POST/DELETE after self.client.logout() (or clearing force_authenticate) and
asserts HTTP 401/403, (2) an authenticated non-member test that creates a user
not on the project, authenticates them and asserts POST/DELETE return HTTP 403,
and (3) a cross-project test that targets a taxa list belonging to a different
project (use a different project/taxa_list fixture and call get_taxa_list()) and
asserts the view returns HTTP 404; reuse existing helper methods/setup in
TaxaListTaxonAPITestCase to create users/projects and endpoints when possible.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@ami/main/tests.py`:
- Line 3453: The hardcoded password in the test fixture (the call to
User.objects.create_user(...) in the setUp method) triggers Ruff S106; fix by
either replacing the literal with a shared test constant (e.g., TEST_PASSWORD)
used across tests or by adding a local suppression comment on the create_user
line (append "# noqa: S106"); apply the same change to the create_user call
inside TaxaListTaxonValidationTestCase.setUp so both occurrences are handled
consistently.
- Around line 3457-3458: Change the test fixture creation to use the uppercase
rank convention: replace the lowercase literal "species" used when creating
self.taxon1 and self.taxon2 via Taxon.objects.create(...) with the canonical
TaxonRank.SPECIES.name (i.e., "SPECIES") so ranks match the rest of the codebase
and satisfy test_rank_formatting assertions.

---

Nitpick comments:
In `@ami/main/tests.py`:
- Around line 3448-3450: Add permission-enforcement tests to
TaxaListTaxonAPITestCase to exercise TaxaListTaxonViewSet and
IsProjectMemberOrReadOnly: add (1) an unauthenticated test that calls
POST/DELETE after self.client.logout() (or clearing force_authenticate) and
asserts HTTP 401/403, (2) an authenticated non-member test that creates a user
not on the project, authenticates them and asserts POST/DELETE return HTTP 403,
and (3) a cross-project test that targets a taxa list belonging to a different
project (use a different project/taxa_list fixture and call get_taxa_list()) and
asserts the view returns HTTP 404; reuse existing helper methods/setup in
TaxaListTaxonAPITestCase to create users/projects and endpoints when possible.

In `@ami/ml/serializers.py`:
- Around line 161-168: The create method override in the serializer (def
create(self, validated_data)) is dead code because "project" is no longer a
serializer field and project association is handled in the view's
perform_create; remove the entire create(self, validated_data) override
(including the pop, conditional add, and return) so the serializer uses the base
class implementation directly and avoid redundant code in the class.

mihow and others added 4 commits February 17, 2026 17:09
Use "SPECIES" instead of "species" to match TaxonRank convention
used throughout the codebase.

Co-Authored-By: Claude <noreply@anthropic.com>
Match the permission pattern used by other project-scoped viewsets
(ProjectViewSet, DeploymentViewSet, SiteViewSet, DeviceViewSet).
Without this, non-staff project members cannot manage taxa lists.

Co-Authored-By: Claude <noreply@anthropic.com>
The project write field was removed from the serializer in favour of
server-side assignment in ProcessingServiceViewSet.perform_create().

Co-Authored-By: Claude <noreply@anthropic.com>
Tests were sending `project` as a POST field, but the write-only field
was removed from ProcessingServiceSerializer in favor of server-side
assignment via `project_id` query parameter. Also removes dead code in
the serializer's create() override and fixes Prettier formatting.

Co-Authored-By: Claude <noreply@anthropic.com>
@mihow
Copy link
Collaborator

mihow commented Feb 18, 2026

Merge plan & interaction with #1110 and #1133

Permission fix (latest commit)

TaxaListViewSet was using ObjectPermission, which silently denied all write operations for every user. Root cause: TaxaList uses a M2M projects field, so BaseModel.get_project() returns None, and check_permission() always returns False. Switched to IsProjectMemberOrReadOnly which resolves the project via ProjectMixin.get_active_project(). Added 10 permission tests covering member CRUD, anonymous read-only, non-member rejection, and owner access.

Recommended merge order

  1. This PR (Configurable taxa lists #1094) — mergeable now, permission fix is self-contained
  2. Require project_id on list endpoints to prevent full table scans #1133 (require project_id) — minor conflict on TaxaListViewSet (require_project = True here vs require_project_for_list = False there). Resolve by adopting require_project_for_list = False since taxa lists are global M2M resources.
  3. feat: Add model-level permissions framework (rebase test - no not merge) #1110 (model-level permissions) — has merge conflicts, stale since Jan 31. After it merges:
    • Switch TaxaListViewSet back to ObjectPermission (the new check_permission() will route M2M models through model-level checks)
    • Add create_taxalist, update_taxalist, delete_taxalist to the AuthorizedUser role definition — without this, taxa list creation will break for non-superusers

Inline review comments

I've added inline comments on the two lines that will need updates when #1110 and #1133 merge.

Copy link
Collaborator

@mihow mihow left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inline notes for future merge compatibility with #1110 and #1133.

"created_at",
"updated_at",
]
permission_classes = [IsProjectMemberOrReadOnly]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After #1110 merges: Switch back to ObjectPermission. The new check_permission() routes M2M models (where get_project_accessor() returns "projects") through check_model_level_permission(), which uses global Django permissions instead of guardian object-level perms.

This will work correctly only if create_taxalist, update_taxalist, delete_taxalist are added to the AuthorizedUser role definition in #1110. Without those, taxa list creation breaks for all non-superusers.

"updated_at",
]
permission_classes = [IsProjectMemberOrReadOnly]
require_project = True
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After #1133 merges: Change to require_project_for_list = False.

PR #1133 makes require_project_for_list = True the default on ProjectMixin, but opts out TaxaListViewSet because taxa lists are global M2M resources (same rationale as TaxonViewSet and TagViewSet). Keep require_project = True for write operations -- only listing should be allowed without a project filter.

ObjectPermission doesn't work for M2M-to-project models because
BaseModel.get_project() returns None when get_project_accessor()
returns "projects". This caused all write operations (create, update,
delete) on taxa lists to be denied for every user.

Switch to IsProjectMemberOrReadOnly which resolves the project via
ProjectMixin.get_active_project() (from query param) instead of
through the model instance. Add 10 permission tests covering member
CRUD, anonymous read-only, non-member rejection, and owner access.

Co-Authored-By: Claude <noreply@anthropic.com>
@mihow
Copy link
Collaborator

mihow commented Feb 18, 2026

Code review

Found 3 issues:

  1. convertToServerFieldValues sends project_id instead of project, breaking entity creation for Sites, Devices, Storage Sources, and SourceImageCollections. All four serializers declare a writable project PrimaryKeyRelatedField (not project_id), so POST requests from NewEntityDialog for these entity types will fail with 400 validation errors.

...(name ? { name } : {}),
project_id: projectId,
...(customFields ?? {}),

Affected serializers at serializers.py L1602, L1618, L1635, L1228 all use project = serializers.PrimaryKeyRelatedField(...).

  1. user_permissions will always be empty for non-superusers on TaxaList responses. TaxaListSerializer extends DefaultSerializer, which calls add_object_level_permissions() -> get_project(). Since TaxaList uses a M2M to Project (via projects), get_project() explicitly returns None for M2M accessors (ami/base/models.py L143). This means the frontend will never see write permissions for taxa lists, so edit/delete buttons may be incorrectly hidden.

class TaxaListSerializer(DefaultSerializer):
taxa = serializers.SerializerMethodField()

Root cause at get_project():

antenna/ami/base/models.py

Lines 142 to 144 in 4682933

accessor = self.get_project_accessor()
if accessor == "projects" or accessor is None:
return None

  1. TaxaListTaxonViewSet.list() bypasses DRF pagination entirely, returning all taxa in a single response without limit/offset support. It also evaluates the queryset twice (queryset.count() + serializer.data). Every other list endpoint in the codebase uses LimitOffsetPaginationWithPermissions via DefaultViewSet.

antenna/ami/main/api/views.py

Lines 1686 to 1691 in 4682933

def list(self, request, taxalist_pk=None):
"""List all taxa in the taxa list."""
queryset = self.get_queryset()
serializer = self.get_serializer(queryset, many=True)
return Response({"count": queryset.count(), "results": serializer.data})

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@mihow
Copy link
Collaborator

mihow commented Feb 18, 2026

Additional issues from code review

Two more issues to track:

4. user_permissions always empty for non-superusers on TaxaList responses

TaxaListSerializer extends DefaultSerializer, which calls add_object_level_permissions()instance.get_user_object_permissions()get_project(). Since TaxaList uses M2M to Project (projects), get_project() returns None (ami/base/models.py L142-144), so _get_object_perms() returns [] for all non-superusers.

The frontend gates edit/delete buttons on canUpdate/canDelete which read from user_permissions (taxa-list-columns.tsx L84-99), so non-superuser project members will never see those buttons even though IsProjectMemberOrReadOnly allows them to write at the API level.

5. TaxaListTaxonViewSet.list() bypasses DRF pagination

The list() method manually returns {"count": queryset.count(), "results": serializer.data} without calling self.paginate_queryset() or self.get_paginated_response(). It also evaluates the queryset twice (once for count(), once for serializer.data).

antenna/ami/main/api/views.py

Lines 1686 to 1691 in 4682933

def list(self, request, taxalist_pk=None):
"""List all taxa in the taxa list."""
queryset = self.get_queryset()
serializer = self.get_serializer(queryset, many=True)
return Response({"count": queryset.count(), "results": serializer.data})

Note: the frontend currently doesn't call this endpoint (it uses useSpecies with a taxa_list_id filter on /api/v2/taxa/ instead), so this is low-impact for now but inconsistent with every other list endpoint.

🤖 Generated with Claude Code

mihow and others added 4 commits February 18, 2026 16:13
convertToServerFieldValues was sending `project_id` in the POST body,
but the serializers for Sites, Devices, Storage Sources, and
SourceImageCollections all declare `project` as the field name. Also
pass project_id as a query param so IsProjectMemberOrReadOnly can
resolve the active project for permission checks.

Co-Authored-By: Claude <noreply@anthropic.com>
TaxaList had no permission entries in Project.Permissions, so
non-superuser members could never receive create/update/delete
permissions through the guardian role system. Adds create_taxalist,
update_taxalist, and delete_taxalist to Project.Permissions, the
Meta permissions list, and the ProjectManager role.

Co-Authored-By: Claude <noreply@anthropic.com>
Models with an M2M relationship to Project (like TaxaList) return None
from get_project(), causing the default permission resolver to return
an empty list. This meant non-superuser members never saw edit/delete
buttons despite having the correct role permissions.

Adds add_m2m_object_permissions() to ami/base/permissions.py, which
resolves permissions against the active project from the request
context. TaxaListSerializer now calls this instead of the default
path. The function is designed to be reused by other M2M-to-Project
serializers (Taxon, Pipeline, ProcessingService) as part of #1120.

Co-Authored-By: Claude <noreply@anthropic.com>
The UI fetches taxa for a list via the main /taxa/ endpoint with a
taxa_list_id filter, not the nested /taxa/lists/{id}/taxa/ list
endpoint. Remove the custom list() method and corresponding tests.
The viewset now only provides create (POST) and delete (DELETE).

Co-Authored-By: Claude <noreply@anthropic.com>
mihow and others added 2 commits February 27, 2026 00:31
Merge main into feat/taxa-lists. The only conflict was in ui/src/app.tsx
where the taxa-lists routes needed to coexist with the collections-to-
capture-sets rename from main. Dropped a stale CollectionDetails route
reference (component doesn't exist).

Co-Authored-By: Claude <noreply@anthropic.com>
Use STRING.SELECT_TAXON_PLACEHOLDER and STRING.LOADING_DATA instead of
hardcoded English strings in taxa list detail pages.

Co-Authored-By: Claude <noreply@anthropic.com>
@mihow
Copy link
Collaborator

mihow commented Feb 27, 2026

All 3 issues from this review are now resolved:

  1. project_idproject — Fixed in 312e362 (fix: send project instead of project_id in entity creation requests)
  2. user_permissions empty for M2M models — Fixed in 3d2eb1e (fix: resolve user_permissions for M2M-to-Project models)
  3. TaxaListTaxonViewSet.list() bypasses pagination — Removed in d0c8c24 (refactor: remove unused list endpoint from TaxaListTaxonViewSet)

@mihow
Copy link
Collaborator

mihow commented Feb 27, 2026

Both issues from this comment are also resolved:

4. user_permissions always empty for non-superusers — Fixed in 3d2eb1e via add_m2m_object_permissions() in TaxaListSerializer.get_permissions(), which resolves the project from the request context instead of relying on get_project().

5. TaxaListTaxonViewSet.list() bypasses DRF pagination — The endpoint was removed entirely in d0c8c24 since the frontend uses useSpecies with a taxa_list_id filter on /api/v2/taxa/ instead.

Copy link
Collaborator

@mihow mihow left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested once more with different user roles and I think it's ready to go!

@mihow mihow merged commit 0487cf9 into main Feb 27, 2026
7 of 9 checks passed
@mihow mihow deleted the feat/taxa-lists branch February 27, 2026 09:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants