Skip to content

Configurable taxa lists#1094

Open
annavik wants to merge 42 commits intomainfrom
feat/taxa-lists
Open

Configurable taxa lists#1094
annavik wants to merge 42 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 ✅

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 failed. Why did it fail? →

Name Link
🔨 Latest commit b21acfd
🔍 Latest deploy log https://app.netlify.com/projects/antenna-preview/deploys/699663f5c4a2850008d82308

@netlify
Copy link

netlify bot commented Jan 20, 2026

Deploy Preview for antenna-ssec failed. Why did it fail? →

Name Link
🔨 Latest commit b21acfd
🔍 Latest deploy log https://app.netlify.com/projects/antenna-ssec/deploys/699663f50146b50008e24504

@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 | ✅ 3
✅ Passed checks (3 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.
Description check ✅ Passed The PR description is well-structured with background context, a clear checklist of completed backend and frontend work, and detailed notes on follow-up improvements. It includes screenshots and addresses the template's main sections.
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 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

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: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
ami/ml/views.py (1)

150-190: ⚠️ Potential issue | 🟠 Major

Prevent orphan ProcessingService records by validating project before database persistence.

With require_project = True, get_active_project() raises Http404 when the project is missing or invalid. However, serializer.save() is called first, persisting the ProcessingService record to the database. If get_active_project() then raises an exception, an orphan record remains in the database without project assignment.

Proposed fix
-        instance = serializer.save()
-        project = self.get_active_project()
+        project = self.get_active_project()
+        instance = serializer.save()
         if project:
             instance.projects.add(project)
🤖 Fix all issues with AI agents
In `@ami/main/api/views.py`:
- Around line 1691-1713: The create method may raise Taxon.DoesNotExist between
validation and Taxon.objects.get; to fix, catch that race by replacing or
wrapping the Taxon.objects.get(pk=taxon_id) call: use Django's
get_object_or_404(Taxon, pk=taxon_id) or try/except around Taxon.objects.get to
return a 404 Response when not found, then proceed to taxa_list.taxa.add(taxon)
and return self.get_serializer(taxon).data; update references in this method
(create, TaxaListTaxonInputSerializer, Taxon.objects.get, taxa_list.taxa.add,
get_serializer) accordingly.
- Around line 1662-1670: The class TaxaListTaxonViewSet currently sets
permission_classes = [], allowing unrestricted access; remove this override so
it inherits the default permissions (from DefaultViewSet/TaxaListViewSet) or
explicitly set permission_classes to the same tuple used by TaxaListViewSet
(e.g., [IsAuthenticated, <project-specific update permission class>); also
ensure the create() and destroy() handlers in TaxaListTaxonViewSet perform the
"can update" check (reuse the same permission check or call the parent/utility
that enforces the taxa-list "can_update" permission) so add explicit permission
checks if they aren’t already present.

In `@ui/src/data-services/hooks/taxa-lists/useTaxaListDetails.ts`:
- Around line 17-20: Change the query generic to match the server payload and
include projectId in the cache key: update the useAuthorizedQuery generic from
<TaxaList> to <ServerTaxaList> (the hook receives ServerTaxaList JSON which is
then converted via convertServerRecord) and add projectId to the queryKey array
passed to useAuthorizedQuery (alongside API_ROUTES.TAXA_LISTS and id) to prevent
cache collisions across projects.

In `@ui/src/data-services/models/entity.ts`:
- Around line 21-28: The createdAt getter incorrectly checks
this._data.updated_at before formatting; change the condition to check
this._data.created_at instead so that createdAt returns undefined only when
created_at is missing, and continue to call getFormatedDateTimeString with new
Date(this._data.created_at) inside the createdAt getter to format the existing
timestamp.

In `@ui/src/pages/species/species.tsx`:
- Around line 197-204: The onOpenAutoFocus handler on Dialog.Content currently
calls e.preventDefault(), which prevents focus from moving into the modal;
update that handler so after calling e.preventDefault() you manually transfer
focus into the dialog (e.g., call focus() on the Dialog.Content element or its
dialogRef/current DOM node, or focus its first focusable child) so keyboard
focus lands inside the modal; locate the onOpenAutoFocus prop on Dialog.Content
and implement the manual focus transfer (using e.currentTarget.focus() or
dialogRef.current.focus()/focusFirstDescendant) immediately after
preventDefault().

In `@ui/src/pages/taxa-list-details/add-taxa-list-taxon/add-taxa-list-taxon.tsx`:
- Around line 44-55: The Add button currently only checks for taxon presence, so
rapid clicks can fire duplicate addTaxaListTaxon calls; update the button to
also disable while the mutation is in flight (e.g., add disabled={!taxon ||
isAdding}), derive isAdding from the mutation's loading flag returned by the
hook that provides addTaxaListTaxon (use the isLoading/isMutating boolean from
the useMutation or API hook), and add a defensive guard at the top of the
onClick handler (if (isAdding) return) before awaiting addTaxaListTaxon({
taxaListId, taxonId: taxon.id }) to ensure a single inflight request; keep the
existing setTimeout(setTaxon...) behavior unchanged.

In
`@ui/src/pages/taxa-list-details/remove-taxa-list-taxon/remove-taxa-list-taxon-dialog.tsx`:
- Around line 56-60: The confirm Button currently only uses isSuccess to
disable, so rapid clicks can trigger duplicate requests; update the Button's
disabled prop to also check the mutation's in-flight state (e.g., isLoading or
isPending) returned by the removeTaxaListTaxon hook. Concretely, modify the
disabled expression to something like disabled={isSuccess || isLoading} (or
isPending) so the Button is disabled while removeTaxaListTaxon({ taxaListId,
taxonId }) is executing; ensure you reference the mutation status variable you
already get from the hook alongside isSuccess.

In `@ui/src/pages/taxa-list-details/taxa-list-details.tsx`:
- Around line 60-69: The AddTaxaListTaxonPopover is always rendered even when
the list is read-only; update the JSX to only render AddTaxaListTaxonPopover
when taxaList?.canUpdate is truthy (same gating used for remove), e.g.
conditionally render AddTaxaListTaxonPopover using the taxaList?.canUpdate flag
so the "Add taxon" action is hidden for read-only lists; ensure you reference
the existing props/ids (AddTaxaListTaxonPopover taxaListId={id as string}) and
leave SortControl unchanged.
- Around line 71-79: The Table's isLoading prop uses an inverted guard (!id &&
isLoading) so the loading state never shows when an id exists; update the
isLoading prop on the Table (in taxa-list-details.tsx where Table is rendered)
to a correct expression such as isLoading={isLoading || !id} so the table shows
loading when data is loading or when the id is not yet available (reference the
Table component and the id and isLoading variables).
🧹 Nitpick comments (6)
ui/src/pages/project/entities/new-entity-dialog.tsx (1)

