Skip to content
Open
Show file tree
Hide file tree
Changes from 32 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
fc68985
feat: setup new view for taxa lists
annavik Jan 14, 2026
bd2d08d
feat: make it possible to create new taxa lists
annavik Jan 14, 2026
e9d79dd
feat: make it possible to edit taxa list details
annavik Jan 14, 2026
9a23bf5
feat: set breadcrumb label for taxa list page
annavik Jan 14, 2026
466dc2c
fix: cleanup
annavik Jan 14, 2026
24d8fe5
Add sorting, timestamps, and taxa count to TaxaList API (#1082)
Copilot Jan 15, 2026
08babab
feat: add taxa count columns and more sort options
annavik Jan 16, 2026
b126349
feat: setup form for adding taxon to taxa list
annavik Jan 16, 2026
279dd4c
feat: make "Taxa lists" available from sidebar
annavik Jan 19, 2026
0e8a09f
fix: conditionally render taxa list actions based on user permissions
annavik Jan 19, 2026
4aec469
feat: setup new page for taxa list details
annavik Jan 20, 2026
4bfec3d
fix: tweak breadcrumb logic to show sidebar sub pages
annavik Jan 20, 2026
e73ff00
fix: add taxa list filter
annavik Jan 20, 2026
3fdbbd8
feat: added add_taxon and remove_taxon endpoints to TaxaListViewSet
mohamedelabbas1996 Jan 22, 2026
6778081
Merge branch 'main' into feat/taxa-lists
annavik Jan 23, 2026
f9a5378
feat: make it possible to add taxa to lists from UI
annavik Jan 23, 2026
30459e0
feat: make it possible to remove taxa from lists from UI
annavik Jan 23, 2026
fea81bb
feat: make it possible to see taxon details without changing route
annavik Jan 23, 2026
1d3e7a5
fix: cleanup
annavik Jan 23, 2026
84cb25b
fix: hide remove button if user cannot update taxa list
annavik Jan 23, 2026
dffa27d
fix: prevent tooltip auto focus in dialogs
annavik Jan 23, 2026
1f5e5d4
refactor: use nested routes for taxa list management (without through…
mihow Jan 28, 2026
6aaf0ea
fix: taxa list creation failing with m2m assignment error
mihow Feb 4, 2026
c591ae3
feat: new param to hide/show taxa children when filtering by taxa list
mihow Feb 4, 2026
bf3f304
feat: hide taxa children by default in the taxa management view
mihow Feb 4, 2026
4892c11
chore: remove "by-taxon" url prefix
mihow Feb 4, 2026
a86308f
Merge pull request #1104 from RolnickLab/feat/taxa-lists-through-model
mihow Feb 4, 2026
8b8cdcc
Merge branch 'main' into feat/taxa-lists
mihow Feb 4, 2026
e68e47f
fix: move new taxa list tests in with the others for now
mihow Feb 4, 2026
17cf37e
fix: pass project ID with delete requests
annavik Feb 6, 2026
2716251
fix: address taxa-list PR review feedback (#1119)
mihow Feb 18, 2026
05f60f0
Merge remote-tracking branch 'origin/main' into feat/taxa-lists
mihow Feb 18, 2026
d947756
fix: use uppercase rank values in taxa list tests
mihow Feb 18, 2026
4cd067f
fix: add ObjectPermission to TaxaListViewSet
mihow Feb 18, 2026
d19885b
chore: note dead code in ProcessingServiceSerializer.create()
mihow Feb 18, 2026
8f5144e
fix: update ProcessingService tests to use project_id query param
mihow Feb 18, 2026
4682933
fix: use IsProjectMemberOrReadOnly for TaxaListViewSet permissions
mihow Feb 18, 2026
312e362
fix: send project instead of project_id in entity creation requests
mihow Feb 19, 2026
73f6e08
feat: add TaxaList CRUD permissions for project members
mihow Feb 19, 2026
3d2eb1e
fix: resolve user_permissions for M2M-to-Project models
mihow Feb 19, 2026
d0c8c24
refactor: remove unused list endpoint from TaxaListTaxonViewSet
mihow Feb 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions ami/base/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,35 @@ def add_collection_level_permissions(user: User | None, response_data: dict, mod
return response_data


class IsProjectMemberOrReadOnly(permissions.BasePermission):
"""
Safe methods are allowed for everyone.
Unsafe methods (POST, PUT, PATCH, DELETE) require the requesting user to be
a member of the active project (resolved via ProjectMixin.get_active_project).
"""

def has_permission(self, request, view):
if request.method in permissions.SAFE_METHODS:
return True

if not request.user or not request.user.is_authenticated:
return False

if request.user.is_superuser: # type: ignore[union-attr]
return True

# view must provide get_active_project (i.e. use ProjectMixin)
get_active_project = getattr(view, "get_active_project", None)
if not get_active_project:
return False

project = get_active_project()
if not project:
return False

return project.members.filter(pk=request.user.pk).exists()


class ObjectPermission(permissions.BasePermission):
"""
Generic permission class that delegates to the model's `check_permission(user, action)` method.
Expand Down
48 changes: 45 additions & 3 deletions ami/main/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -633,13 +633,23 @@ def get_occurrences(self, obj):
)


class TaxaListSerializer(serializers.ModelSerializer):
class TaxaListSerializer(DefaultSerializer):
taxa = serializers.SerializerMethodField()
projects = serializers.PrimaryKeyRelatedField(queryset=Project.objects.all(), many=True)
taxa_count = serializers.SerializerMethodField()
projects = serializers.SerializerMethodField()

class Meta:
model = TaxaList
fields = ["id", "name", "description", "taxa", "projects"]
fields = [
"id",
"name",
"description",
"taxa",
"taxa_count",
"projects",
"created_at",
"updated_at",
]

def get_taxa(self, obj):
"""
Expand All @@ -651,6 +661,38 @@ def get_taxa(self, obj):
params={"taxa_list_id": obj.pk},
)

def get_taxa_count(self, obj):
"""
Return the number of taxa in this list.
Uses annotated_taxa_count if available (from ViewSet) for performance.
"""
return getattr(obj, "annotated_taxa_count", obj.taxa.count())

def get_projects(self, obj):
"""
Return list of project IDs this taxa list belongs to.
This is read-only and managed by the server.
"""
return list(obj.projects.values_list("id", flat=True))


class TaxaListTaxonInputSerializer(serializers.Serializer):
"""Serializer for adding a taxon to a taxa list."""

taxon_id = serializers.IntegerField(required=True)

def validate_taxon_id(self, value):
"""Validate that the taxon exists."""
if not Taxon.objects.filter(id=value).exists():
raise serializers.ValidationError("Taxon does not exist.")
return value


class TaxaListTaxonSerializer(TaxonNoParentNestedSerializer):
"""Serializer for taxa in a taxa list (simplified taxon representation)."""

pass


class CaptureTaxonSerializer(DefaultSerializer):
parent = TaxonNoParentNestedSerializer(read_only=True)
Expand Down
128 changes: 119 additions & 9 deletions ami/main/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from django.db.models.functions import Coalesce
from django.db.models.query import QuerySet
from django.forms import BooleanField, CharField, IntegerField
from django.shortcuts import get_object_or_404
from django.utils import timezone
from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.types import OpenApiTypes
Expand All @@ -26,7 +27,7 @@
from ami.base.filters import NullsLastOrderingFilter, ThresholdFilter
from ami.base.models import BaseQuerySet
from ami.base.pagination import LimitOffsetPaginationWithPermissions
from ami.base.permissions import IsActiveStaffOrReadOnly, ObjectPermission
from ami.base.permissions import IsActiveStaffOrReadOnly, IsProjectMemberOrReadOnly, ObjectPermission
from ami.base.serializers import FilterParamsSerializer, SingleParamSerializer
from ami.base.views import ProjectMixin
from ami.main.api.schemas import project_id_doc_param
Expand Down Expand Up @@ -83,6 +84,8 @@
StorageSourceSerializer,
StorageStatusSerializer,
TaxaListSerializer,
TaxaListTaxonInputSerializer,
TaxaListTaxonSerializer,
TaxonListSerializer,
TaxonSearchResultSerializer,
TaxonSerializer,
Expand Down Expand Up @@ -1261,11 +1264,15 @@ def list(self, request, *args, **kwargs):

class TaxonTaxaListFilter(filters.BaseFilterBackend):
"""
Filters taxa based on a TaxaList Similar to `OccurrenceTaxaListFilter`.
Filters taxa based on a TaxaList.

Queries for all taxa that are either:
- Directly in the requested TaxaList.
- A descendant (child or deeper) of any taxon in the TaxaList, recursively.
By default, queries for taxa that are directly in the TaxaList and their descendants.
If include_descendants=false, only taxa directly in the TaxaList are returned.

Query parameters:
- taxa_list_id: ID of the taxa list to filter by
- include_descendants: Set to 'false' to exclude descendants (default: true)
- not_taxa_list_id: ID of taxa list to exclude
"""

query_param = "taxa_list_id"
Expand All @@ -1277,11 +1284,20 @@ def filter_queryset(self, request, queryset, view):
request.query_params.get(self.query_param_exclusive)
)

include_descendants_default = True
include_descendants = request.query_params.get("include_descendants", include_descendants_default)
if include_descendants is not None:
include_descendants = BooleanField(required=False).clean(include_descendants)

def _get_filter(taxa_list: TaxaList) -> models.Q:
taxa = taxa_list.taxa.all() # Get taxa in the taxa list
query_filter = Q(id__in=taxa)
for taxon in taxa:
query_filter |= Q(parents_json__contains=[{"id": taxon.pk}])

# Only include descendants if explicitly requested
if include_descendants:
for taxon in taxa:
query_filter |= Q(parents_json__contains=[{"id": taxon.pk}])

return query_filter

if taxalist_id:
Expand Down Expand Up @@ -1608,17 +1624,111 @@ def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)


class TaxaListViewSet(viewsets.ModelViewSet, ProjectMixin):
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
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.


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)

