Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 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
b21acfd
test: add Playwright e2e tests for taxa lists
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
67 changes: 67 additions & 0 deletions ami/base/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,73 @@ def add_collection_level_permissions(user: User | None, response_data: dict, mod
return response_data


def add_m2m_object_permissions(user, instance, project, response_data: dict) -> dict:
"""
Add object-level permissions for models with an M2M relationship to Project.

The default permission resolution (BaseModel._get_object_perms) relies on
get_project(), which returns None for M2M-to-Project models (TaxaList, etc.)
because there's no single owning project. This function resolves permissions
against a specific project from the request context instead.

Validates that the instance actually belongs to the given project before
granting any permissions (prevents cross-project permission leaks).

This is a temporary approach for the M2M permission gap described in #1120.
Once that issue is resolved, this should be replaced by a generic permission
class (Pattern B: Bare M2M) that handles TaxaList, Taxon, ProcessingService,
Pipeline, and other M2M-to-Project models uniformly.
"""
perms = set(response_data.get("user_permissions", []))

if not project or not instance.projects.filter(pk=project.pk).exists():
response_data["user_permissions"] = list(perms)
return response_data

if user.is_superuser:
perms.update(["update", "delete"])
else:
model_name = instance._meta.model_name
all_perms = get_perms(user, project)
for perm in all_perms:
if perm.endswith(f"_{model_name}"):
action = perm.split("_", 1)[0]
if action in {"update", "delete"}:
perms.add(action)

response_data["user_permissions"] = list(perms)
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
54 changes: 51 additions & 3 deletions ami/main/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from rest_framework.request import Request

from ami.base.fields import DateStringField
from ami.base.permissions import add_m2m_object_permissions
from ami.base.serializers import DefaultSerializer, MinimalNestedModelSerializer, reverse_with_params
from ami.base.views import get_active_project
from ami.jobs.models import Job
Expand Down Expand Up @@ -633,13 +634,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 +662,43 @@ 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_permissions(self, instance, instance_data):
request = self.context["request"]
project = get_active_project(request=request)
return add_m2m_object_permissions(request.user, instance, project, instance_data)

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
124 changes: 115 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,107 @@ 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",
]
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.

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.
"""
instance = serializer.save()
project = self.get_active_project()
if project:
instance.projects.add(project)


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

Only provides create (POST) and delete (DELETE) actions.
The UI lists taxa via the main /taxa/ endpoint with a taxa_list_id filter.
"""

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