2-2: Remove unused import.

API_ROUTES is imported but never used in this file.

♻️ Proposed fix
 import classNames from 'classnames'
-import { API_ROUTES } from 'data-services/constants'
 import { useCreateEntity } from 'data-services/hooks/entities/useCreateEntity'
ui/src/components/breadcrumbs/breadcrumbs.tsx (1)

24-32: Logic change approved; consider adding setMainBreadcrumb to dependency array.

The inverted condition correctly prevents setting mainBreadcrumb for the 'project' item. Note that setMainBreadcrumb is missing from the dependency array. While context setters are typically stable, adding it ensures exhaustive deps compliance.

♻️ Proposed fix
-  }, [navItems, activeNavItem])
+  }, [navItems, activeNavItem, setMainBreadcrumb])
ui/src/data-services/hooks/taxa-lists/useAddTaxaListTaxon.ts (1)

35-35: Minor inconsistency: reset is returned here but not in useRemoveTaxaListTaxon.

This hook returns reset in its return object, but the sibling hook useRemoveTaxaListTaxon does not. Consider aligning the return signatures for consistency.

ui/src/pages/project/sidebar/useSidebarSections.tsx (1)

147-150: Remove dead code.

This expression computes a value but doesn't assign or return it. It appears to be leftover debug code or an incomplete refactor.

🧹 Proposed fix
-  sidebarSections
-    .map(({ items }) => items)
-    .flat()
-    .find((item) => !!matchPath(item.path, location.pathname))
-
   return { sidebarSections, activeItem }
ami/ml/serializers.py (1)

133-158: Consider prefetching projects to avoid N+1 on list endpoints.

SerializerMethodField accesses obj.projects per instance; without prefetching in the viewset, list responses can trigger extra queries. Ensure the queryset prefetches this relation (or annotate IDs) to keep list performance stable.

ami/main/api/views.py (1)

1672-1678: Use raise ... from for proper exception chaining.

When re-raising as a different exception type, use from to preserve the original traceback for debugging.

♻️ Proposed fix
     def get_taxa_list(self):
         """Get the parent taxa list from URL parameters."""
         taxa_list_id = self.kwargs.get("taxalist_pk")
         try:
             return TaxaList.objects.get(pk=taxa_list_id)
         except TaxaList.DoesNotExist:
-            raise api_exceptions.NotFound("Taxa list not found.")
+            raise api_exceptions.NotFound("Taxa list not found.") from None

Comment on lines 44 to 55
<Button
disabled={!taxon}
onClick={async () => {
if (taxon) {
await addTaxaListTaxon({ taxaListId, taxonId: taxon.id })
setTimeout(() => setTaxon(undefined), SUCCESS_TIMEOUT)
}
}}
size="small"
variant="success"
>
<span>{translate(STRING.ADD)}</span>
Copy link
Contributor

@coderabbitai coderabbitai bot Feb 4, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Disable the Add action while the mutation is in flight.

Right now the button is enabled during loading, so rapid clicks can enqueue duplicate add requests.

🛠️ Proposed fix
-            disabled={!taxon}
+            disabled={!taxon || isLoading}
🤖 Prompt for AI Agents
In `@ui/src/pages/taxa-list-details/add-taxa-list-taxon/add-taxa-list-taxon.tsx`
around lines 44 - 55, The Add button currently only checks for taxon presence,
so rapid clicks can fire duplicate addTaxaListTaxon calls; update the button to
also disable while the mutation is in flight (e.g., add disabled={!taxon ||
isAdding}), derive isAdding from the mutation's loading flag returned by the
hook that provides addTaxaListTaxon (use the isLoading/isMutating boolean from
the useMutation or API hook), and add a defensive guard at the top of the
onClick handler (if (isAdding) return) before awaiting addTaxaListTaxon({
taxaListId, taxonId: taxon.id }) to ensure a single inflight request; keep the
existing setTimeout(setTaxon...) behavior unchanged.

Copy link
Collaborator

Choose a reason for hiding this comment

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

@annavik there are a couple suggestions here about guarding against double form submissions. I wonder if we can implement that in general - I was able to do it for new jobs with the keyboard.

Copy link
Contributor

Choose a reason for hiding this comment

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

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

@mihow
Copy link
Collaborator

mihow commented Feb 4, 2026

We are going to need an import option after this too :)

@mihow
Copy link
Collaborator

mihow commented Feb 5, 2026

Hm, I guess we are not ready for the project => project_id change. I thought we did most of that last year.

Here are some fixes in response to the other feedback: #1119

@annavik
Copy link
Member Author

annavik commented Feb 6, 2026

We are going to need an import option after this too :)

Yes!

@annavik
Copy link
Member Author

annavik commented Feb 6, 2026

Hm, I guess we are not ready for the project => project_id change. I thought we did most of that last year.

Here are some fixes in response to the other feedback: #1119

I

@annavik I merged the API restructuring (#1104) to this branch! I left some comments there about the specific changes, but I figure it's time to bring the conversation back to this branch :)

Maybe one final test & look-over?

Thanks for the updates! I tested everything in this branch, I found one small thing needed related to project_id and pushed a fix. After that, works like a charm 👌

For the PR to address feedback, see comments: #1119

* 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.

Comment on lines 1627 to 1661
class TaxaListViewSet(DefaultViewSet, ProjectMixin):
queryset = TaxaList.objects.all()
serializer_class = TaxaListSerializer
ordering_fields = [
"name",
"description",
"annotated_taxa_count",
"created_at",
"updated_at",
]
require_project = True

def get_queryset(self):
qs = super().get_queryset()
# Annotate with taxa count for better performance
qs = qs.annotate(annotated_taxa_count=models.Count("taxa"))
project = self.get_active_project()
if project:
return qs.filter(projects=project)
return qs

serializer_class = TaxaListSerializer
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)

Copy link
Contributor

@coderabbitai coderabbitai bot Feb 18, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

TaxaListViewSet inherits IsActiveStaffOrReadOnly — inconsistent with other project-scoped viewsets.

Every other project-scoped viewset in this file (ProjectViewSet, DeploymentViewSet, SourceImageViewSet, SourceImageCollectionViewSet, SiteViewSet, DeviceViewSet) explicitly sets permission_classes = [ObjectPermission]. TaxaListViewSet inherits the default IsActiveStaffOrReadOnly from DefaultViewSetMixin, which means only staff users can create/edit/delete taxa lists — regular project members cannot.

Given the PR objective of scoping taxa list management to project members, consider adding permission_classes = [ObjectPermission] (or [IsProjectMemberOrReadOnly]) to match the rest of the project-scoped viewsets:

