diff --git a/ami/base/permissions.py b/ami/base/permissions.py index 3c4b6c053..247d5e5d4 100644 --- a/ami/base/permissions.py +++ b/ami/base/permissions.py @@ -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. diff --git a/ami/main/api/serializers.py b/ami/main/api/serializers.py index d49a414a5..1a7265b1d 100644 --- a/ami/main/api/serializers.py +++ b/ami/main/api/serializers.py @@ -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 @@ -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): """ @@ -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) diff --git a/ami/main/api/views.py b/ami/main/api/views.py index 9a2770ac8..df29946f5 100644 --- a/ami/main/api/views.py +++ b/ami/main/api/views.py @@ -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 @@ -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 @@ -83,6 +84,8 @@ StorageSourceSerializer, StorageStatusSerializer, TaxaListSerializer, + TaxaListTaxonInputSerializer, + TaxaListTaxonSerializer, TaxonListSerializer, TaxonSearchResultSerializer, TaxonSerializer, @@ -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" @@ -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: @@ -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] + 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. + """ + 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\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): diff --git a/ami/main/migrations/0082_add_taxalist_permissions.py b/ami/main/migrations/0082_add_taxalist_permissions.py new file mode 100644 index 000000000..2e233e65f --- /dev/null +++ b/ami/main/migrations/0082_add_taxalist_permissions.py @@ -0,0 +1,71 @@ +# Generated by Django 4.2.10 on 2026-02-18 19:24 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0081_s3storagesource_region"), + ] + + operations = [ + migrations.AlterModelOptions( + name="project", + options={ + "ordering": ["-priority", "created_at"], + "permissions": [ + ("create_identification", "Can create identifications"), + ("update_identification", "Can update identifications"), + ("delete_identification", "Can delete identifications"), + ("create_job", "Can create a job"), + ("update_job", "Can update a job"), + ("run_ml_job", "Can run/retry/cancel ML jobs"), + ("run_populate_captures_collection_job", "Can run/retry/cancel Populate Collection jobs"), + ("run_data_storage_sync_job", "Can run/retry/cancel Data Storage Sync jobs"), + ("run_data_export_job", "Can run/retry/cancel Data Export jobs"), + ("run_single_image_ml_job", "Can process a single capture"), + ("run_post_processing_job", "Can run/retry/cancel Post-Processing jobs"), + ("delete_job", "Can delete a job"), + ("create_deployment", "Can create a deployment"), + ("delete_deployment", "Can delete a deployment"), + ("update_deployment", "Can update a deployment"), + ("sync_deployment", "Can sync images to a deployment"), + ("create_sourceimagecollection", "Can create a collection"), + ("update_sourceimagecollection", "Can update a collection"), + ("delete_sourceimagecollection", "Can delete a collection"), + ("populate_sourceimagecollection", "Can populate a collection"), + ("create_sourceimage", "Can create a source image"), + ("update_sourceimage", "Can update a source image"), + ("delete_sourceimage", "Can delete a source image"), + ("star_sourceimage", "Can star a source image"), + ("create_sourceimageupload", "Can create a source image upload"), + ("update_sourceimageupload", "Can update a source image upload"), + ("delete_sourceimageupload", "Can delete a source image upload"), + ("create_s3storagesource", "Can create storage"), + ("delete_s3storagesource", "Can delete storage"), + ("update_s3storagesource", "Can update storage"), + ("test_s3storagesource", "Can test storage connection"), + ("create_site", "Can create a site"), + ("delete_site", "Can delete a site"), + ("update_site", "Can update a site"), + ("create_device", "Can create a device"), + ("delete_device", "Can delete a device"), + ("update_device", "Can update a device"), + ("view_userprojectmembership", "Can view project members"), + ("create_userprojectmembership", "Can add a user to the project"), + ("update_userprojectmembership", "Can update a user's project membership and role in the project"), + ("delete_userprojectmembership", "Can remove a user from the project"), + ("create_dataexport", "Can create a data export"), + ("update_dataexport", "Can update a data export"), + ("delete_dataexport", "Can delete a data export"), + ("create_projectpipelineconfig", "Can register pipelines for the project"), + ("update_projectpipelineconfig", "Can update pipeline configurations"), + ("delete_projectpipelineconfig", "Can remove pipelines from the project"), + ("create_taxalist", "Can create a taxa list"), + ("update_taxalist", "Can update a taxa list"), + ("delete_taxalist", "Can delete a taxa list"), + ("view_private_data", "Can view private data"), + ], + }, + ), + ] diff --git a/ami/main/models.py b/ami/main/models.py index 60a7dc0f4..400932fc3 100644 --- a/ami/main/models.py +++ b/ami/main/models.py @@ -431,6 +431,11 @@ class Permissions: UPDATE_PROJECT_PIPELINE_CONFIG = "update_projectpipelineconfig" DELETE_PROJECT_PIPELINE_CONFIG = "delete_projectpipelineconfig" + # TaxaList permissions + CREATE_TAXALIST = "create_taxalist" + UPDATE_TAXALIST = "update_taxalist" + DELETE_TAXALIST = "delete_taxalist" + # Other permissions VIEW_PRIVATE_DATA = "view_private_data" DELETE_OCCURRENCES = "delete_occurrences" @@ -498,6 +503,10 @@ class Meta: ("create_projectpipelineconfig", "Can register pipelines for the project"), ("update_projectpipelineconfig", "Can update pipeline configurations"), ("delete_projectpipelineconfig", "Can remove pipelines from the project"), + # TaxaList permissions + ("create_taxalist", "Can create a taxa list"), + ("update_taxalist", "Can update a taxa list"), + ("delete_taxalist", "Can delete a taxa list"), # Other permissions ("view_private_data", "Can view private data"), ] diff --git a/ami/main/tests.py b/ami/main/tests.py index ca6fb53cc..f82148937 100644 --- a/ami/main/tests.py +++ b/ami/main/tests.py @@ -10,7 +10,7 @@ from guardian.shortcuts import assign_perm, get_perms, remove_perm from PIL import Image from rest_framework import status -from rest_framework.test import APIRequestFactory, APITestCase +from rest_framework.test import APIClient, APIRequestFactory, APITestCase from rich import print from ami.exports.models import DataExport @@ -3445,6 +3445,170 @@ def test_taxon_detail_visible_when_excluded_from_list(self): self.assertEqual(res.status_code, status.HTTP_200_OK) +class TaxaListViewSetPermissionTestCase(TestCase): + """Test TaxaListViewSet write permissions for project members vs non-members.""" + + def setUp(self): + self.owner = User.objects.create_user(email="owner@example.com", password="testpass") + self.member = User.objects.create_user(email="member@example.com", password="testpass") + self.non_member = User.objects.create_user(email="nonmember@example.com", password="testpass") + self.project = Project.objects.create(name="Test Project", owner=self.owner) + self.project.members.add(self.member) + self.taxa_list = TaxaList.objects.create(name="Existing List", description="A list") + self.taxa_list.projects.add(self.project) + self.client = APIClient() + self.list_url = f"/api/v2/taxa/lists/?project_id={self.project.pk}" + self.detail_url = f"/api/v2/taxa/lists/{self.taxa_list.pk}/?project_id={self.project.pk}" + + def test_member_can_create_taxa_list(self): + self.client.force_authenticate(self.member) + response = self.client.post(self.list_url, {"name": "New List"}) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_member_can_update_taxa_list(self): + self.client.force_authenticate(self.member) + response = self.client.patch(self.detail_url, {"name": "Renamed"}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.taxa_list.refresh_from_db() + self.assertEqual(self.taxa_list.name, "Renamed") + + def test_member_can_delete_taxa_list(self): + self.client.force_authenticate(self.member) + response = self.client.delete(self.detail_url) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + + def test_anonymous_cannot_create_taxa_list(self): + response = self.client.post(self.list_url, {"name": "Anon List"}) + self.assertIn(response.status_code, [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN]) + + def test_anonymous_cannot_update_taxa_list(self): + response = self.client.patch(self.detail_url, {"name": "Hacked"}) + self.assertIn(response.status_code, [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN]) + + def test_non_member_cannot_create_taxa_list(self): + self.client.force_authenticate(self.non_member) + response = self.client.post(self.list_url, {"name": "Intruder List"}) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_non_member_cannot_update_taxa_list(self): + self.client.force_authenticate(self.non_member) + response = self.client.patch(self.detail_url, {"name": "Hacked"}) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + +class TaxaListTaxonAPITestCase(TestCase): + """Test TaxaList taxa management operations via API.""" + + def setUp(self): + """Set up test data.""" + self.user = User.objects.create_user(email="test@example.com", password="testpass") + self.project = Project.objects.create(name="Test Project", owner=self.user) + self.taxa_list = TaxaList.objects.create(name="Test Taxa List", description="Test description") + self.taxa_list.projects.add(self.project) + self.taxon1 = Taxon.objects.create(name="Taxon 1", rank="SPECIES") + self.taxon2 = Taxon.objects.create(name="Taxon 2", rank="SPECIES") + self.client = APIClient() + self.client.force_authenticate(self.user) + self.base_url = f"/api/v2/taxa/lists/{self.taxa_list.pk}/taxa/?project_id={self.project.pk}" + + def test_add_taxon_returns_201(self): + """Test adding taxon to taxa list returns 201.""" + response = self.client.post(self.base_url, {"taxon_id": self.taxon1.pk}) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertTrue(self.taxa_list.taxa.filter(pk=self.taxon1.pk).exists()) + self.assertEqual(response.data["id"], self.taxon1.pk) + + def test_add_duplicate_returns_400(self): + """Test adding duplicate taxon returns 400.""" + self.taxa_list.taxa.add(self.taxon1) + response = self.client.post(self.base_url, {"taxon_id": self.taxon1.pk}) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("already in this taxa list", str(response.data).lower()) + + def test_add_nonexistent_taxon_returns_400(self): + """Test adding non-existent taxon returns 400.""" + response = self.client.post(self.base_url, {"taxon_id": 999999}) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_delete_by_taxon_id(self): + """Test deleting by taxon ID returns 204.""" + self.taxa_list.taxa.add(self.taxon1) + url = f"/api/v2/taxa/lists/{self.taxa_list.pk}/taxa/{self.taxon1.pk}/?project_id={self.project.pk}" + response = self.client.delete(url) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertFalse(self.taxa_list.taxa.filter(pk=self.taxon1.pk).exists()) + + def test_delete_nonexistent_returns_404(self): + """Test deleting non-existent taxon returns 404.""" + url = f"/api/v2/taxa/lists/{self.taxa_list.pk}/taxa/{self.taxon1.pk}/?project_id={self.project.pk}" + response = self.client.delete(url) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_m2m_relationship_works(self): + """Test that M2M relationship still works correctly.""" + self.taxa_list.taxa.add(self.taxon1) + # Should be accessible via M2M relationship + self.assertEqual(self.taxa_list.taxa.count(), 1) + self.assertIn(self.taxon1, self.taxa_list.taxa.all()) + # Test reverse relationship + self.assertIn(self.taxa_list, self.taxon1.lists.all()) + + def test_add_multiple_taxa(self): + """Test adding multiple taxa to the same list.""" + response1 = self.client.post(self.base_url, {"taxon_id": self.taxon1.pk}) + self.assertEqual(response1.status_code, status.HTTP_201_CREATED) + + response2 = self.client.post(self.base_url, {"taxon_id": self.taxon2.pk}) + self.assertEqual(response2.status_code, status.HTTP_201_CREATED) + + self.assertEqual(self.taxa_list.taxa.count(), 2) + + def test_remove_one_taxon_keeps_others(self): + """Test that removing one taxon doesn't affect others.""" + self.taxa_list.taxa.add(self.taxon1, self.taxon2) + + url = f"/api/v2/taxa/lists/{self.taxa_list.pk}/taxa/{self.taxon1.pk}/?project_id={self.project.pk}" + response = self.client.delete(url) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + + # taxon1 should be removed + self.assertFalse(self.taxa_list.taxa.filter(pk=self.taxon1.pk).exists()) + # taxon2 should still be there + self.assertTrue(self.taxa_list.taxa.filter(pk=self.taxon2.pk).exists()) + self.assertEqual(self.taxa_list.taxa.count(), 1) + + +class TaxaListTaxonValidationTestCase(TestCase): + """Test validation and error cases.""" + + def setUp(self): + """Set up test data.""" + self.user = User.objects.create_user(email="test@example.com", password="testpass") + self.project = Project.objects.create(name="Test Project", owner=self.user) + self.taxa_list = TaxaList.objects.create(name="Test Taxa List") + self.taxa_list.projects.add(self.project) + self.taxon = Taxon.objects.create(name="Test Taxon", rank="SPECIES") + self.client = APIClient() + self.client.force_authenticate(self.user) + self.base_url = f"/api/v2/taxa/lists/{self.taxa_list.pk}/taxa/?project_id={self.project.pk}" + + def test_add_without_taxon_id_returns_400(self): + """Test adding without taxon_id returns 400.""" + response = self.client.post(self.base_url, {}) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_add_with_invalid_taxon_id_returns_400(self): + """Test adding with invalid taxon_id returns 400.""" + response = self.client.post(self.base_url, {"taxon_id": "invalid"}) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_nonexistent_taxa_list_returns_404(self): + """Test adding taxon to non-existent taxa list returns 404.""" + url = f"/api/v2/taxa/lists/999999/taxa/?project_id={self.project.pk}" + response = self.client.post(url, {"taxon_id": self.taxon.pk}) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + class TestProjectPipelinesAPI(APITestCase): """Test the project pipelines API endpoint.""" diff --git a/ami/ml/serializers.py b/ami/ml/serializers.py index 3217c519b..6c5782c8f 100644 --- a/ami/ml/serializers.py +++ b/ami/ml/serializers.py @@ -2,7 +2,6 @@ from rest_framework import serializers from ami.main.api.serializers import DefaultSerializer, MinimalNestedModelSerializer -from ami.main.models import Project from .models.algorithm import Algorithm, AlgorithmCategoryMap from .models.pipeline import Pipeline, PipelineStage @@ -134,11 +133,7 @@ class Meta: class ProcessingServiceSerializer(DefaultSerializer): pipelines = PipelineNestedSerializer(many=True, read_only=True) - project = serializers.PrimaryKeyRelatedField( - write_only=True, - queryset=Project.objects.all(), - required=False, - ) + projects = serializers.SerializerMethodField() class Meta: model = ProcessingService @@ -148,7 +143,6 @@ class Meta: "name", "description", "projects", - "project", "endpoint_url", "pipelines", "created_at", @@ -157,14 +151,12 @@ class Meta: "last_checked_live", ] - def create(self, validated_data): - project = validated_data.pop("project", None) - instance = super().create(validated_data) - - if project: - instance.projects.add(project) - - return instance + def get_projects(self, obj): + """ + Return list of project IDs this processing service belongs to. + This is read-only and managed by the server. + """ + return list(obj.projects.values_list("id", flat=True)) class PipelineRegistrationSerializer(serializers.Serializer): diff --git a/ami/ml/tests.py b/ami/ml/tests.py index 6d029492b..cabf0e962 100644 --- a/ami/ml/tests.py +++ b/ami/ml/tests.py @@ -53,10 +53,11 @@ def setUp(self): self.factory = APIRequestFactory() def _create_processing_service(self, name: str, endpoint_url: str): - processing_services_create_url = reverse_with_params("api:processingservice-list") + processing_services_create_url = reverse_with_params( + "api:processingservice-list", params={"project_id": self.project.pk} + ) self.client.force_authenticate(user=self.user) processing_service_data = { - "project": self.project.pk, "name": name, "endpoint_url": endpoint_url, } @@ -117,10 +118,11 @@ def test_processing_service_pipeline_registration(self): def test_create_processing_service_without_endpoint_url(self): """Test creating a ProcessingService without endpoint_url (pull mode)""" - processing_services_create_url = reverse_with_params("api:processingservice-list") + processing_services_create_url = reverse_with_params( + "api:processingservice-list", params={"project_id": self.project.pk} + ) self.client.force_authenticate(user=self.user) processing_service_data = { - "project": self.project.pk, "name": "Pull Mode Service", "description": "Service without endpoint", } diff --git a/ami/ml/views.py b/ami/ml/views.py index edcf0517c..b3272f567 100644 --- a/ami/ml/views.py +++ b/ami/ml/views.py @@ -151,6 +151,7 @@ class ProcessingServiceViewSet(DefaultViewSet, ProjectMixin): serializer_class = ProcessingServiceSerializer filterset_fields = ["projects"] ordering_fields = ["id", "created_at", "updated_at"] + require_project = True def get_queryset(self) -> QuerySet: qs: QuerySet = super().get_queryset() @@ -177,6 +178,20 @@ def create(self, request, *args, **kwargs): {"instance": serializer.data, "status": status_response.dict()}, status=status.HTTP_201_CREATED ) + def perform_create(self, serializer): + """ + Create a ProcessingService and automatically assign it to the active project. + + Users cannot manually assign processing services to projects for security reasons. + A processing service is always created in the context of the active project. + + @TODO Do we need a permission check here to ensure the user can add processing services to the project? + """ + instance = serializer.save() + project = self.get_active_project() + if project: + instance.projects.add(project) + @action(detail=True, methods=["get"]) def status(self, request: Request, pk=None) -> Response: """ diff --git a/ami/users/roles.py b/ami/users/roles.py index 79cad22ae..ce902a3f5 100644 --- a/ami/users/roles.py +++ b/ami/users/roles.py @@ -194,6 +194,9 @@ class ProjectManager(Role): Project.Permissions.CREATE_PROJECT_PIPELINE_CONFIG, Project.Permissions.UPDATE_PROJECT_PIPELINE_CONFIG, Project.Permissions.DELETE_PROJECT_PIPELINE_CONFIG, + Project.Permissions.CREATE_TAXALIST, + Project.Permissions.UPDATE_TAXALIST, + Project.Permissions.DELETE_TAXALIST, } ) diff --git a/config/api_router.py b/config/api_router.py index 52541cde0..49475f726 100644 --- a/config/api_router.py +++ b/config/api_router.py @@ -40,6 +40,13 @@ router.register(r"detections", views.DetectionViewSet) router.register(r"occurrences", views.OccurrenceViewSet) router.register(r"taxa/lists", views.TaxaListViewSet) +# NESTED: /taxa/lists/{taxalist_id}/taxa/ +taxa_lists_router = routers.NestedDefaultRouter(router, r"taxa/lists", lookup="taxalist") +taxa_lists_router.register( + r"taxa", + views.TaxaListTaxonViewSet, + basename="taxalist-taxa", +) router.register(r"taxa", views.TaxonViewSet) router.register(r"tags", views.TagViewSet) router.register(r"ml/algorithms", ml_views.AlgorithmViewSet) @@ -78,5 +85,5 @@ ] -urlpatterns += router.urls + projects_router.urls +urlpatterns += router.urls + projects_router.urls + taxa_lists_router.urls # diff --git a/ui/src/app.tsx b/ui/src/app.tsx index faefb398d..a562efc24 100644 --- a/ui/src/app.tsx +++ b/ui/src/app.tsx @@ -40,6 +40,8 @@ import { Projects } from 'pages/projects/projects' import SessionDetails from 'pages/session-details/session-details' import { Sessions } from 'pages/sessions/sessions' import { Species } from 'pages/species/species' +import { TaxaListDetails } from 'pages/taxa-list-details/taxa-list-details' +import { TaxaLists } from 'pages/taxa-lists/taxa-lists' import { ReactNode, useContext, useEffect } from 'react' import { Helmet, HelmetProvider } from 'react-helmet-async' import { @@ -112,6 +114,12 @@ export const App = () => ( } /> } /> } /> + } /> + } /> + } + /> } /> { - if (activeNavItem.id === 'project') { - setMainBreadcrumb(undefined) - } else { + if (activeNavItem.id !== 'project') { setMainBreadcrumb(activeNavItem) } return () => { setMainBreadcrumb(undefined) } - }, [navItems, activeNavItem]) + }, [navItems, activeNavItem, setMainBreadcrumb]) const breadcrumbs = [ pageBreadcrumb, diff --git a/ui/src/components/taxon-search/add-taxon.tsx b/ui/src/components/taxon-search/add-taxon.tsx index 0c4c871ae..1529696f9 100644 --- a/ui/src/components/taxon-search/add-taxon.tsx +++ b/ui/src/components/taxon-search/add-taxon.tsx @@ -2,6 +2,7 @@ import { Taxon } from 'data-services/models/taxa' import { PlusIcon } from 'lucide-react' import { Button, Popover } from 'nova-ui-kit' import { useState } from 'react' +import { STRING, translate } from 'utils/language' import { TaxonSearch } from './taxon-search' export const AddTaxon = ({ onAdd }: { onAdd: (taxon?: Taxon) => void }) => { @@ -17,7 +18,11 @@ export const AddTaxon = ({ onAdd }: { onAdd: (taxon?: Taxon) => void }) => { className="w-full justify-between px-4 text-muted-foreground font-normal" > <> - Add taxon + + {translate(STRING.ENTITY_ADD, { + type: translate(STRING.ENTITY_TYPE_TAXON), + })} + diff --git a/ui/src/data-services/hooks/entities/useCreateEntity.ts b/ui/src/data-services/hooks/entities/useCreateEntity.ts index 869855746..531b1eed4 100644 --- a/ui/src/data-services/hooks/entities/useCreateEntity.ts +++ b/ui/src/data-services/hooks/entities/useCreateEntity.ts @@ -13,7 +13,7 @@ export const useCreateEntity = (collection: string, onSuccess?: () => void) => { const { mutateAsync, isLoading, isSuccess, reset, error } = useMutation({ mutationFn: (fieldValues: EntityFieldValues) => axios.post( - `${API_URL}/${collection}/`, + `${API_URL}/${collection}/?project_id=${fieldValues.projectId}`, convertToServerFieldValues(fieldValues), { headers: getAuthHeader(user), diff --git a/ui/src/data-services/hooks/entities/useDeleteEntity.ts b/ui/src/data-services/hooks/entities/useDeleteEntity.ts index 4c1ee6747..bd608e119 100644 --- a/ui/src/data-services/hooks/entities/useDeleteEntity.ts +++ b/ui/src/data-services/hooks/entities/useDeleteEntity.ts @@ -4,13 +4,21 @@ import { API_URL } from 'data-services/constants' import { getAuthHeader } from 'data-services/utils' import { useUser } from 'utils/user/userContext' -export const useDeleteEntity = (collection: string, onSuccess?: () => void) => { +export const useDeleteEntity = ({ + collection, + onSuccess, + projectId, +}: { + collection: string + onSuccess?: () => void + projectId: string +}) => { const { user } = useUser() const queryClient = useQueryClient() const { mutateAsync, isLoading, isSuccess, error } = useMutation({ mutationFn: (id: string) => - axios.delete(`${API_URL}/${collection}/${id}/`, { + axios.delete(`${API_URL}/${collection}/${id}/?project_id=${projectId}`, { headers: getAuthHeader(user), }), onSuccess: () => { diff --git a/ui/src/data-services/hooks/taxa-lists/useAddTaxaListTaxon.ts b/ui/src/data-services/hooks/taxa-lists/useAddTaxaListTaxon.ts new file mode 100644 index 000000000..410eed732 --- /dev/null +++ b/ui/src/data-services/hooks/taxa-lists/useAddTaxaListTaxon.ts @@ -0,0 +1,36 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import axios from 'axios' +import { API_ROUTES, API_URL, SUCCESS_TIMEOUT } from 'data-services/constants' +import { getAuthHeader } from 'data-services/utils' +import { useUser } from 'utils/user/userContext' + +export const useAddTaxaListTaxon = (projectId: string) => { + const { user } = useUser() + const queryClient = useQueryClient() + + const { mutateAsync, isLoading, isSuccess, reset, error } = useMutation({ + mutationFn: ({ + taxaListId, + taxonId, + }: { + taxaListId: string + taxonId: string + }) => + axios.post( + `${API_URL}/${API_ROUTES.TAXA_LISTS}/${taxaListId}/taxa/?project_id=${projectId}`, + { + taxon_id: taxonId, + }, + { + headers: getAuthHeader(user), + } + ), + onSuccess: () => { + queryClient.invalidateQueries([API_ROUTES.TAXA_LISTS]) + queryClient.invalidateQueries([API_ROUTES.SPECIES]) + setTimeout(reset, SUCCESS_TIMEOUT) + }, + }) + + return { addTaxaListTaxon: mutateAsync, error, isLoading, isSuccess, reset } +} diff --git a/ui/src/data-services/hooks/taxa-lists/useRemoveTaxaListTaxon.ts b/ui/src/data-services/hooks/taxa-lists/useRemoveTaxaListTaxon.ts new file mode 100644 index 000000000..2a27c852c --- /dev/null +++ b/ui/src/data-services/hooks/taxa-lists/useRemoveTaxaListTaxon.ts @@ -0,0 +1,33 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import axios from 'axios' +import { API_ROUTES, API_URL, SUCCESS_TIMEOUT } from 'data-services/constants' +import { getAuthHeader } from 'data-services/utils' +import { useUser } from 'utils/user/userContext' + +export const useRemoveTaxaListTaxon = (projectId: string) => { + const { user } = useUser() + const queryClient = useQueryClient() + + const { mutateAsync, isLoading, isSuccess, reset, error } = useMutation({ + mutationFn: ({ + taxaListId, + taxonId, + }: { + taxaListId: string + taxonId: string + }) => + axios.delete( + `${API_URL}/${API_ROUTES.TAXA_LISTS}/${taxaListId}/taxa/${taxonId}/?project_id=${projectId}`, + { + headers: getAuthHeader(user), + } + ), + onSuccess: () => { + queryClient.invalidateQueries([API_ROUTES.TAXA_LISTS]) + queryClient.invalidateQueries([API_ROUTES.SPECIES]) + setTimeout(reset, SUCCESS_TIMEOUT) + }, + }) + + return { removeTaxaListTaxon: mutateAsync, isLoading, error, isSuccess } +} diff --git a/ui/src/data-services/hooks/taxa-lists/useTaxaListDetails.ts b/ui/src/data-services/hooks/taxa-lists/useTaxaListDetails.ts new file mode 100644 index 000000000..1ad1b4e06 --- /dev/null +++ b/ui/src/data-services/hooks/taxa-lists/useTaxaListDetails.ts @@ -0,0 +1,34 @@ +import { API_ROUTES, API_URL } from 'data-services/constants' +import { ServerTaxaList, TaxaList } from 'data-services/models/taxa-list' +import { useMemo } from 'react' +import { useAuthorizedQuery } from '../auth/useAuthorizedQuery' + +const convertServerRecord = (record: ServerTaxaList) => new TaxaList(record) + +export const useTaxaListDetails = ( + id: string, + projectId: string +): { + taxaList?: TaxaList + isLoading: boolean + isFetching: boolean + error?: unknown +} => { + const { data, isLoading, isFetching, error } = + useAuthorizedQuery({ + queryKey: [API_ROUTES.TAXA_LISTS, projectId, id], + url: `${API_URL}/${API_ROUTES.TAXA_LISTS}/${id}/?project_id=${projectId}`, + }) + + const taxaList = useMemo( + () => (data ? convertServerRecord(data) : undefined), + [data] + ) + + return { + taxaList, + isLoading, + isFetching, + error, + } +} diff --git a/ui/src/data-services/hooks/taxa-lists/useTaxaLists.ts b/ui/src/data-services/hooks/taxa-lists/useTaxaLists.ts index d735bd80b..96758f69a 100644 --- a/ui/src/data-services/hooks/taxa-lists/useTaxaLists.ts +++ b/ui/src/data-services/hooks/taxa-lists/useTaxaLists.ts @@ -13,6 +13,7 @@ export const useTaxaLists = ( params?: FetchParams ): { taxaLists?: TaxaList[] + total: number userPermissions?: UserPermission[] isLoading: boolean isFetching: boolean @@ -26,6 +27,7 @@ export const useTaxaLists = ( // Fetch data from API const { data, isLoading, isFetching, error } = useAuthorizedQuery<{ + count: number results: ServerTaxaList[] user_permissions?: UserPermission[] }>({ @@ -41,6 +43,7 @@ export const useTaxaLists = ( return { taxaLists, + total: data?.count ?? 0, userPermissions: data?.user_permissions, isLoading, isFetching, diff --git a/ui/src/data-services/models/entity.ts b/ui/src/data-services/models/entity.ts index bec547f58..9656518f2 100644 --- a/ui/src/data-services/models/entity.ts +++ b/ui/src/data-services/models/entity.ts @@ -11,14 +11,18 @@ export class Entity { } get canUpdate(): boolean { - return this._data.user_permissions.includes(UserPermission.Update) + return this._data.user_permissions?.includes(UserPermission.Update) } get canDelete(): boolean { - return this._data.user_permissions.includes(UserPermission.Delete) + return this._data.user_permissions?.includes(UserPermission.Delete) } - get createdAt(): string { + get createdAt(): string | undefined { + if (!this._data.created_at) { + return undefined + } + return getFormatedDateTimeString({ date: new Date(this._data.created_at), }) diff --git a/ui/src/data-services/models/taxa-list.ts b/ui/src/data-services/models/taxa-list.ts index c9124cd33..bafd01781 100644 --- a/ui/src/data-services/models/taxa-list.ts +++ b/ui/src/data-services/models/taxa-list.ts @@ -1,19 +1,21 @@ import { Entity, ServerEntity } from 'data-services/models/entity' export type ServerTaxaList = ServerEntity & { - taxa: string // URL to taxa API endpoint (filtered by this TaxaList) projects: number[] // Array of project IDs + taxa: string // URL to taxa API endpoint (filtered by this taxa list) + taxa_count: number // Number of taxa in list } export class TaxaList extends Entity { protected readonly _taxaList: ServerTaxaList public constructor(taxaList: ServerTaxaList) { - super(taxaList) // Call the parent class constructor + super(taxaList) + this._taxaList = taxaList } - get taxaUrl(): string { - return this._taxaList.taxa + get taxaCount() { + return this._taxaList.taxa_count } } diff --git a/ui/src/pages/occurrences/occurrences.tsx b/ui/src/pages/occurrences/occurrences.tsx index 31cb53d7b..bf3059b6c 100644 --- a/ui/src/pages/occurrences/occurrences.tsx +++ b/ui/src/pages/occurrences/occurrences.tsx @@ -276,8 +276,8 @@ const OccurrenceDetailsDialog = ({ > {occurrence ? ( { + const { projectId } = useParams() const [isOpen, setIsOpen] = useState(false) - const { deleteEntity, isLoading, isSuccess, error } = - useDeleteEntity(collection) + const { deleteEntity, isLoading, isSuccess, error } = useDeleteEntity({ + collection, + projectId: projectId as string, + }) return ( diff --git a/ui/src/pages/project/entities/new-entity-dialog.tsx b/ui/src/pages/project/entities/new-entity-dialog.tsx index b47af48b0..8b464dc49 100644 --- a/ui/src/pages/project/entities/new-entity-dialog.tsx +++ b/ui/src/pages/project/entities/new-entity-dialog.tsx @@ -16,14 +16,14 @@ export const NewEntityDialog = ({ buttonSize = 'small', buttonVariant = 'outline', collection, - type, isCompact, + type, }: { buttonSize?: string buttonVariant?: string collection: string - type: string isCompact?: boolean + type: string }) => { const { projectId } = useParams() const [isOpen, setIsOpen] = useState(false) @@ -64,10 +64,12 @@ export const NewEntityDialog = ({ isLoading={isLoading} isSuccess={isSuccess} onSubmit={(data) => { - createEntity({ + const fieldValues = { ...data, projectId: projectId as string, - }) + } + + createEntity(fieldValues) }} /> diff --git a/ui/src/pages/project/sidebar/sidebar.tsx b/ui/src/pages/project/sidebar/sidebar.tsx index 421f7f3dc..54fa02fbb 100644 --- a/ui/src/pages/project/sidebar/sidebar.tsx +++ b/ui/src/pages/project/sidebar/sidebar.tsx @@ -13,14 +13,14 @@ import { useSidebarSections } from './useSidebarSections' export const Sidebar = ({ project }: { project: ProjectDetails }) => { const { sidebarSections, activeItem } = useSidebarSections(project) - const { setDetailBreadcrumb } = useContext(BreadcrumbContext) + const { setMainBreadcrumb } = useContext(BreadcrumbContext) useEffect(() => { if (activeItem) { - setDetailBreadcrumb({ title: activeItem.title, path: activeItem.path }) + setMainBreadcrumb({ title: activeItem.title, path: activeItem.path }) } return () => { - setDetailBreadcrumb(undefined) + setMainBreadcrumb(undefined) } }, [activeItem]) diff --git a/ui/src/pages/project/sidebar/useSidebarSections.tsx b/ui/src/pages/project/sidebar/useSidebarSections.tsx index 6db0e5d8e..7f4fcb32d 100644 --- a/ui/src/pages/project/sidebar/useSidebarSections.tsx +++ b/ui/src/pages/project/sidebar/useSidebarSections.tsx @@ -26,6 +26,15 @@ const getSidebarSections = ( title: translate(STRING.NAV_ITEM_COLLECTIONS), path: APP_ROUTES.COLLECTIONS({ projectId: project.id }), }, + { + id: 'taxa-lists', + title: translate(STRING.NAV_ITEM_TAXA_LISTS), + path: APP_ROUTES.TAXA_LISTS({ projectId: project.id }), + matchPath: APP_ROUTES.TAXA_LIST_DETAILS({ + projectId: ':projectId', + taxaListId: '*', + }), + }, { id: 'exports', title: translate(STRING.NAV_ITEM_EXPORTS), @@ -135,10 +144,5 @@ export const useSidebarSections = (project: ProjectDetails) => { ) }, [location.pathname, sidebarSections]) - sidebarSections - .map(({ items }) => items) - .flat() - .find((item) => !!matchPath(item.path, location.pathname)) - return { sidebarSections, activeItem } } diff --git a/ui/src/pages/species/species-columns.tsx b/ui/src/pages/species/species-columns.tsx index 921f456e4..86ccd7036 100644 --- a/ui/src/pages/species/species-columns.tsx +++ b/ui/src/pages/species/species-columns.tsx @@ -21,14 +21,17 @@ export const columns: (project: { }) => TableColumn[] = ({ projectId, featureFlags }) => [ { id: 'cover-image', - name: 'Cover image', + name: translate(STRING.FIELD_LABEL_IMAGE), sortField: 'cover_image_url', renderCell: (item: Species) => { return ( ) }, diff --git a/ui/src/pages/species/species.tsx b/ui/src/pages/species/species.tsx index 577a6ca29..3a2c6ba59 100644 --- a/ui/src/pages/species/species.tsx +++ b/ui/src/pages/species/species.tsx @@ -196,8 +196,8 @@ const SpeciesDetailsDialog = ({ id }: { id: string }) => { > {species ? ( { + const [open, setOpen] = useState(false) + + return ( + + + + + + setOpen(false)} + taxaListId={taxaListId} + /> + + + ) +} diff --git a/ui/src/pages/taxa-list-details/add-taxa-list-taxon/add-taxa-list-taxon.tsx b/ui/src/pages/taxa-list-details/add-taxa-list-taxon/add-taxa-list-taxon.tsx new file mode 100644 index 000000000..79935c5e8 --- /dev/null +++ b/ui/src/pages/taxa-list-details/add-taxa-list-taxon/add-taxa-list-taxon.tsx @@ -0,0 +1,66 @@ +import { FormError } from 'components/form/layout/layout' +import { TaxonSelect } from 'components/taxon-search/taxon-select' +import { SUCCESS_TIMEOUT } from 'data-services/constants' +import { useAddTaxaListTaxon } from 'data-services/hooks/taxa-lists/useAddTaxaListTaxon' +import { Taxon } from 'data-services/models/taxa' +import { CheckIcon, Loader2Icon } from 'lucide-react' +import { Button } from 'nova-ui-kit' +import { useState } from 'react' +import { useParams } from 'react-router-dom' +import { STRING, translate } from 'utils/language' +import { parseServerError } from 'utils/parseServerError/parseServerError' + +export const AddTaxaListTaxon = ({ + onCancel, + taxaListId, +}: { + onCancel: () => void + taxaListId: string +}) => { + const { projectId } = useParams() + const [taxon, setTaxon] = useState() + const { addTaxaListTaxon, error, isLoading, isSuccess } = useAddTaxaListTaxon( + projectId as string + ) + const formError = error ? parseServerError(error)?.message : undefined + + return ( + <> + {formError && ( + + )} +
+
+ +
+
+ + +
+
+ + ) +} diff --git a/ui/src/pages/taxa-list-details/remove-taxa-list-taxon/remove-taxa-list-taxon-dialog.tsx b/ui/src/pages/taxa-list-details/remove-taxa-list-taxon/remove-taxa-list-taxon-dialog.tsx new file mode 100644 index 000000000..b5e628c86 --- /dev/null +++ b/ui/src/pages/taxa-list-details/remove-taxa-list-taxon/remove-taxa-list-taxon-dialog.tsx @@ -0,0 +1,76 @@ +import { FormError, FormSection } from 'components/form/layout/layout' +import { useRemoveTaxaListTaxon } from 'data-services/hooks/taxa-lists/useRemoveTaxaListTaxon' +import * as Dialog from 'design-system/components/dialog/dialog' +import { CheckIcon, Loader2Icon, XIcon } from 'lucide-react' +import { Button } from 'nova-ui-kit' +import { useState } from 'react' +import { useParams } from 'react-router-dom' +import { STRING, translate } from 'utils/language' +import { useFormError } from 'utils/useFormError' + +export const RemoveTaxaListTaxonDialog = ({ + taxaListId, + taxonId, +}: { + taxaListId: string + taxonId: string +}) => { + const { projectId } = useParams() + const [isOpen, setIsOpen] = useState(false) + const { removeTaxaListTaxon, isLoading, isSuccess, error } = + useRemoveTaxaListTaxon(projectId as string) + const errorMessage = useFormError({ error }) + + return ( + + + + + + {errorMessage && ( + + )} + +
+ + +
+
+
+
+ ) +} diff --git a/ui/src/pages/taxa-list-details/taxa-list-details-columns.tsx b/ui/src/pages/taxa-list-details/taxa-list-details-columns.tsx new file mode 100644 index 000000000..777b1031d --- /dev/null +++ b/ui/src/pages/taxa-list-details/taxa-list-details-columns.tsx @@ -0,0 +1,89 @@ +import { Species } from 'data-services/models/species' +import { BasicTableCell } from 'design-system/components/table/basic-table-cell/basic-table-cell' +import { ImageTableCell } from 'design-system/components/table/image-table-cell/image-table-cell' +import { + ImageCellTheme, + TableColumn, + TextAlign, +} from 'design-system/components/table/types' +import { TaxonDetails } from 'nova-ui-kit' +import { Link } from 'react-router-dom' +import { APP_ROUTES } from 'utils/constants' +import { getAppRoute } from 'utils/getAppRoute' +import { STRING, translate } from 'utils/language' +import { RemoveTaxaListTaxonDialog } from './remove-taxa-list-taxon/remove-taxa-list-taxon-dialog' + +export const columns: (params: { + canUpdate?: boolean + projectId: string + taxaListId: string +}) => TableColumn[] = ({ canUpdate, projectId, taxaListId }) => [ + { + id: 'cover-image', + name: translate(STRING.FIELD_LABEL_IMAGE), + sortField: 'cover_image_url', + renderCell: (item: Species) => { + return ( + + ) + }, + }, + { + id: 'name', + sortField: 'name', + name: translate(STRING.FIELD_LABEL_TAXON), + renderCell: (item: Species) => ( + + + + + + ), + }, + { + id: 'rank', + name: 'Taxon rank', + styles: { + textAlign: TextAlign.Right, + }, + renderCell: (item: Species) => , + }, + { + id: 'actions', + name: '', + styles: { + padding: '16px', + width: '100%', + }, + renderCell: (item: Species) => ( +
+ {canUpdate ? ( + + ) : null} +
+ ), + }, +] diff --git a/ui/src/pages/taxa-list-details/taxa-list-details.tsx b/ui/src/pages/taxa-list-details/taxa-list-details.tsx new file mode 100644 index 000000000..85c10cdcf --- /dev/null +++ b/ui/src/pages/taxa-list-details/taxa-list-details.tsx @@ -0,0 +1,148 @@ +import { useSpecies } from 'data-services/hooks/species/useSpecies' +import { useSpeciesDetails } from 'data-services/hooks/species/useSpeciesDetails' +import { useTaxaListDetails } from 'data-services/hooks/taxa-lists/useTaxaListDetails' +import * as Dialog from 'design-system/components/dialog/dialog' +import { PageFooter } from 'design-system/components/page-footer/page-footer' +import { PageHeader } from 'design-system/components/page-header/page-header' +import { PaginationBar } from 'design-system/components/pagination-bar/pagination-bar' +import { SortControl } from 'design-system/components/sort-control' +import { Table } from 'design-system/components/table/table/table' +import { SpeciesDetails, TABS } from 'pages/species-details/species-details' +import { useContext, useEffect } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { BreadcrumbContext } from 'utils/breadcrumbContext' +import { APP_ROUTES } from 'utils/constants' +import { getAppRoute } from 'utils/getAppRoute' +import { STRING, translate } from 'utils/language' +import { usePagination } from 'utils/usePagination' +import { useSelectedView } from 'utils/useSelectedView' +import { useSort } from 'utils/useSort' +import { AddTaxaListTaxonPopover } from './add-taxa-list-taxon/add-taxa-list-taxon-popover' +import { columns } from './taxa-list-details-columns' + +export const TaxaListDetails = () => { + const { projectId, id, taxonId } = useParams() + const { setDetailBreadcrumb } = useContext(BreadcrumbContext) + const { sort, setSort } = useSort({ field: 'name', order: 'asc' }) + const { pagination, setPage } = usePagination() + const { taxaList } = useTaxaListDetails(id as string, projectId as string) + const { species, total, isLoading, isFetching, error } = useSpecies({ + projectId, + sort, + pagination, + filters: [ + { field: 'include_unobserved', value: 'true' }, + { field: 'include_descendants', value: 'false' }, + { field: 'taxa_list_id', value: id }, + ], + }) + + useEffect(() => { + setDetailBreadcrumb( + taxaList ? { title: taxaList.name } : { title: 'Loading...' } + ) + + return () => { + setDetailBreadcrumb(undefined) + } + }, [taxaList, setDetailBreadcrumb]) + + return ( + <> + + + {taxaList?.canUpdate ? ( + + ) : null} + + + + {species?.length ? ( + + ) : null} + + {taxonId ? ( + + ) : null} + + ) +} + +const SpeciesDetailsDialog = ({ + taxaListId, + taxonId, +}: { + taxaListId: string + taxonId: string +}) => { + const navigate = useNavigate() + const { selectedView, setSelectedView } = useSelectedView(TABS.FIELDS, 'tab') + const { projectId } = useParams() + const { species, isLoading, error } = useSpeciesDetails(taxonId, projectId) + + return ( + { + if (!open) { + setSelectedView(undefined) + } + + navigate( + getAppRoute({ + to: APP_ROUTES.TAXA_LIST_DETAILS({ + projectId: projectId as string, + taxaListId, + }), + keepSearchParams: true, + }) + ) + }} + > + + {species ? ( + + ) : null} + + + ) +} diff --git a/ui/src/pages/taxa-lists/taxa-list-columns.tsx b/ui/src/pages/taxa-lists/taxa-list-columns.tsx new file mode 100644 index 000000000..61ae7419d --- /dev/null +++ b/ui/src/pages/taxa-lists/taxa-list-columns.tsx @@ -0,0 +1,105 @@ +import { API_ROUTES } from 'data-services/constants' +import { TaxaList } from 'data-services/models/taxa-list' +import { BasicTableCell } from 'design-system/components/table/basic-table-cell/basic-table-cell' +import { + CellTheme, + TableColumn, + TextAlign, +} from 'design-system/components/table/types' +import { DeleteEntityDialog } from 'pages/project/entities/delete-entity-dialog' +import { UpdateEntityDialog } from 'pages/project/entities/entity-details-dialog' +import { Link } from 'react-router-dom' +import { APP_ROUTES } from 'utils/constants' +import { getAppRoute } from 'utils/getAppRoute' +import { STRING, translate } from 'utils/language' +import { AddTaxaListTaxonPopover } from '../taxa-list-details/add-taxa-list-taxon/add-taxa-list-taxon-popover' + +export const columns: (projectId: string) => TableColumn[] = ( + projectId: string +) => [ + { + id: 'name', + name: translate(STRING.FIELD_LABEL_NAME), + sortField: 'name', + renderCell: (item: TaxaList) => ( + + + + ), + }, + { + id: 'description', + name: translate(STRING.FIELD_LABEL_DESCRIPTION), + sortField: 'description', + renderCell: (item: TaxaList) => , + }, + { + id: 'taxa', + name: translate(STRING.FIELD_LABEL_TAXA), + sortField: 'annotated_taxa_count', + styles: { + textAlign: TextAlign.Right, + }, + renderCell: (item: TaxaList) => ( + + + + ), + }, + { + id: 'created-at', + name: translate(STRING.FIELD_LABEL_CREATED_AT), + sortField: 'created_at', + renderCell: (item: TaxaList) => , + }, + { + id: 'updated-at', + name: translate(STRING.FIELD_LABEL_UPDATED_AT), + sortField: 'updated_at', + renderCell: (item: TaxaList) => , + }, + { + id: 'actions', + name: '', + styles: { + padding: '16px', + width: '100%', + }, + renderCell: (item: TaxaList) => ( +
+ {item.canUpdate ? ( + + ) : null} + {item.canUpdate ? ( + + ) : null} + {item.canDelete ? ( + + ) : null} +
+ ), + }, +] diff --git a/ui/src/pages/taxa-lists/taxa-lists.tsx b/ui/src/pages/taxa-lists/taxa-lists.tsx new file mode 100644 index 000000000..fe9f41e22 --- /dev/null +++ b/ui/src/pages/taxa-lists/taxa-lists.tsx @@ -0,0 +1,70 @@ +import { API_ROUTES } from 'data-services/constants' +import { useTaxaLists } from 'data-services/hooks/taxa-lists/useTaxaLists' +import { PageHeader } from 'design-system/components/page-header/page-header' +import { PaginationBar } from 'design-system/components/pagination-bar/pagination-bar' +import { SortControl } from 'design-system/components/sort-control' +import { Table } from 'design-system/components/table/table/table' +import { NewEntityDialog } from 'pages/project/entities/new-entity-dialog' +import { useParams } from 'react-router-dom' +import { STRING, translate } from 'utils/language' +import { usePagination } from 'utils/usePagination' +import { UserPermission } from 'utils/user/types' +import { useSort } from 'utils/useSort' +import { columns } from './taxa-list-columns' + +export const TaxaLists = () => { + const { projectId, id } = useParams() + const { sort, setSort } = useSort({ + field: 'name', + order: 'asc', + }) + const { pagination, setPage } = usePagination() + const { taxaLists, total, userPermissions, isLoading, isFetching, error } = + useTaxaLists({ + projectId, + pagination, + sort, + }) + const canCreate = userPermissions?.includes(UserPermission.Create) + + return ( + <> + + + {canCreate && ( + + )} + +
+ {taxaLists?.length ? ( + + ) : null} + + ) +} diff --git a/ui/src/utils/constants.ts b/ui/src/utils/constants.ts index eb58f48d9..a65716caf 100644 --- a/ui/src/utils/constants.ts +++ b/ui/src/utils/constants.ts @@ -93,6 +93,19 @@ export const APP_ROUTES = { TAXA: (params: { projectId: string }) => `/projects/${params.projectId}/taxa`, + TAXA_LISTS: (params: { projectId: string }) => + `/projects/${params.projectId}/taxa-lists`, + + TAXA_LIST_DETAILS: (params: { projectId: string; taxaListId: string }) => + `/projects/${params.projectId}/taxa-lists/${params.taxaListId}`, + + TAXA_LIST_TAXON_DETAILS: (params: { + projectId: string + taxaListId: string + taxonId: string + }) => + `/projects/${params.projectId}/taxa-lists/${params.taxaListId}/taxa/${params.taxonId}`, + TAXON_DETAILS: (params: { projectId: string; taxonId: string }) => `/projects/${params.projectId}/taxa/${params.taxonId}`, diff --git a/ui/src/utils/getAppRoute.ts b/ui/src/utils/getAppRoute.ts index db9ee534e..c95284f01 100644 --- a/ui/src/utils/getAppRoute.ts +++ b/ui/src/utils/getAppRoute.ts @@ -1,15 +1,17 @@ type FilterType = - | 'deployment' - | 'event' - | 'occurrence' | 'capture' - | 'detections__source_image' - | 'taxon' - | 'timestamp' | 'collection' | 'collections' + | 'deployment' + | 'detections__source_image' + | 'event' + | 'include_unobserved' + | 'occurrence' | 'source_image_collection' | 'source_image_single' + | 'taxa_list_id' + | 'taxon' + | 'timestamp' export const getAppRoute = ({ to, diff --git a/ui/src/utils/language.ts b/ui/src/utils/language.ts index 5e8f467ba..ccd0251f8 100644 --- a/ui/src/utils/language.ts +++ b/ui/src/utils/language.ts @@ -1,5 +1,6 @@ export enum STRING { /* BUTTON */ + ADD, ADMIN, BACK, CANCEL, @@ -61,6 +62,8 @@ export enum STRING { ENTITY_TYPE_PROCESSING_SERVICE, ENTITY_TYPE_PROJECT_DETAILS, ENTITY_TYPE_PROJECT, + ENTITY_TYPE_TAXA_LIST, + ENTITY_TYPE_TAXON, ENTITY_VIEW, /* FIELD_LABEL */ @@ -186,6 +189,7 @@ export enum STRING { MESSAGE_PERMISSIONS_MISSING, MESSAGE_PROCESS_NOW_TOOLTIP, MESSAGE_REMOVE_MEMBER_CONFIRM, + MESSAGE_REMOVE_TAXA_LIST_TAXON_CONFIRM, MESSAGE_RESET_INSTRUCTIONS_SENT, MESSAGE_RESULT_RANGE, MESSAGE_SIGNED_UP, @@ -216,6 +220,7 @@ export enum STRING { NAV_ITEM_SITES, NAV_ITEM_STORAGE, NAV_ITEM_SUMMARY, + NAV_ITEM_TAXA_LISTS, NAV_ITEM_TAXA, NAV_ITEM_TEAM, NAV_ITEM_TERMS_OF_SERVICE, @@ -281,6 +286,7 @@ export enum STRING { REJECT_ID_SHORT, REJECT_ID, REMOVE_MEMBER, + REMOVE_TAXA_LIST_TAXON, RESULTS_MEMBERS, RESULTS, SELECT_COLUMNS, @@ -303,6 +309,7 @@ export enum STRING { const ENGLISH_STRINGS: { [key in STRING]: string } = { /* BUTTON */ + [STRING.ADD]: 'Add', [STRING.ADMIN]: 'Admin', [STRING.BACK]: 'Back', [STRING.CANCEL]: 'Cancel', @@ -460,6 +467,8 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { [STRING.ENTITY_TYPE_PROCESSING_SERVICE]: 'processing service', [STRING.ENTITY_TYPE_PROJECT_DETAILS]: 'project details', [STRING.ENTITY_TYPE_PROJECT]: 'project', + [STRING.ENTITY_TYPE_TAXA_LIST]: 'taxa list', + [STRING.ENTITY_TYPE_TAXON]: 'taxon', [STRING.ENTITY_VIEW]: 'View {{type}}', /* MESSAGE */ @@ -505,6 +514,8 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { 'Process this single image with presets', [STRING.MESSAGE_REMOVE_MEMBER_CONFIRM]: 'Are you sure you want to remove {{user}} from the team?', + [STRING.MESSAGE_REMOVE_TAXA_LIST_TAXON_CONFIRM]: + 'Are you sure you want to remove this taxon from the taxa list?', [STRING.MESSAGE_RESET_INSTRUCTIONS_SENT]: 'Reset intructions has been sent to {{email}}!', [STRING.MESSAGE_RESULT_RANGE]: @@ -537,6 +548,7 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { [STRING.NAV_ITEM_SITES]: 'Sites', [STRING.NAV_ITEM_STORAGE]: 'Storage', [STRING.NAV_ITEM_SUMMARY]: 'Summary', + [STRING.NAV_ITEM_TAXA_LISTS]: 'Taxa lists', [STRING.NAV_ITEM_TAXA]: 'Taxa', [STRING.NAV_ITEM_TEAM]: 'Team', [STRING.NAV_ITEM_TERMS_OF_SERVICE]: 'Terms of service', @@ -614,6 +626,7 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { [STRING.REJECT_ID_SHORT]: 'Reject', [STRING.REJECT_ID]: 'Reject ID', [STRING.REMOVE_MEMBER]: 'Remove member', + [STRING.REMOVE_TAXA_LIST_TAXON]: 'Remove taxon', [STRING.RESULTS_MEMBERS]: '{{total}} member(s)', [STRING.RESULTS]: '{{total}} result(s)', [STRING.SELECT_COLUMNS]: 'Select columns',