Comment on lines 1627 to 1660
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?


class TaxaListTaxonViewSet(viewsets.GenericViewSet, ProjectMixin):
"""
Nested ViewSet for managing taxa in a taxa list.
Accessed via /taxa/lists/{taxa_list_id}/taxa/
"""

serializer_class = TaxaListTaxonSerializer
permission_classes = [IsProjectMemberOrReadOnly]
require_project = True

def get_taxa_list(self):
"""Get the parent taxa list from URL parameters, scoped to the active project."""
taxa_list_id = self.kwargs.get("taxalist_pk")
project = self.get_active_project()
try:
return TaxaList.objects.get(pk=taxa_list_id, projects=project)
except TaxaList.DoesNotExist:
raise api_exceptions.NotFound("Taxa list not found.") from None

def get_queryset(self):
"""Return taxa in the specified taxa list."""
taxa_list = self.get_taxa_list()
return taxa_list.taxa.all()

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})

def create(self, request, taxalist_pk=None):
"""Add a taxon to the taxa list."""
taxa_list = self.get_taxa_list()

# Validate input
input_serializer = TaxaListTaxonInputSerializer(data=request.data)
input_serializer.is_valid(raise_exception=True)
taxon_id = input_serializer.validated_data["taxon_id"]

# Check if already exists
if taxa_list.taxa.filter(pk=taxon_id).exists():
return Response(
{"non_field_errors": ["Taxon is already in this taxa list."]},
status=status.HTTP_400_BAD_REQUEST,
)

# Add taxon
taxon = get_object_or_404(Taxon, pk=taxon_id)
taxa_list.taxa.add(taxon)

# Return the added taxon
serializer = self.get_serializer(taxon)
return Response(serializer.data, status=status.HTTP_201_CREATED)

@action(detail=False, methods=["delete"], url_path=r"(?P<taxon_id>\d+)")
def delete_by_taxon(self, request, taxalist_pk=None, taxon_id=None):
"""
Remove a taxon from the taxa list by taxon ID.
DELETE /taxa/lists/{taxa_list_id}/taxa/{taxon_id}/
"""
taxa_list = self.get_taxa_list()

# Check if taxon exists in list
if not taxa_list.taxa.filter(pk=taxon_id).exists():
raise api_exceptions.NotFound("Taxon is not in this taxa list.")

# Remove taxon
taxa_list.taxa.remove(taxon_id)
return Response(status=status.HTTP_204_NO_CONTENT)


class TagViewSet(DefaultViewSet, ProjectMixin):
Expand Down
Loading
Loading