Proposed fix
 class TaxaListViewSet(DefaultViewSet, ProjectMixin):
     queryset = TaxaList.objects.all()
     serializer_class = TaxaListSerializer
     ordering_fields = [
         "name",
         "description",
         "annotated_taxa_count",
         "created_at",
         "updated_at",
     ]
+    permission_classes = [IsProjectMemberOrReadOnly]
     require_project = True
#!/bin/bash
# Verify permission_classes across project-scoped viewsets
rg -n 'permission_classes' ami/main/api/views.py
🧰 Tools
🪛 Ruff (0.15.1)

[warning] 1630-1636: Mutable default value for class attribute

(RUF012)

🤖 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 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.

Copy link
Contributor

Choose a reason for hiding this comment

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

@mihow I think there might be some crossed wires here!

This comment thread is about the permission classes on TaxaListViewSet (lines 1627-1661 in ami/main/api/views.py). The bot flagged that it inherits IsActiveStaffOrReadOnly from DefaultViewSet, which means only staff users can create/edit/delete taxa lists — whereas other project-scoped viewsets like DeploymentViewSet, SiteViewSet, etc. use permission_classes = [ObjectPermission] to allow project members to manage them.

Your response about lowercase rank="species" in tests sounds like it's for a different comment thread (possibly about test file changes).

For this permissions issue: do you want to update TaxaListViewSet to use ObjectPermission or IsProjectMemberOrReadOnly to match the other project-scoped viewsets, or is there a reason to keep the staff-only restriction?

Comment on lines +17 to +22
}) => {
const { projectId } = useParams()
const [isOpen, setIsOpen] = useState(false)
const { removeTaxaListTaxon, isLoading, isSuccess, error } =
useRemoveTaxaListTaxon(projectId as string)
const errorMessage = useFormError({ error })
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find and examine the Dialog implementation
fd "dialog.tsx" ui/src/design-system --exec cat {}

Repository: RolnickLab/antenna

Length of output: 2157


🏁 Script executed:

#!/bin/bash
# Examine the remove-taxa-list-taxon-dialog.tsx file
cat ui/src/pages/taxa-list-details/remove-taxa-list-taxon/remove-taxa-list-taxon-dialog.tsx

Repository: RolnickLab/antenna

Length of output: 2651


🏁 Script executed:

#!/bin/bash
# Find and examine the useRemoveTaxaListTaxon hook
fd "useRemoveTaxaListTaxon" ui/src --exec cat {}

Repository: RolnickLab/antenna

Length of output: 1126


🏁 Script executed:

#!/bin/bash
# Find the SUCCESS_TIMEOUT constant value
rg "SUCCESS_TIMEOUT" ui/src/data-services/constants --max-count=5 -A 2 -B 2

Repository: RolnickLab/antenna

Length of output: 132


🏁 Script executed:

#!/bin/bash
# Find the constants file
fd "constants" --type f --path "*/data-services/*" | head -20

Repository: RolnickLab/antenna

Length of output: 295


🏁 Script executed:

#!/bin/bash
# Find the constants file
fd "constants" --type f | grep "data-services"

Repository: RolnickLab/antenna

Length of output: 95


🏁 Script executed:

#!/bin/bash
# Check the SUCCESS_TIMEOUT constant value
rg "SUCCESS_TIMEOUT" ui/src/data-services/constants.ts -A 2 -B 2

Repository: RolnickLab/antenna

Length of output: 208


Dialog content unmounts on close; state resets automatically after 1 second.

The custom Dialog.Content wraps Radix UI's Dialog.Portal and Dialog.Content, which unmounts children when the dialog closes. Additionally, useRemoveTaxaListTaxon includes automatic state reset via setTimeout(reset, SUCCESS_TIMEOUT) in the onSuccess callback (1-second timeout), so stale "Confirmed" state does not persist across typical dialog open/close cycles.

A narrow edge case remains: if a user closes and immediately reopens the dialog within ~1 second before the automatic reset fires, they could briefly see stale success state. If UX polish is desired, consider resetting mutation state when the dialog opens.

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

In
`@ui/src/pages/taxa-list-details/remove-taxa-list-taxon/remove-taxa-list-taxon-dialog.tsx`
around lines 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.

# 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 5 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>
End-to-end tests covering the full taxa list CRUD workflow as a
ProjectManager member: create, view detail, add/remove taxa, rename,
and delete. Also verifies that permission-gated UI elements (create
button, edit/delete row buttons, add-taxon button) are visible for
project members.

Co-Authored-By: Claude <noreply@anthropic.com>
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.

4 participants

Comments