From fc689856323f9c98924f23a23a6dcb7ab834be35 Mon Sep 17 00:00:00 2001 From: Anna Viklund Date: Wed, 14 Jan 2026 12:07:10 +0100 Subject: [PATCH 01/38] feat: setup new view for taxa lists --- ui/src/app.tsx | 2 + ui/src/data-services/models/entity.ts | 6 +- ui/src/data-services/models/taxa-list.ts | 7 +-- ui/src/pages/taxa-lists/taxa-list-columns.tsx | 47 ++++++++++++++++ ui/src/pages/taxa-lists/taxa-lists.tsx | 56 +++++++++++++++++++ ui/src/utils/getAppRoute.ts | 14 +++-- ui/src/utils/language.ts | 2 + 7 files changed, 122 insertions(+), 12 deletions(-) create mode 100644 ui/src/pages/taxa-lists/taxa-list-columns.tsx create mode 100644 ui/src/pages/taxa-lists/taxa-lists.tsx diff --git a/ui/src/app.tsx b/ui/src/app.tsx index 962440aca..686a37469 100644 --- a/ui/src/app.tsx +++ b/ui/src/app.tsx @@ -39,6 +39,7 @@ 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 { TaxaLists } from 'pages/taxa-lists/taxa-lists' import { ReactNode, useContext, useEffect } from 'react' import { Helmet, HelmetProvider } from 'react-helmet-async' import { @@ -132,6 +133,7 @@ export const App = () => ( } /> } /> } /> + } /> 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: '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) => , + }, +] 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..22d584592 --- /dev/null +++ b/ui/src/pages/taxa-lists/taxa-lists.tsx @@ -0,0 +1,56 @@ +import { useTaxaLists } from 'data-services/hooks/taxa-lists/useTaxaLists' +import { PageHeader } from 'design-system/components/page-header/page-header' +import { SortControl } from 'design-system/components/sort-control' +import { Table } from 'design-system/components/table/table/table' +import { DeploymentDetailsDialog } from 'pages/deployment-details/deployment-details-dialog' +import { useParams } from 'react-router-dom' +import { STRING, translate } from 'utils/language' +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 { taxaLists, userPermissions, isLoading, isFetching, error } = + useTaxaLists({ + projectId, + pagination: { page: 0, perPage: 200 }, + sort, + }) + const canCreate = userPermissions?.includes(UserPermission.Create) + + return ( + <> + + + {canCreate ? null : null} + + + {id ? : null} + + ) +} diff --git a/ui/src/utils/getAppRoute.ts b/ui/src/utils/getAppRoute.ts index 2bb12d398..d99a60181 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 7d27ca081..a96d5b8e3 100644 --- a/ui/src/utils/language.ts +++ b/ui/src/utils/language.ts @@ -207,6 +207,7 @@ export enum STRING { NAV_ITEM_STORAGE, NAV_ITEM_SUMMARY, NAV_ITEM_TAXA, + NAV_ITEM_TAXA_LISTS, NAV_ITEM_TERMS_OF_SERVICE, /* TAB_ITEM */ @@ -508,6 +509,7 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { [STRING.NAV_ITEM_STORAGE]: 'Storage', [STRING.NAV_ITEM_SUMMARY]: 'Summary', [STRING.NAV_ITEM_TAXA]: 'Taxa', + [STRING.NAV_ITEM_TAXA_LISTS]: 'Taxa lists', [STRING.NAV_ITEM_TERMS_OF_SERVICE]: 'Terms of service', /* TAB_ITEM */ From bd2d08dbaedca2356fa637ecaee465a5f193ed56 Mon Sep 17 00:00:00 2001 From: Anna Viklund Date: Wed, 14 Jan 2026 12:25:45 +0100 Subject: [PATCH 02/38] feat: make it possible to create new taxa lists --- ui/src/pages/project/entities/new-entity-dialog.tsx | 10 ++++++++-- ui/src/pages/taxa-lists/taxa-lists.tsx | 11 ++++++++++- ui/src/utils/language.ts | 2 ++ 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/ui/src/pages/project/entities/new-entity-dialog.tsx b/ui/src/pages/project/entities/new-entity-dialog.tsx index b47af48b0..396383433 100644 --- a/ui/src/pages/project/entities/new-entity-dialog.tsx +++ b/ui/src/pages/project/entities/new-entity-dialog.tsx @@ -16,14 +16,16 @@ export const NewEntityDialog = ({ buttonSize = 'small', buttonVariant = 'outline', collection, - type, + global, isCompact, + type, }: { buttonSize?: string buttonVariant?: string collection: string - type: string + global?: boolean isCompact?: boolean + type: string }) => { const { projectId } = useParams() const [isOpen, setIsOpen] = useState(false) @@ -67,6 +69,10 @@ export const NewEntityDialog = ({ createEntity({ ...data, projectId: projectId as string, + customFields: { + ...data.customFields, + ...(global ? { projects: [projectId as string] } : {}), // Some entities are shared across projects + }, }) }} /> diff --git a/ui/src/pages/taxa-lists/taxa-lists.tsx b/ui/src/pages/taxa-lists/taxa-lists.tsx index 22d584592..4788969b3 100644 --- a/ui/src/pages/taxa-lists/taxa-lists.tsx +++ b/ui/src/pages/taxa-lists/taxa-lists.tsx @@ -1,8 +1,10 @@ +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 { SortControl } from 'design-system/components/sort-control' import { Table } from 'design-system/components/table/table/table' import { DeploymentDetailsDialog } from 'pages/deployment-details/deployment-details-dialog' +import { NewEntityDialog } from 'pages/project/entities/new-entity-dialog' import { useParams } from 'react-router-dom' import { STRING, translate } from 'utils/language' import { UserPermission } from 'utils/user/types' @@ -39,7 +41,14 @@ export const TaxaLists = () => { setSort={setSort} sort={sort} /> - {canCreate ? null : null} + {canCreate && ( + + )}
Date: Wed, 14 Jan 2026 12:33:43 +0100 Subject: [PATCH 03/38] feat: make it possible to edit taxa list details --- ui/src/data-services/models/entity.ts | 4 +-- ui/src/pages/taxa-lists/taxa-list-columns.tsx | 28 +++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/ui/src/data-services/models/entity.ts b/ui/src/data-services/models/entity.ts index e46ffb34d..c262f2b1c 100644 --- a/ui/src/data-services/models/entity.ts +++ b/ui/src/data-services/models/entity.ts @@ -11,11 +11,11 @@ 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 | undefined { diff --git a/ui/src/pages/taxa-lists/taxa-list-columns.tsx b/ui/src/pages/taxa-lists/taxa-list-columns.tsx index da2b83a26..5bcd6e744 100644 --- a/ui/src/pages/taxa-lists/taxa-list-columns.tsx +++ b/ui/src/pages/taxa-lists/taxa-list-columns.tsx @@ -1,6 +1,9 @@ +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 } 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' @@ -44,4 +47,29 @@ export const columns: (projectId: string) => TableColumn[] = ( sortField: 'updated_at', renderCell: (item: TaxaList) => , }, + { + id: 'actions', + name: '', + styles: { + padding: '16px', + width: '100%', + }, + renderCell: (item: TaxaList) => ( +
+ {/* TODO: Check user permissions when returned by API */} + + + +
+ ), + }, ] From 9a23bf51ba6dcb388ce8b21ad01a39482e297cfd Mon Sep 17 00:00:00 2001 From: Anna Viklund Date: Wed, 14 Jan 2026 12:42:27 +0100 Subject: [PATCH 04/38] feat: set breadcrumb label for taxa list page --- ui/src/pages/taxa-lists/taxa-lists.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/ui/src/pages/taxa-lists/taxa-lists.tsx b/ui/src/pages/taxa-lists/taxa-lists.tsx index 4788969b3..efd87211f 100644 --- a/ui/src/pages/taxa-lists/taxa-lists.tsx +++ b/ui/src/pages/taxa-lists/taxa-lists.tsx @@ -5,13 +5,16 @@ import { SortControl } from 'design-system/components/sort-control' import { Table } from 'design-system/components/table/table/table' import { DeploymentDetailsDialog } from 'pages/deployment-details/deployment-details-dialog' import { NewEntityDialog } from 'pages/project/entities/new-entity-dialog' +import { useContext, useEffect } from 'react' import { useParams } from 'react-router-dom' +import { BreadcrumbContext } from 'utils/breadcrumbContext' import { STRING, translate } from 'utils/language' import { UserPermission } from 'utils/user/types' import { useSort } from 'utils/useSort' import { columns } from './taxa-list-columns' export const TaxaLists = () => { + const { setDetailBreadcrumb } = useContext(BreadcrumbContext) const { projectId, id } = useParams() const { sort, setSort } = useSort({ @@ -26,6 +29,14 @@ export const TaxaLists = () => { }) const canCreate = userPermissions?.includes(UserPermission.Create) + useEffect(() => { + setDetailBreadcrumb({ title: translate(STRING.NAV_ITEM_TAXA_LISTS) }) + + return () => { + setDetailBreadcrumb(undefined) + } + }, []) + return ( <> Date: Wed, 14 Jan 2026 12:48:37 +0100 Subject: [PATCH 05/38] fix: cleanup --- .../hooks/taxa-lists/useTaxaLists.ts | 3 +++ ui/src/pages/taxa-lists/taxa-lists.tsx | 18 +++++++++++++----- 2 files changed, 16 insertions(+), 5 deletions(-) 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/pages/taxa-lists/taxa-lists.tsx b/ui/src/pages/taxa-lists/taxa-lists.tsx index efd87211f..938e305c3 100644 --- a/ui/src/pages/taxa-lists/taxa-lists.tsx +++ b/ui/src/pages/taxa-lists/taxa-lists.tsx @@ -1,14 +1,15 @@ 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 { DeploymentDetailsDialog } from 'pages/deployment-details/deployment-details-dialog' import { NewEntityDialog } from 'pages/project/entities/new-entity-dialog' import { useContext, useEffect } from 'react' import { useParams } from 'react-router-dom' import { BreadcrumbContext } from 'utils/breadcrumbContext' 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' @@ -16,15 +17,15 @@ import { columns } from './taxa-list-columns' export const TaxaLists = () => { const { setDetailBreadcrumb } = useContext(BreadcrumbContext) const { projectId, id } = useParams() - const { sort, setSort } = useSort({ field: 'name', order: 'asc', }) - const { taxaLists, userPermissions, isLoading, isFetching, error } = + const { pagination, setPage } = usePagination() + const { taxaLists, total, userPermissions, isLoading, isFetching, error } = useTaxaLists({ projectId, - pagination: { page: 0, perPage: 200 }, + pagination, sort, }) const canCreate = userPermissions?.includes(UserPermission.Create) @@ -70,7 +71,14 @@ export const TaxaLists = () => { sortable sortSettings={sort} /> - {id ? : null} + {taxaLists?.length ? ( + + ) : null} ) } From 24d8fe5d176fcd74a1cacebf19267e6e0e80605c Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 15 Jan 2026 16:55:18 +0100 Subject: [PATCH 06/38] Add sorting, timestamps, and taxa count to TaxaList API (#1082) * Initial plan * Add sorting, timestamps and taxa_count to TaxaList API Co-Authored-By: Claude Co-authored-by: mihow <158175+mihow@users.noreply.github.com> * Optimize taxa_count with query annotation Co-Authored-By: Claude Co-authored-by: mihow <158175+mihow@users.noreply.github.com> * Format lists with trailing commas per black style Co-Authored-By: Claude Co-authored-by: mihow <158175+mihow@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mihow <158175+mihow@users.noreply.github.com> Co-authored-by: Anna Viklund --- ami/main/api/serializers.py | 21 +++++++++++++++++++-- ami/main/api/views.py | 12 +++++++++--- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/ami/main/api/serializers.py b/ami/main/api/serializers.py index 1c5b7a126..6af8339ff 100644 --- a/ami/main/api/serializers.py +++ b/ami/main/api/serializers.py @@ -614,13 +614,23 @@ def get_occurrences(self, obj): ) -class TaxaListSerializer(serializers.ModelSerializer): +class TaxaListSerializer(DefaultSerializer): taxa = serializers.SerializerMethodField() + taxa_count = serializers.SerializerMethodField() projects = serializers.PrimaryKeyRelatedField(queryset=Project.objects.all(), many=True) 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): """ @@ -632,6 +642,13 @@ 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()) + class CaptureTaxonSerializer(DefaultSerializer): parent = TaxonNoParentNestedSerializer(read_only=True) diff --git a/ami/main/api/views.py b/ami/main/api/views.py index 9a2770ac8..6a72091a4 100644 --- a/ami/main/api/views.py +++ b/ami/main/api/views.py @@ -1608,18 +1608,24 @@ 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", + "created_at", + "updated_at", + ] 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 - class TagViewSet(DefaultViewSet, ProjectMixin): queryset = Tag.objects.all() From 08babab8b9db563728bd4f7018a9d49081e564e5 Mon Sep 17 00:00:00 2001 From: Anna Viklund Date: Fri, 16 Jan 2026 12:52:58 +0100 Subject: [PATCH 07/38] feat: add taxa count columns and more sort options --- ami/main/api/views.py | 2 ++ ui/src/data-services/models/taxa-list.ts | 9 ++++-- .../project/entities/new-entity-dialog.tsx | 21 +++++++++----- ui/src/pages/taxa-lists/taxa-list-columns.tsx | 29 ++++++++++++++----- ui/src/pages/taxa-lists/taxa-lists.tsx | 1 - 5 files changed, 43 insertions(+), 19 deletions(-) diff --git a/ami/main/api/views.py b/ami/main/api/views.py index 6a72091a4..0cbc41c9a 100644 --- a/ami/main/api/views.py +++ b/ami/main/api/views.py @@ -1613,6 +1613,8 @@ class TaxaListViewSet(DefaultViewSet, ProjectMixin): serializer_class = TaxaListSerializer ordering_fields = [ "name", + "description", + "annotated_taxa_count", "created_at", "updated_at", ] diff --git a/ui/src/data-services/models/taxa-list.ts b/ui/src/data-services/models/taxa-list.ts index c0524adfa..bafd01781 100644 --- a/ui/src/data-services/models/taxa-list.ts +++ b/ui/src/data-services/models/taxa-list.ts @@ -1,16 +1,21 @@ import { Entity, ServerEntity } from 'data-services/models/entity' export type ServerTaxaList = ServerEntity & { - taxa: string // URL to taxa API endpoint (filtered by this taxa list) 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 taxaCount() { + return this._taxaList.taxa_count + } } diff --git a/ui/src/pages/project/entities/new-entity-dialog.tsx b/ui/src/pages/project/entities/new-entity-dialog.tsx index 396383433..1fc4506ce 100644 --- a/ui/src/pages/project/entities/new-entity-dialog.tsx +++ b/ui/src/pages/project/entities/new-entity-dialog.tsx @@ -1,4 +1,5 @@ import classNames from 'classnames' +import { API_ROUTES } from 'data-services/constants' import { useCreateEntity } from 'data-services/hooks/entities/useCreateEntity' import * as Dialog from 'design-system/components/dialog/dialog' import { PlusIcon } from 'lucide-react' @@ -16,14 +17,12 @@ export const NewEntityDialog = ({ buttonSize = 'small', buttonVariant = 'outline', collection, - global, isCompact, type, }: { buttonSize?: string buttonVariant?: string collection: string - global?: boolean isCompact?: boolean type: string }) => { @@ -66,14 +65,20 @@ export const NewEntityDialog = ({ isLoading={isLoading} isSuccess={isSuccess} onSubmit={(data) => { - createEntity({ + const fieldValues = { ...data, projectId: projectId as string, - customFields: { - ...data.customFields, - ...(global ? { projects: [projectId as string] } : {}), // Some entities are shared across projects - }, - }) + } + + // Taxa lists require some custom handling since a global entity + if (collection === API_ROUTES.TAXA_LISTS) { + fieldValues.customFields = { + projects: [projectId as string], + taxa: [], + } + } + + createEntity(fieldValues) }} /> diff --git a/ui/src/pages/taxa-lists/taxa-list-columns.tsx b/ui/src/pages/taxa-lists/taxa-list-columns.tsx index 5bcd6e744..6f17a479a 100644 --- a/ui/src/pages/taxa-lists/taxa-list-columns.tsx +++ b/ui/src/pages/taxa-lists/taxa-list-columns.tsx @@ -1,7 +1,11 @@ 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 } from 'design-system/components/table/types' +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' @@ -16,6 +20,21 @@ export const columns: (projectId: string) => TableColumn[] = ( 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) => ( TableColumn[] = ( filters: { include_unobserved: 'true', taxa_list_id: item.id }, })} > - + ), }, - { - id: 'description', - name: translate(STRING.FIELD_LABEL_DESCRIPTION), - sortField: 'description', - renderCell: (item: TaxaList) => , - }, { id: 'created-at', name: translate(STRING.FIELD_LABEL_CREATED_AT), diff --git a/ui/src/pages/taxa-lists/taxa-lists.tsx b/ui/src/pages/taxa-lists/taxa-lists.tsx index 938e305c3..41ed64a67 100644 --- a/ui/src/pages/taxa-lists/taxa-lists.tsx +++ b/ui/src/pages/taxa-lists/taxa-lists.tsx @@ -56,7 +56,6 @@ export const TaxaLists = () => { {canCreate && ( From b126349643c38ad88bfff1afc3dd9df58cb3a9c5 Mon Sep 17 00:00:00 2001 From: Anna Viklund Date: Fri, 16 Jan 2026 13:34:14 +0100 Subject: [PATCH 08/38] feat: setup form for adding taxon to taxa list --- ui/src/components/taxon-search/add-taxon.tsx | 7 ++- .../add-taxon/add-taxon-popover.tsx | 27 +++++++++ .../pages/taxa-lists/add-taxon/add-taxon.tsx | 58 +++++++++++++++++++ ui/src/pages/taxa-lists/taxa-list-columns.tsx | 4 +- ui/src/utils/language.ts | 6 ++ 5 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 ui/src/pages/taxa-lists/add-taxon/add-taxon-popover.tsx create mode 100644 ui/src/pages/taxa-lists/add-taxon/add-taxon.tsx 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/pages/taxa-lists/add-taxon/add-taxon-popover.tsx b/ui/src/pages/taxa-lists/add-taxon/add-taxon-popover.tsx new file mode 100644 index 000000000..103dcbe93 --- /dev/null +++ b/ui/src/pages/taxa-lists/add-taxon/add-taxon-popover.tsx @@ -0,0 +1,27 @@ +import { PlusIcon } from 'lucide-react' +import { Button, Popover } from 'nova-ui-kit' +import { useState } from 'react' +import { STRING, translate } from 'utils/language' +import { AddTaxon } from './add-taxon' + +export const AddTaxonPopover = ({ taxaListId }: { taxaListId: string }) => { + const [open, setOpen] = useState(false) + + return ( + + + + + + setOpen(false)} taxaListId={taxaListId} /> + + + ) +} diff --git a/ui/src/pages/taxa-lists/add-taxon/add-taxon.tsx b/ui/src/pages/taxa-lists/add-taxon/add-taxon.tsx new file mode 100644 index 000000000..de5044d38 --- /dev/null +++ b/ui/src/pages/taxa-lists/add-taxon/add-taxon.tsx @@ -0,0 +1,58 @@ +import { FormError } from 'components/form/layout/layout' +import { TaxonSelect } from 'components/taxon-search/taxon-select' +import { Taxon } from 'data-services/models/taxa' +import { CheckIcon, Loader2Icon } from 'lucide-react' +import { Button } from 'nova-ui-kit' +import { useState } from 'react' +import { STRING, translate } from 'utils/language' +import { parseServerError } from 'utils/parseServerError/parseServerError' + +interface AddTaxonProps { + onCancel: () => void + taxaListId: string +} + +export const AddTaxon = ({ onCancel }: AddTaxonProps) => { + const [taxon, setTaxon] = useState() + const { error, isLoading, isSuccess } = { + error: undefined, + isLoading: false, + isSuccess: false, + } + const formError = error ? parseServerError(error)?.message : undefined + + return ( + <> + {formError && ( + + )} +
+
+ +
+
+ + +
+
+ + ) +} diff --git a/ui/src/pages/taxa-lists/taxa-list-columns.tsx b/ui/src/pages/taxa-lists/taxa-list-columns.tsx index 6f17a479a..1153b41ff 100644 --- a/ui/src/pages/taxa-lists/taxa-list-columns.tsx +++ b/ui/src/pages/taxa-lists/taxa-list-columns.tsx @@ -12,6 +12,7 @@ import { Link } from 'react-router-dom' import { APP_ROUTES } from 'utils/constants' import { getAppRoute } from 'utils/getAppRoute' import { STRING, translate } from 'utils/language' +import { AddTaxonPopover } from './add-taxon/add-taxon-popover' export const columns: (projectId: string) => TableColumn[] = ( projectId: string @@ -69,14 +70,13 @@ export const columns: (projectId: string) => TableColumn[] = ( }, renderCell: (item: TaxaList) => (
- {/* TODO: Check user permissions when returned by API */} + - Date: Mon, 19 Jan 2026 17:00:31 +0100 Subject: [PATCH 09/38] feat: make "Taxa lists" available from sidebar --- ui/src/app.tsx | 2 +- ui/src/pages/project/sidebar/useSidebarSections.tsx | 5 +++++ ui/src/utils/constants.ts | 3 +++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/ui/src/app.tsx b/ui/src/app.tsx index 686a37469..4da9deb11 100644 --- a/ui/src/app.tsx +++ b/ui/src/app.tsx @@ -112,6 +112,7 @@ export const App = () => ( } /> } /> } /> + } /> } /> ( } /> } /> } /> - } /> `/projects/${params.projectId}/taxa`, + TAXA_LISTS: (params: { projectId: string }) => + `/projects/${params.projectId}/taxa-lists`, + TAXON_DETAILS: (params: { projectId: string; taxonId: string }) => `/projects/${params.projectId}/taxa/${params.taxonId}`, } From 0e8a09f3f419d88f98ecdd83b4226ce5cda295e1 Mon Sep 17 00:00:00 2001 From: Anna Viklund Date: Mon, 19 Jan 2026 17:01:09 +0100 Subject: [PATCH 10/38] fix: conditionally render taxa list actions based on user permissions --- ui/src/pages/taxa-lists/taxa-list-columns.tsx | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/ui/src/pages/taxa-lists/taxa-list-columns.tsx b/ui/src/pages/taxa-lists/taxa-list-columns.tsx index 1153b41ff..a11d68e4f 100644 --- a/ui/src/pages/taxa-lists/taxa-list-columns.tsx +++ b/ui/src/pages/taxa-lists/taxa-list-columns.tsx @@ -70,18 +70,22 @@ export const columns: (projectId: string) => TableColumn[] = ( }, renderCell: (item: TaxaList) => (
- - - + {item.canUpdate ? : null} + {item.canUpdate ? ( + + ) : null} + {item.canDelete ? ( + + ) : null}
), }, From 4aec469e18442a04b49a4063710f5a316fe1fe15 Mon Sep 17 00:00:00 2001 From: Anna Viklund Date: Tue, 20 Jan 2026 14:09:24 +0100 Subject: [PATCH 11/38] feat: setup new page for taxa list details --- ui/src/app.tsx | 2 + .../hooks/taxa-lists/useTaxaListDetails.ts | 35 +++++ .../project/sidebar/useSidebarSections.tsx | 4 + .../add-taxon/add-taxon-popover.tsx | 0 .../add-taxon/add-taxon.tsx | 0 .../taxa-list-details-columns.tsx | 68 +++++++++ .../taxa-list-details/taxa-list-details.tsx | 133 ++++++++++++++++++ ui/src/pages/taxa-lists/taxa-list-columns.tsx | 15 +- ui/src/utils/constants.ts | 3 + 9 files changed, 258 insertions(+), 2 deletions(-) create mode 100644 ui/src/data-services/hooks/taxa-lists/useTaxaListDetails.ts rename ui/src/pages/{taxa-lists => taxa-list-details}/add-taxon/add-taxon-popover.tsx (100%) rename ui/src/pages/{taxa-lists => taxa-list-details}/add-taxon/add-taxon.tsx (100%) create mode 100644 ui/src/pages/taxa-list-details/taxa-list-details-columns.tsx create mode 100644 ui/src/pages/taxa-list-details/taxa-list-details.tsx diff --git a/ui/src/app.tsx b/ui/src/app.tsx index 4da9deb11..4f25e95ea 100644 --- a/ui/src/app.tsx +++ b/ui/src/app.tsx @@ -39,6 +39,7 @@ 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' @@ -113,6 +114,7 @@ export const App = () => ( } /> } /> } /> + } /> } /> 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, 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/pages/project/sidebar/useSidebarSections.tsx b/ui/src/pages/project/sidebar/useSidebarSections.tsx index 1760081c2..10a3ff2f3 100644 --- a/ui/src/pages/project/sidebar/useSidebarSections.tsx +++ b/ui/src/pages/project/sidebar/useSidebarSections.tsx @@ -30,6 +30,10 @@ const getSidebarSections = ( 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', diff --git a/ui/src/pages/taxa-lists/add-taxon/add-taxon-popover.tsx b/ui/src/pages/taxa-list-details/add-taxon/add-taxon-popover.tsx similarity index 100% rename from ui/src/pages/taxa-lists/add-taxon/add-taxon-popover.tsx rename to ui/src/pages/taxa-list-details/add-taxon/add-taxon-popover.tsx diff --git a/ui/src/pages/taxa-lists/add-taxon/add-taxon.tsx b/ui/src/pages/taxa-list-details/add-taxon/add-taxon.tsx similarity index 100% rename from ui/src/pages/taxa-lists/add-taxon/add-taxon.tsx rename to ui/src/pages/taxa-list-details/add-taxon/add-taxon.tsx 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..df4a8f549 --- /dev/null +++ b/ui/src/pages/taxa-list-details/taxa-list-details-columns.tsx @@ -0,0 +1,68 @@ +import { Tag } from 'components/taxon-tags/tag' +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' + +export const columns: (project: { + projectId: string + featureFlags?: { [key: string]: boolean } +}) => TableColumn[] = ({ projectId, featureFlags }) => [ + { + id: 'cover-image', + name: 'Cover image', + sortField: 'cover_image_url', + renderCell: (item: Species) => { + return ( + + ) + }, + }, + { + id: 'name', + sortField: 'name', + name: translate(STRING.FIELD_LABEL_TAXON), + renderCell: (item: Species) => ( + +
+ + + + {featureFlags?.tags && item.tags.length ? ( +
+ {item.tags.map((tag) => ( + + ))} +
+ ) : null} +
+
+ ), + }, + { + id: 'rank', + name: 'Taxon rank', + styles: { + textAlign: TextAlign.Right, + }, + renderCell: (item: Species) => , + }, +] 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..e480a1466 --- /dev/null +++ b/ui/src/pages/taxa-list-details/taxa-list-details.tsx @@ -0,0 +1,133 @@ +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 { AddTaxonPopover } from './add-taxon/add-taxon-popover' +import { columns } from './taxa-list-details-columns' + +export const TaxaListDetails = () => { + const { projectId, id } = 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' }], + }) + + useEffect(() => { + setDetailBreadcrumb( + taxaList ? { title: taxaList.name } : { title: 'Loading...' } + ) + + return () => { + setDetailBreadcrumb(undefined) + } + }, [taxaList]) + + return ( + <> + + + + +
+ + {species?.length ? ( + + ) : null} + + + ) +} + +const SpeciesDetailsDialog = ({ id }: { id: string }) => { + const navigate = useNavigate() + const { selectedView, setSelectedView } = useSelectedView(TABS.FIELDS, 'tab') + const { projectId } = useParams() + const { setDetailBreadcrumb } = useContext(BreadcrumbContext) + const { species, isLoading, error } = useSpeciesDetails(id, projectId) + + useEffect(() => { + setDetailBreadcrumb(species ? { title: species.name } : undefined) + + return () => { + setDetailBreadcrumb(undefined) + } + }, [species]) + + return ( + { + if (!open) { + setSelectedView(undefined) + } + + navigate( + getAppRoute({ + to: APP_ROUTES.TAXA({ projectId: projectId as string }), + 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 index a11d68e4f..05a90fa7e 100644 --- a/ui/src/pages/taxa-lists/taxa-list-columns.tsx +++ b/ui/src/pages/taxa-lists/taxa-list-columns.tsx @@ -12,7 +12,7 @@ import { Link } from 'react-router-dom' import { APP_ROUTES } from 'utils/constants' import { getAppRoute } from 'utils/getAppRoute' import { STRING, translate } from 'utils/language' -import { AddTaxonPopover } from './add-taxon/add-taxon-popover' +import { AddTaxonPopover } from '../taxa-list-details/add-taxon/add-taxon-popover' export const columns: (projectId: string) => TableColumn[] = ( projectId: string @@ -21,7 +21,18 @@ export const columns: (projectId: string) => TableColumn[] = ( id: 'name', name: translate(STRING.FIELD_LABEL_NAME), sortField: 'name', - renderCell: (item: TaxaList) => , + renderCell: (item: TaxaList) => ( + + + + ), }, { id: 'description', diff --git a/ui/src/utils/constants.ts b/ui/src/utils/constants.ts index 8929fbe6f..1338ea709 100644 --- a/ui/src/utils/constants.ts +++ b/ui/src/utils/constants.ts @@ -96,6 +96,9 @@ export const APP_ROUTES = { 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}`, + TAXON_DETAILS: (params: { projectId: string; taxonId: string }) => `/projects/${params.projectId}/taxa/${params.taxonId}`, } From 4bfec3d9908580f36e8214e918f2cfd77b24159d Mon Sep 17 00:00:00 2001 From: Anna Viklund Date: Tue, 20 Jan 2026 14:10:20 +0100 Subject: [PATCH 12/38] fix: tweak breadcrumb logic to show sidebar sub pages --- ui/src/components/breadcrumbs/breadcrumbs.tsx | 4 +--- ui/src/pages/project/sidebar/sidebar.tsx | 6 +++--- ui/src/pages/taxa-lists/taxa-lists.tsx | 11 ----------- 3 files changed, 4 insertions(+), 17 deletions(-) diff --git a/ui/src/components/breadcrumbs/breadcrumbs.tsx b/ui/src/components/breadcrumbs/breadcrumbs.tsx index 49d79ca6a..4dcb3c589 100644 --- a/ui/src/components/breadcrumbs/breadcrumbs.tsx +++ b/ui/src/components/breadcrumbs/breadcrumbs.tsx @@ -22,9 +22,7 @@ export const Breadcrumbs = ({ } = useContext(BreadcrumbContext) useEffect(() => { - if (activeNavItem.id === 'project') { - setMainBreadcrumb(undefined) - } else { + if (activeNavItem.id !== 'project') { setMainBreadcrumb(activeNavItem) } 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/taxa-lists/taxa-lists.tsx b/ui/src/pages/taxa-lists/taxa-lists.tsx index 41ed64a67..150ef0e30 100644 --- a/ui/src/pages/taxa-lists/taxa-lists.tsx +++ b/ui/src/pages/taxa-lists/taxa-lists.tsx @@ -5,9 +5,7 @@ import { PaginationBar } from 'design-system/components/pagination-bar/paginatio 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 { useContext, useEffect } from 'react' import { useParams } from 'react-router-dom' -import { BreadcrumbContext } from 'utils/breadcrumbContext' import { STRING, translate } from 'utils/language' import { usePagination } from 'utils/usePagination' import { UserPermission } from 'utils/user/types' @@ -15,7 +13,6 @@ import { useSort } from 'utils/useSort' import { columns } from './taxa-list-columns' export const TaxaLists = () => { - const { setDetailBreadcrumb } = useContext(BreadcrumbContext) const { projectId, id } = useParams() const { sort, setSort } = useSort({ field: 'name', @@ -30,14 +27,6 @@ export const TaxaLists = () => { }) const canCreate = userPermissions?.includes(UserPermission.Create) - useEffect(() => { - setDetailBreadcrumb({ title: translate(STRING.NAV_ITEM_TAXA_LISTS) }) - - return () => { - setDetailBreadcrumb(undefined) - } - }, []) - return ( <> Date: Tue, 20 Jan 2026 14:55:46 +0100 Subject: [PATCH 13/38] fix: add taxa list filter --- ui/src/pages/taxa-list-details/taxa-list-details.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ui/src/pages/taxa-list-details/taxa-list-details.tsx b/ui/src/pages/taxa-list-details/taxa-list-details.tsx index e480a1466..4e9a85d62 100644 --- a/ui/src/pages/taxa-list-details/taxa-list-details.tsx +++ b/ui/src/pages/taxa-list-details/taxa-list-details.tsx @@ -30,7 +30,10 @@ export const TaxaListDetails = () => { projectId, sort, pagination, - filters: [{ field: 'include_unobserved', value: 'true' }], + filters: [ + { field: 'include_unobserved', value: 'true' }, + { field: 'taxa_list_id', value: id }, + ], }) useEffect(() => { From 3fdbbd825745e188af8d91bfd0c74ccaa0f9f899 Mon Sep 17 00:00:00 2001 From: mohamedelabbas1996 Date: Thu, 22 Jan 2026 02:59:22 -0500 Subject: [PATCH 14/38] feat: added add_taxon and remove_taxon endpoints to TaxaListViewSet Co-Authored-By: Claude --- ami/main/api/views.py | 50 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/ami/main/api/views.py b/ami/main/api/views.py index 0cbc41c9a..a253d25d4 100644 --- a/ami/main/api/views.py +++ b/ami/main/api/views.py @@ -1628,6 +1628,56 @@ def get_queryset(self): return qs.filter(projects=project) return qs + def _get_taxon(self): + """ + Get taxon from the POST request body. + """ + key = "taxon_id" + taxon_id = SingleParamSerializer[int].clean( + key, + field=serializers.IntegerField(required=True, min_value=0), + data=self.request.data, + ) + + try: + return Taxon.objects.get(id=taxon_id) + except Taxon.DoesNotExist: + raise api_exceptions.NotFound(detail=f"Taxon with id {taxon_id} not found") + + @action(detail=True, methods=["post"], name="add_taxon") + def add_taxon(self, request, pk=None): + """ + Add a taxon to a taxa list. + """ + taxa_list: TaxaList = self.get_object() + taxon = self._get_taxon() + taxa_list.taxa.add(taxon) + + return Response( + { + "taxa_list_id": taxa_list.pk, + "taxon_id": taxon.pk, + "taxa_count": taxa_list.taxa.count(), + } + ) + + @action(detail=True, methods=["post"], name="remove_taxon") + def remove_taxon(self, request, pk=None): + """ + Remove a taxon from a taxa list. + """ + taxa_list: TaxaList = self.get_object() + taxon = self._get_taxon() + taxa_list.taxa.remove(taxon) + + return Response( + { + "taxa_list_id": taxa_list.pk, + "taxon_id": taxon.pk, + "taxa_count": taxa_list.taxa.count(), + } + ) + class TagViewSet(DefaultViewSet, ProjectMixin): queryset = Tag.objects.all() From f9a537819e7250b40d195e91e39cd8031b03c489 Mon Sep 17 00:00:00 2001 From: Anna Viklund Date: Fri, 23 Jan 2026 12:52:33 +0100 Subject: [PATCH 15/38] feat: make it possible to add taxa to lists from UI --- .../hooks/taxa-lists/useAddTaxaListTaxon.ts | 36 +++++++++++++++++++ .../hooks/taxa-lists/useTaxaListDetails.ts | 4 +-- .../add-taxa-list-taxon-popover.tsx} | 15 +++++--- .../add-taxa-list-taxon.tsx} | 28 +++++++++------ .../taxa-list-details/taxa-list-details.tsx | 4 +-- ui/src/pages/taxa-lists/taxa-list-columns.tsx | 6 ++-- 6 files changed, 72 insertions(+), 21 deletions(-) create mode 100644 ui/src/data-services/hooks/taxa-lists/useAddTaxaListTaxon.ts rename ui/src/pages/taxa-list-details/{add-taxon/add-taxon-popover.tsx => add-taxa-list-taxon/add-taxa-list-taxon-popover.tsx} (67%) rename ui/src/pages/taxa-list-details/{add-taxon/add-taxon.tsx => add-taxa-list-taxon/add-taxa-list-taxon.tsx} (70%) 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..b3784bdce --- /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}/add_taxon/?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/useTaxaListDetails.ts b/ui/src/data-services/hooks/taxa-lists/useTaxaListDetails.ts index 30f81240d..8dec88823 100644 --- a/ui/src/data-services/hooks/taxa-lists/useTaxaListDetails.ts +++ b/ui/src/data-services/hooks/taxa-lists/useTaxaListDetails.ts @@ -16,9 +16,7 @@ export const useTaxaListDetails = ( } => { const { data, isLoading, isFetching, error } = useAuthorizedQuery({ queryKey: [API_ROUTES.TAXA_LISTS, id], - url: `${API_URL}/${API_ROUTES.TAXA_LISTS}/${id}/?project_id=${ - projectId || '' - }`, + url: `${API_URL}/${API_ROUTES.TAXA_LISTS}/${id}/?project_id=${projectId}`, }) const taxaList = useMemo( diff --git a/ui/src/pages/taxa-list-details/add-taxon/add-taxon-popover.tsx b/ui/src/pages/taxa-list-details/add-taxa-list-taxon/add-taxa-list-taxon-popover.tsx similarity index 67% rename from ui/src/pages/taxa-list-details/add-taxon/add-taxon-popover.tsx rename to ui/src/pages/taxa-list-details/add-taxa-list-taxon/add-taxa-list-taxon-popover.tsx index 103dcbe93..4066a85c2 100644 --- a/ui/src/pages/taxa-list-details/add-taxon/add-taxon-popover.tsx +++ b/ui/src/pages/taxa-list-details/add-taxa-list-taxon/add-taxa-list-taxon-popover.tsx @@ -2,9 +2,13 @@ import { PlusIcon } from 'lucide-react' import { Button, Popover } from 'nova-ui-kit' import { useState } from 'react' import { STRING, translate } from 'utils/language' -import { AddTaxon } from './add-taxon' +import { AddTaxaListTaxon } from './add-taxa-list-taxon' -export const AddTaxonPopover = ({ taxaListId }: { taxaListId: string }) => { +export const AddTaxaListTaxonPopover = ({ + taxaListId, +}: { + taxaListId: string +}) => { const [open, setOpen] = useState(false) return ( @@ -19,8 +23,11 @@ export const AddTaxonPopover = ({ taxaListId }: { taxaListId: string }) => { - - setOpen(false)} taxaListId={taxaListId} /> + + setOpen(false)} + taxaListId={taxaListId} + /> ) diff --git a/ui/src/pages/taxa-list-details/add-taxon/add-taxon.tsx b/ui/src/pages/taxa-list-details/add-taxa-list-taxon/add-taxa-list-taxon.tsx similarity index 70% rename from ui/src/pages/taxa-list-details/add-taxon/add-taxon.tsx rename to ui/src/pages/taxa-list-details/add-taxa-list-taxon/add-taxa-list-taxon.tsx index de5044d38..5cf6dba8f 100644 --- a/ui/src/pages/taxa-list-details/add-taxon/add-taxon.tsx +++ b/ui/src/pages/taxa-list-details/add-taxa-list-taxon/add-taxa-list-taxon.tsx @@ -1,24 +1,27 @@ 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' -interface AddTaxonProps { +export const AddTaxaListTaxon = ({ + onCancel, + taxaListId, +}: { onCancel: () => void taxaListId: string -} - -export const AddTaxon = ({ onCancel }: AddTaxonProps) => { +}) => { + const { projectId } = useParams() const [taxon, setTaxon] = useState() - const { error, isLoading, isSuccess } = { - error: undefined, - isLoading: false, - isSuccess: false, - } + const { addTaxaListTaxon, error, isLoading, isSuccess } = useAddTaxaListTaxon( + projectId as string + ) const formError = error ? parseServerError(error)?.message : undefined return ( @@ -40,7 +43,12 @@ export const AddTaxon = ({ onCancel }: AddTaxonProps) => {
TableColumn[] = ( projectId: string @@ -81,7 +81,9 @@ export const columns: (projectId: string) => TableColumn[] = ( }, renderCell: (item: TaxaList) => (
- {item.canUpdate ? : null} + {item.canUpdate ? ( + + ) : null} {item.canUpdate ? ( Date: Fri, 23 Jan 2026 12:52:50 +0100 Subject: [PATCH 16/38] feat: make it possible to remove taxa from lists from UI --- .../taxa-lists/useRemoveTaxaListTaxon.ts | 36 +++++++++ .../remove-taxa-list-taxon-dialog.tsx | 78 +++++++++++++++++++ .../taxa-list-details-columns.tsx | 26 ++++--- .../taxa-list-details/taxa-list-details.tsx | 6 +- ui/src/utils/language.ts | 5 ++ 5 files changed, 140 insertions(+), 11 deletions(-) create mode 100644 ui/src/data-services/hooks/taxa-lists/useRemoveTaxaListTaxon.ts create mode 100644 ui/src/pages/taxa-list-details/remove-taxa-list-taxon/remove-taxa-list-taxon-dialog.tsx 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..3dcefd09a --- /dev/null +++ b/ui/src/data-services/hooks/taxa-lists/useRemoveTaxaListTaxon.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 useRemoveTaxaListTaxon = (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}/remove_taxon/?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 { removeTaxaListTaxon: mutateAsync, isLoading, error, isSuccess } +} 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..f98306c84 --- /dev/null +++ b/ui/src/pages/taxa-list-details/remove-taxa-list-taxon/remove-taxa-list-taxon-dialog.tsx @@ -0,0 +1,78 @@ +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 index df4a8f549..7ed600707 100644 --- a/ui/src/pages/taxa-list-details/taxa-list-details-columns.tsx +++ b/ui/src/pages/taxa-list-details/taxa-list-details-columns.tsx @@ -1,4 +1,3 @@ -import { Tag } from 'components/taxon-tags/tag' 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' @@ -12,11 +11,12 @@ 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: (project: { projectId: string - featureFlags?: { [key: string]: boolean } -}) => TableColumn[] = ({ projectId, featureFlags }) => [ + taxaListId: string +}) => TableColumn[] = ({ projectId, taxaListId }) => [ { id: 'cover-image', name: 'Cover image', @@ -46,13 +46,6 @@ export const columns: (project: { > - {featureFlags?.tags && item.tags.length ? ( -
- {item.tags.map((tag) => ( - - ))} -
- ) : null}
), @@ -65,4 +58,17 @@ export const columns: (project: { }, renderCell: (item: Species) => , }, + { + id: 'actions', + name: '', + styles: { + padding: '16px', + width: '100%', + }, + renderCell: (item: Species) => ( +
+ +
+ ), + }, ] diff --git a/ui/src/pages/taxa-list-details/taxa-list-details.tsx b/ui/src/pages/taxa-list-details/taxa-list-details.tsx index 579719825..b51eb1dbd 100644 --- a/ui/src/pages/taxa-list-details/taxa-list-details.tsx +++ b/ui/src/pages/taxa-list-details/taxa-list-details.tsx @@ -57,7 +57,10 @@ export const TaxaListDetails = () => { title={taxaList?.name ?? `${translate(STRING.LOADING_DATA)}...`} > @@ -66,6 +69,7 @@ export const TaxaListDetails = () => {
Date: Fri, 23 Jan 2026 15:38:12 +0100 Subject: [PATCH 17/38] feat: make it possible to see taxon details without changing route --- ui/src/app.tsx | 4 +++ ui/src/pages/species/species-columns.tsx | 5 ++- .../taxa-list-details-columns.tsx | 15 +++++++-- .../taxa-list-details/taxa-list-details.tsx | 31 ++++++++++--------- ui/src/utils/constants.ts | 7 +++++ 5 files changed, 45 insertions(+), 17 deletions(-) diff --git a/ui/src/app.tsx b/ui/src/app.tsx index 5b9f0054e..a562efc24 100644 --- a/ui/src/app.tsx +++ b/ui/src/app.tsx @@ -116,6 +116,10 @@ export const App = () => ( } /> } /> } /> + } + /> } /> ) }, 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 index 7ed600707..e4a8be6e6 100644 --- a/ui/src/pages/taxa-list-details/taxa-list-details-columns.tsx +++ b/ui/src/pages/taxa-list-details/taxa-list-details-columns.tsx @@ -26,7 +26,14 @@ export const columns: (project: { ) }, @@ -40,7 +47,11 @@ export const columns: (project: {
diff --git a/ui/src/pages/taxa-list-details/taxa-list-details.tsx b/ui/src/pages/taxa-list-details/taxa-list-details.tsx index b51eb1dbd..bd8d51be9 100644 --- a/ui/src/pages/taxa-list-details/taxa-list-details.tsx +++ b/ui/src/pages/taxa-list-details/taxa-list-details.tsx @@ -21,7 +21,7 @@ import { AddTaxaListTaxonPopover } from './add-taxa-list-taxon/add-taxa-list-tax import { columns } from './taxa-list-details-columns' export const TaxaListDetails = () => { - const { projectId, id } = useParams() + const { projectId, id, taxonId } = useParams() const { setDetailBreadcrumb } = useContext(BreadcrumbContext) const { sort, setSort } = useSort({ field: 'name', order: 'asc' }) const { pagination, setPage } = usePagination() @@ -87,28 +87,28 @@ export const TaxaListDetails = () => { /> ) : null} + {taxonId ? ( + + ) : null} ) } -const SpeciesDetailsDialog = ({ id }: { id: string }) => { +const SpeciesDetailsDialog = ({ + taxaListId, + taxonId, +}: { + taxaListId: string + taxonId: string +}) => { const navigate = useNavigate() const { selectedView, setSelectedView } = useSelectedView(TABS.FIELDS, 'tab') const { projectId } = useParams() - const { setDetailBreadcrumb } = useContext(BreadcrumbContext) - const { species, isLoading, error } = useSpeciesDetails(id, projectId) - - useEffect(() => { - setDetailBreadcrumb(species ? { title: species.name } : undefined) - - return () => { - setDetailBreadcrumb(undefined) - } - }, [species]) + const { species, isLoading, error } = useSpeciesDetails(taxonId, projectId) return ( { if (!open) { setSelectedView(undefined) @@ -116,7 +116,10 @@ const SpeciesDetailsDialog = ({ id }: { id: string }) => { navigate( getAppRoute({ - to: APP_ROUTES.TAXA({ projectId: projectId as string }), + to: APP_ROUTES.TAXA_LIST_DETAILS({ + projectId: projectId as string, + taxaListId, + }), keepSearchParams: true, }) ) diff --git a/ui/src/utils/constants.ts b/ui/src/utils/constants.ts index 455620dc6..a65716caf 100644 --- a/ui/src/utils/constants.ts +++ b/ui/src/utils/constants.ts @@ -99,6 +99,13 @@ export const APP_ROUTES = { 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}`, From 1d3e7a5235e323c0387127237c5f65a804512d4b Mon Sep 17 00:00:00 2001 From: Anna Viklund Date: Fri, 23 Jan 2026 15:43:04 +0100 Subject: [PATCH 18/38] fix: cleanup --- ui/src/pages/species/species-columns.tsx | 2 +- .../taxa-list-details-columns.tsx | 28 +++++++++---------- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/ui/src/pages/species/species-columns.tsx b/ui/src/pages/species/species-columns.tsx index 313ff85ed..86ccd7036 100644 --- a/ui/src/pages/species/species-columns.tsx +++ b/ui/src/pages/species/species-columns.tsx @@ -21,7 +21,7 @@ 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/taxa-list-details/taxa-list-details-columns.tsx b/ui/src/pages/taxa-list-details/taxa-list-details-columns.tsx index e4a8be6e6..734e9d87d 100644 --- a/ui/src/pages/taxa-list-details/taxa-list-details-columns.tsx +++ b/ui/src/pages/taxa-list-details/taxa-list-details-columns.tsx @@ -19,7 +19,7 @@ export const columns: (project: { }) => TableColumn[] = ({ projectId, taxaListId }) => [ { id: 'cover-image', - name: 'Cover image', + name: translate(STRING.FIELD_LABEL_IMAGE), sortField: 'cover_image_url', renderCell: (item: Species) => { return ( @@ -44,20 +44,18 @@ export const columns: (project: { name: translate(STRING.FIELD_LABEL_TAXON), renderCell: (item: Species) => ( -
- - - -
+ + +
), }, From 84cb25b1b34d870c86f7e1e14b83c6026453d131 Mon Sep 17 00:00:00 2001 From: Anna Viklund Date: Fri, 23 Jan 2026 16:37:04 +0100 Subject: [PATCH 19/38] fix: hide remove button if user cannot update taxa list --- .../taxa-list-details/taxa-list-details-columns.tsx | 12 +++++++++--- ui/src/pages/taxa-list-details/taxa-list-details.tsx | 2 ++ 2 files changed, 11 insertions(+), 3 deletions(-) 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 index 734e9d87d..777b1031d 100644 --- a/ui/src/pages/taxa-list-details/taxa-list-details-columns.tsx +++ b/ui/src/pages/taxa-list-details/taxa-list-details-columns.tsx @@ -13,10 +13,11 @@ 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: (project: { +export const columns: (params: { + canUpdate?: boolean projectId: string taxaListId: string -}) => TableColumn[] = ({ projectId, taxaListId }) => [ +}) => TableColumn[] = ({ canUpdate, projectId, taxaListId }) => [ { id: 'cover-image', name: translate(STRING.FIELD_LABEL_IMAGE), @@ -76,7 +77,12 @@ export const columns: (project: { }, 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 index bd8d51be9..5973256e7 100644 --- a/ui/src/pages/taxa-list-details/taxa-list-details.tsx +++ b/ui/src/pages/taxa-list-details/taxa-list-details.tsx @@ -58,6 +58,7 @@ export const TaxaListDetails = () => { > {
Date: Fri, 23 Jan 2026 16:58:06 +0100 Subject: [PATCH 20/38] fix: prevent tooltip auto focus in dialogs --- ui/src/pages/occurrences/occurrences.tsx | 6 +++++- ui/src/pages/species/species.tsx | 6 +++++- ui/src/pages/taxa-list-details/taxa-list-details.tsx | 6 +++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/ui/src/pages/occurrences/occurrences.tsx b/ui/src/pages/occurrences/occurrences.tsx index 31cb53d7b..7611306c8 100644 --- a/ui/src/pages/occurrences/occurrences.tsx +++ b/ui/src/pages/occurrences/occurrences.tsx @@ -276,8 +276,12 @@ const OccurrenceDetailsDialog = ({ > { + /* Prevent tooltip auto focus */ + e.preventDefault() + }} > {occurrence ? ( { > { + /* Prevent tooltip auto focus */ + e.preventDefault() + }} > {species ? ( { + /* Prevent tooltip auto focus */ + e.preventDefault() + }} > {species ? ( Date: Tue, 27 Jan 2026 17:10:28 -0800 Subject: [PATCH 21/38] refactor: use nested routes for taxa list management (without through model) Refactors taxa list endpoints to use nested routes and proper HTTP methods while keeping the simple ManyToManyField (no through model). **Backend changes:** - Created TaxaListTaxonViewSet with nested routes under /taxa/lists/{id}/taxa/ - Simple serializers for input validation and output - Removed deprecated add_taxon/remove_taxon actions from TaxaListViewSet - Uses Django M2M .add() and .remove() methods directly **Frontend changes:** - Updated useAddTaxaListTaxon to POST to /taxa/ endpoint - Updated useRemoveTaxaListTaxon to use DELETE method **API changes:** - POST /taxa/lists/{id}/taxa/ - Add taxon (returns 201, 400 for duplicates) - DELETE /taxa/lists/{id}/taxa/by-taxon/{taxon_id}/ - Remove taxon (returns 204, 404 for non-existent) - GET /taxa/lists/{id}/taxa/ - List taxa in list - Removed POST /taxa/lists/{id}/add_taxon/ (deprecated) - Removed POST /taxa/lists/{id}/remove_taxon/ (deprecated) **Benefits:** - Same API as project membership (consistency) - No migration needed (keeps existing simple M2M) - Proper HTTP semantics (POST=201, DELETE=204) - RESTful nested resource design **Tests:** - Added comprehensive test suite (13 tests, all passing) - Tests for CRUD operations, validation, and error cases Co-Authored-By: Claude Sonnet 4.5 --- ami/main/api/serializers.py | 18 +++ ami/main/api/views.py | 101 ++++++++----- ami/main/tests/__init__.py | 1 + ami/main/tests/test_taxa_list_taxa_api.py | 140 ++++++++++++++++++ config/api_router.py | 9 +- .../hooks/taxa-lists/useAddTaxaListTaxon.ts | 2 +- .../taxa-lists/useRemoveTaxaListTaxon.ts | 7 +- 7 files changed, 231 insertions(+), 47 deletions(-) create mode 100644 ami/main/tests/__init__.py create mode 100644 ami/main/tests/test_taxa_list_taxa_api.py diff --git a/ami/main/api/serializers.py b/ami/main/api/serializers.py index b2316be69..83352eab6 100644 --- a/ami/main/api/serializers.py +++ b/ami/main/api/serializers.py @@ -669,6 +669,24 @@ def get_taxa_count(self, obj): return getattr(obj, "annotated_taxa_count", obj.taxa.count()) +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) parents = TaxonParentSerializer(many=True, read_only=True) diff --git a/ami/main/api/views.py b/ami/main/api/views.py index a253d25d4..2cdea19eb 100644 --- a/ami/main/api/views.py +++ b/ami/main/api/views.py @@ -83,6 +83,8 @@ StorageSourceSerializer, StorageStatusSerializer, TaxaListSerializer, + TaxaListTaxonInputSerializer, + TaxaListTaxonSerializer, TaxonListSerializer, TaxonSearchResultSerializer, TaxonSerializer, @@ -1628,55 +1630,74 @@ def get_queryset(self): return qs.filter(projects=project) return qs - def _get_taxon(self): - """ - Get taxon from the POST request body. - """ - key = "taxon_id" - taxon_id = SingleParamSerializer[int].clean( - key, - field=serializers.IntegerField(required=True, min_value=0), - data=self.request.data, - ) +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 = [] # Allow public access for now + + def get_taxa_list(self): + """Get the parent taxa list from URL parameters.""" + taxa_list_id = self.kwargs.get("taxalist_pk") try: - return Taxon.objects.get(id=taxon_id) - except Taxon.DoesNotExist: - raise api_exceptions.NotFound(detail=f"Taxon with id {taxon_id} not found") + return TaxaList.objects.get(pk=taxa_list_id) + except TaxaList.DoesNotExist: + raise api_exceptions.NotFound("Taxa list not found.") - @action(detail=True, methods=["post"], name="add_taxon") - def add_taxon(self, request, pk=None): - """ - Add a taxon to a taxa list. - """ - taxa_list: TaxaList = self.get_object() - taxon = self._get_taxon() + 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 = Taxon.objects.get(pk=taxon_id) taxa_list.taxa.add(taxon) - return Response( - { - "taxa_list_id": taxa_list.pk, - "taxon_id": taxon.pk, - "taxa_count": taxa_list.taxa.count(), - } - ) + # Return the added taxon + serializer = self.get_serializer(taxon) + return Response(serializer.data, status=status.HTTP_201_CREATED) - @action(detail=True, methods=["post"], name="remove_taxon") - def remove_taxon(self, request, pk=None): + @action(detail=False, methods=["delete"], url_path=r"by-taxon/(?P\d+)") + def delete_by_taxon(self, request, taxalist_pk=None, taxon_id=None): """ - Remove a taxon from a taxa list. + Remove a taxon from the taxa list by taxon ID. + DELETE /taxa/lists/{taxa_list_id}/taxa/by-taxon/{taxon_id}/ """ - taxa_list: TaxaList = self.get_object() - taxon = self._get_taxon() - taxa_list.taxa.remove(taxon) + taxa_list = self.get_taxa_list() - return Response( - { - "taxa_list_id": taxa_list.pk, - "taxon_id": taxon.pk, - "taxa_count": taxa_list.taxa.count(), - } - ) + # 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/tests/__init__.py b/ami/main/tests/__init__.py new file mode 100644 index 000000000..420b08f2b --- /dev/null +++ b/ami/main/tests/__init__.py @@ -0,0 +1 @@ +# Tests for ami.main app diff --git a/ami/main/tests/test_taxa_list_taxa_api.py b/ami/main/tests/test_taxa_list_taxa_api.py new file mode 100644 index 000000000..e168fd65b --- /dev/null +++ b/ami/main/tests/test_taxa_list_taxa_api.py @@ -0,0 +1,140 @@ +""" +Tests for TaxaList taxa management API endpoints (without through model). +""" +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from ami.main.models import Project, TaxaList, Taxon +from ami.users.models import User + + +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_list_taxa_in_list(self): + """Test listing taxa in a taxa list.""" + self.taxa_list.taxa.add(self.taxon1, self.taxon2) + + response = self.client.get(self.base_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 2) + taxon_ids = [item["id"] for item in response.data["results"]] + self.assertIn(self.taxon1.pk, taxon_ids) + self.assertIn(self.taxon2.pk, taxon_ids) + + 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/by-taxon/{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/by-taxon/{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_list_empty_taxa_list(self): + """Test listing taxa in an empty taxa list.""" + response = self.client.get(self.base_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 0) + self.assertEqual(response.data["results"], []) + + 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/by-taxon/{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 accessing non-existent taxa list returns 404.""" + url = f"/api/v2/taxa/lists/999999/taxa/?project_id={self.project.pk}" + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) diff --git a/config/api_router.py b/config/api_router.py index 13d4026e5..5a21a8753 100644 --- a/config/api_router.py +++ b/config/api_router.py @@ -35,6 +35,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) @@ -73,5 +80,5 @@ ] -urlpatterns += router.urls + projects_router.urls +urlpatterns += router.urls + projects_router.urls + taxa_lists_router.urls # diff --git a/ui/src/data-services/hooks/taxa-lists/useAddTaxaListTaxon.ts b/ui/src/data-services/hooks/taxa-lists/useAddTaxaListTaxon.ts index b3784bdce..410eed732 100644 --- a/ui/src/data-services/hooks/taxa-lists/useAddTaxaListTaxon.ts +++ b/ui/src/data-services/hooks/taxa-lists/useAddTaxaListTaxon.ts @@ -17,7 +17,7 @@ export const useAddTaxaListTaxon = (projectId: string) => { taxonId: string }) => axios.post( - `${API_URL}/${API_ROUTES.TAXA_LISTS}/${taxaListId}/add_taxon/?project_id=${projectId}`, + `${API_URL}/${API_ROUTES.TAXA_LISTS}/${taxaListId}/taxa/?project_id=${projectId}`, { taxon_id: taxonId, }, diff --git a/ui/src/data-services/hooks/taxa-lists/useRemoveTaxaListTaxon.ts b/ui/src/data-services/hooks/taxa-lists/useRemoveTaxaListTaxon.ts index 3dcefd09a..9daa59a70 100644 --- a/ui/src/data-services/hooks/taxa-lists/useRemoveTaxaListTaxon.ts +++ b/ui/src/data-services/hooks/taxa-lists/useRemoveTaxaListTaxon.ts @@ -16,11 +16,8 @@ export const useRemoveTaxaListTaxon = (projectId: string) => { taxaListId: string taxonId: string }) => - axios.post( - `${API_URL}/${API_ROUTES.TAXA_LISTS}/${taxaListId}/remove_taxon/?project_id=${projectId}`, - { - taxon_id: taxonId, - }, + axios.delete( + `${API_URL}/${API_ROUTES.TAXA_LISTS}/${taxaListId}/taxa/by-taxon/${taxonId}/?project_id=${projectId}`, { headers: getAuthHeader(user), } From 6aaf0ea41e4bfb73691f3818b083b1ee5aea83c0 Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Tue, 3 Feb 2026 16:48:20 -0800 Subject: [PATCH 22/38] fix: taxa list creation failing with m2m assignment error Fixes 500 error when creating taxa lists via the API. The error was caused by attempting to directly assign many-to-many fields during model instantiation, which Django does not allow. Also addresses a security issue discovered during the fix: users were able to assign taxa lists and processing services to arbitrary projects. Changes: - Make projects field read-only on TaxaListSerializer and ProcessingServiceSerializer - Add perform_create() methods to handle m2m project assignment after instance creation - Update UI to send project_id instead of project for proper backend detection - Remove client-side project field from taxa list creation Resources are now automatically assigned to the active project by the server, preventing the m2m assignment error and ensuring proper project scoping. --- ami/main/api/serializers.py | 9 +++++++- ami/main/api/views.py | 16 ++++++++++++++ ami/ml/serializers.py | 22 ++++++------------- ami/ml/views.py | 15 +++++++++++++ ui/src/data-services/hooks/entities/utils.ts | 2 +- .../project/entities/new-entity-dialog.tsx | 8 ------- 6 files changed, 47 insertions(+), 25 deletions(-) diff --git a/ami/main/api/serializers.py b/ami/main/api/serializers.py index 83352eab6..9885f06ec 100644 --- a/ami/main/api/serializers.py +++ b/ami/main/api/serializers.py @@ -636,7 +636,7 @@ def get_occurrences(self, obj): class TaxaListSerializer(DefaultSerializer): taxa = serializers.SerializerMethodField() taxa_count = serializers.SerializerMethodField() - projects = serializers.PrimaryKeyRelatedField(queryset=Project.objects.all(), many=True) + projects = serializers.SerializerMethodField() class Meta: model = TaxaList @@ -668,6 +668,13 @@ def get_taxa_count(self, obj): """ 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.""" diff --git a/ami/main/api/views.py b/ami/main/api/views.py index 2cdea19eb..e39ea982f 100644 --- a/ami/main/api/views.py +++ b/ami/main/api/views.py @@ -1620,6 +1620,7 @@ class TaxaListViewSet(DefaultViewSet, ProjectMixin): "created_at", "updated_at", ] + require_project = True def get_queryset(self): qs = super().get_queryset() @@ -1630,6 +1631,20 @@ def get_queryset(self): return qs.filter(projects=project) return qs + 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) + class TaxaListTaxonViewSet(viewsets.GenericViewSet, ProjectMixin): """ @@ -1639,6 +1654,7 @@ class TaxaListTaxonViewSet(viewsets.GenericViewSet, ProjectMixin): serializer_class = TaxaListTaxonSerializer permission_classes = [] # Allow public access for now + require_project = True def get_taxa_list(self): """Get the parent taxa list from URL parameters.""" diff --git a/ami/ml/serializers.py b/ami/ml/serializers.py index 8ebe50e0a..a0f955552 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 @@ -133,11 +132,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 @@ -147,7 +142,6 @@ class Meta: "name", "description", "projects", - "project", "endpoint_url", "pipelines", "created_at", @@ -156,11 +150,9 @@ 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)) diff --git a/ami/ml/views.py b/ami/ml/views.py index 4c0699b70..7340bf0d3 100644 --- a/ami/ml/views.py +++ b/ami/ml/views.py @@ -147,6 +147,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() @@ -173,6 +174,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/ui/src/data-services/hooks/entities/utils.ts b/ui/src/data-services/hooks/entities/utils.ts index a4723d4be..112b0a657 100644 --- a/ui/src/data-services/hooks/entities/utils.ts +++ b/ui/src/data-services/hooks/entities/utils.ts @@ -6,7 +6,7 @@ export const convertToServerFieldValues = (fieldValues: EntityFieldValues) => { return { ...(description ? { description } : {}), ...(name ? { name } : {}), - project: projectId, + project_id: projectId, ...(customFields ?? {}), } } diff --git a/ui/src/pages/project/entities/new-entity-dialog.tsx b/ui/src/pages/project/entities/new-entity-dialog.tsx index 1fc4506ce..c9a41ef8e 100644 --- a/ui/src/pages/project/entities/new-entity-dialog.tsx +++ b/ui/src/pages/project/entities/new-entity-dialog.tsx @@ -70,14 +70,6 @@ export const NewEntityDialog = ({ projectId: projectId as string, } - // Taxa lists require some custom handling since a global entity - if (collection === API_ROUTES.TAXA_LISTS) { - fieldValues.customFields = { - projects: [projectId as string], - taxa: [], - } - } - createEntity(fieldValues) }} /> From c591ae3dc1f1cbddf66e3a3352ae5fbc07f6eaa6 Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Tue, 3 Feb 2026 18:02:11 -0800 Subject: [PATCH 23/38] feat: new param to hide/show taxa children when filtering by taxa list --- ami/main/api/views.py | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/ami/main/api/views.py b/ami/main/api/views.py index e39ea982f..a0c19afe8 100644 --- a/ami/main/api/views.py +++ b/ami/main/api/views.py @@ -1263,11 +1263,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. + If include_descendants=true, also includes descendants (children or deeper) recursively. + + Query parameters: + - taxa_list_id: ID of the taxa list to filter by + - include_descendants: Set to 'true' to include descendants (default: false) + - not_taxa_list_id: ID of taxa list to exclude """ query_param = "taxa_list_id" @@ -1279,11 +1283,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: From bf3f30450906ae067a98cf16f6330eb0f7d910ec Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Tue, 3 Feb 2026 18:04:51 -0800 Subject: [PATCH 24/38] feat: hide taxa children by default in the taxa management view --- ui/src/pages/taxa-list-details/taxa-list-details.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/ui/src/pages/taxa-list-details/taxa-list-details.tsx b/ui/src/pages/taxa-list-details/taxa-list-details.tsx index 0f0e536fc..6fc3a33b2 100644 --- a/ui/src/pages/taxa-list-details/taxa-list-details.tsx +++ b/ui/src/pages/taxa-list-details/taxa-list-details.tsx @@ -32,6 +32,7 @@ export const TaxaListDetails = () => { pagination, filters: [ { field: 'include_unobserved', value: 'true' }, + { field: 'include_descendants', value: 'false' }, { field: 'taxa_list_id', value: id }, ], }) From 4892c111d094f19969f5beb8f86192cd266bd301 Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Tue, 3 Feb 2026 18:15:33 -0800 Subject: [PATCH 25/38] chore: remove "by-taxon" url prefix --- ami/main/api/views.py | 4 ++-- ami/main/tests/test_taxa_list_taxa_api.py | 7 ++++--- .../hooks/taxa-lists/useRemoveTaxaListTaxon.ts | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/ami/main/api/views.py b/ami/main/api/views.py index a0c19afe8..e0d760f27 100644 --- a/ami/main/api/views.py +++ b/ami/main/api/views.py @@ -1712,11 +1712,11 @@ def create(self, request, taxalist_pk=None): serializer = self.get_serializer(taxon) return Response(serializer.data, status=status.HTTP_201_CREATED) - @action(detail=False, methods=["delete"], url_path=r"by-taxon/(?P\d+)") + @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/by-taxon/{taxon_id}/ + DELETE /taxa/lists/{taxa_list_id}/taxa/{taxon_id}/ """ taxa_list = self.get_taxa_list() diff --git a/ami/main/tests/test_taxa_list_taxa_api.py b/ami/main/tests/test_taxa_list_taxa_api.py index e168fd65b..1d5a3a439 100644 --- a/ami/main/tests/test_taxa_list_taxa_api.py +++ b/ami/main/tests/test_taxa_list_taxa_api.py @@ -1,6 +1,7 @@ """ Tests for TaxaList taxa management API endpoints (without through model). """ + from django.test import TestCase from rest_framework import status from rest_framework.test import APIClient @@ -57,14 +58,14 @@ def test_list_taxa_in_list(self): 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/by-taxon/{self.taxon1.pk}/?project_id={self.project.pk}" + 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/by-taxon/{self.taxon1.pk}/?project_id={self.project.pk}" + 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) @@ -98,7 +99,7 @@ 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/by-taxon/{self.taxon1.pk}/?project_id={self.project.pk}" + 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) diff --git a/ui/src/data-services/hooks/taxa-lists/useRemoveTaxaListTaxon.ts b/ui/src/data-services/hooks/taxa-lists/useRemoveTaxaListTaxon.ts index 9daa59a70..2a27c852c 100644 --- a/ui/src/data-services/hooks/taxa-lists/useRemoveTaxaListTaxon.ts +++ b/ui/src/data-services/hooks/taxa-lists/useRemoveTaxaListTaxon.ts @@ -17,7 +17,7 @@ export const useRemoveTaxaListTaxon = (projectId: string) => { taxonId: string }) => axios.delete( - `${API_URL}/${API_ROUTES.TAXA_LISTS}/${taxaListId}/taxa/by-taxon/${taxonId}/?project_id=${projectId}`, + `${API_URL}/${API_ROUTES.TAXA_LISTS}/${taxaListId}/taxa/${taxonId}/?project_id=${projectId}`, { headers: getAuthHeader(user), } From e68e47f56b7fd33f78a268a06323aec0d8d96dca Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Tue, 3 Feb 2026 18:32:58 -0800 Subject: [PATCH 26/38] fix: move new taxa list tests in with the others for now --- ami/main/tests.py | 133 +++++++++++++++++++- ami/main/tests/__init__.py | 1 - ami/main/tests/test_taxa_list_taxa_api.py | 141 ---------------------- 3 files changed, 132 insertions(+), 143 deletions(-) delete mode 100644 ami/main/tests/__init__.py delete mode 100644 ami/main/tests/test_taxa_list_taxa_api.py diff --git a/ami/main/tests.py b/ami/main/tests.py index a6be324f4..e2d631689 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 @@ -3443,3 +3443,134 @@ def test_taxon_detail_visible_when_excluded_from_list(self): detail_url = f"/api/v2/taxa/{excluded_taxon.id}/?project_id={self.project.pk}" res = self.client.get(detail_url) self.assertEqual(res.status_code, status.HTTP_200_OK) + + +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_list_taxa_in_list(self): + """Test listing taxa in a taxa list.""" + self.taxa_list.taxa.add(self.taxon1, self.taxon2) + + response = self.client.get(self.base_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 2) + taxon_ids = [item["id"] for item in response.data["results"]] + self.assertIn(self.taxon1.pk, taxon_ids) + self.assertIn(self.taxon2.pk, taxon_ids) + + 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_list_empty_taxa_list(self): + """Test listing taxa in an empty taxa list.""" + response = self.client.get(self.base_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 0) + self.assertEqual(response.data["results"], []) + + 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 accessing non-existent taxa list returns 404.""" + url = f"/api/v2/taxa/lists/999999/taxa/?project_id={self.project.pk}" + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) diff --git a/ami/main/tests/__init__.py b/ami/main/tests/__init__.py deleted file mode 100644 index 420b08f2b..000000000 --- a/ami/main/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Tests for ami.main app diff --git a/ami/main/tests/test_taxa_list_taxa_api.py b/ami/main/tests/test_taxa_list_taxa_api.py deleted file mode 100644 index 1d5a3a439..000000000 --- a/ami/main/tests/test_taxa_list_taxa_api.py +++ /dev/null @@ -1,141 +0,0 @@ -""" -Tests for TaxaList taxa management API endpoints (without through model). -""" - -from django.test import TestCase -from rest_framework import status -from rest_framework.test import APIClient - -from ami.main.models import Project, TaxaList, Taxon -from ami.users.models import User - - -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_list_taxa_in_list(self): - """Test listing taxa in a taxa list.""" - self.taxa_list.taxa.add(self.taxon1, self.taxon2) - - response = self.client.get(self.base_url) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], 2) - taxon_ids = [item["id"] for item in response.data["results"]] - self.assertIn(self.taxon1.pk, taxon_ids) - self.assertIn(self.taxon2.pk, taxon_ids) - - 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_list_empty_taxa_list(self): - """Test listing taxa in an empty taxa list.""" - response = self.client.get(self.base_url) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], 0) - self.assertEqual(response.data["results"], []) - - 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 accessing non-existent taxa list returns 404.""" - url = f"/api/v2/taxa/lists/999999/taxa/?project_id={self.project.pk}" - response = self.client.get(url) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) From 17cf37e5dd4d235ecc684aa83207631a4bd553c4 Mon Sep 17 00:00:00 2001 From: Anna Viklund Date: Fri, 6 Feb 2026 16:32:49 +0100 Subject: [PATCH 27/38] fix: pass project ID with delete requests --- .../data-services/hooks/entities/useDeleteEntity.ts | 12 ++++++++++-- .../pages/project/entities/delete-entity-dialog.tsx | 8 ++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) 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/pages/project/entities/delete-entity-dialog.tsx b/ui/src/pages/project/entities/delete-entity-dialog.tsx index 375a276c8..a211c928d 100644 --- a/ui/src/pages/project/entities/delete-entity-dialog.tsx +++ b/ui/src/pages/project/entities/delete-entity-dialog.tsx @@ -4,6 +4,7 @@ import * as Dialog from 'design-system/components/dialog/dialog' import { TrashIcon } 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' export const DeleteEntityDialog = ({ @@ -15,9 +16,12 @@ export const DeleteEntityDialog = ({ id: string type: string }) => { + 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 ( From 2716251eaf7aeff1b938e980879cf2743e5fdc1e Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Tue, 17 Feb 2026 16:41:06 -0800 Subject: [PATCH 28/38] fix: address taxa-list PR review feedback (#1119) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: entity createdAt guard, utils project field, taxa-list query type and cache key, table loading state - entity.ts: createdAt getter was guarding on updated_at instead of created_at - utils.ts: revert project_id back to project; existing serializers (Device, Site, StorageSource, SourceImageCollection) declare the field as project - useTaxaListDetails: generic was TaxaList (client shape) instead of ServerTaxaList (raw API shape); added projectId to queryKey to avoid cross-project cache hits - taxa-list-details: isLoading on Table was gated on !id which is always falsy (id is a route param), so the loading state never showed Co-Authored-By: Claude * fix: restrict TaxaListTaxonViewSet to IsActiveStaffOrReadOnly; update tests TaxaListTaxonViewSet had permission_classes = [] (open to everyone). Set to IsActiveStaffOrReadOnly to match the parent TaxaListViewSet pattern. Test setUp now creates users with is_staff=True so POST/DELETE pass the permission check. Co-Authored-By: Claude * fix: backend cleanup in taxa-list views - TaxonTaxaListFilter docstring: include_descendants default was documented as false but the code defaults to True; corrected docs to match - get_taxa_list: added "from None" to suppress chained DoesNotExist traceback in the NotFound exception - create: replaced Taxon.objects.get with get_object_or_404 to close the race between serializer validation and the lookup - added get_object_or_404 import Co-Authored-By: Claude * feat: UX — permission gating and double-submit guards on taxa-list pages - taxa-list-details: wrap AddTaxaListTaxonPopover in canUpdate check so users without write permission do not see the add button - add-taxa-list-taxon: disable the Add button while the mutation is in flight to prevent duplicate submissions - remove-taxa-list-taxon-dialog: disable the Confirm button while the remove mutation is in flight - taxa-lists: use the paginated total from the API instead of the length of the current page for the results count Co-Authored-By: Claude * chore: naming fixes, dead-code removal, missing useEffect dep - language.ts: rename MESSAGE_MESSAGE_REMOVE_TAXA_LIST_TAXON_CONFIRM to MESSAGE_REMOVE_TAXA_LIST_TAXON_CONFIRM (duplicated prefix); update enum key, translation map entry, and the one usage site - new-entity-dialog.tsx: remove unused API_ROUTES import - useSidebarSections.tsx: remove expression that computes a value but never assigns or returns it - breadcrumbs.tsx: add setMainBreadcrumb to useEffect dependency array Co-Authored-By: Claude * fix: transfer focus to dialog after preventDefault in onOpenAutoFocus When e.preventDefault() stops Radix's default focus behaviour the dialog has no focused element, which breaks keyboard navigation and screen readers. Explicitly focus the dialog content after preventing the default in all three sites: taxa-list-details, species, and occurrences. Co-Authored-By: Claude * fix: gate TaxaListTaxonViewSet on project membership, not is_staff IsActiveStaffOrReadOnly blocks any non-staff user from POST/DELETE, but the intent of taxa lists is that any project member can manage them. Added IsProjectMemberOrReadOnly to ami/base/permissions.py: safe methods are open; unsafe methods check project.members via the active project resolved by ProjectMixin.get_active_project(). Reverted test users back to plain create_user — Project.objects.create with owner= auto-adds the owner as a member via ensure_owner_membership, which is all the permission now requires. Co-Authored-By: Claude * fix: scope taxa list lookup to active project, add missing useEffect dep - get_taxa_list() now validates that the taxa list belongs to the active project, preventing cross-project manipulation via URL params - Added setDetailBreadcrumb to useEffect dependency array Co-Authored-By: Claude * fix: cleanup UI fixes * fix: allow superusers to add taxa --------- Co-authored-by: Claude Co-authored-by: Anna Viklund --- ami/base/permissions.py | 29 +++++++++++++++++++ ami/main/api/views.py | 20 +++++++------ ui/src/components/breadcrumbs/breadcrumbs.tsx | 2 +- .../hooks/taxa-lists/useTaxaListDetails.ts | 4 +-- ui/src/data-services/models/entity.ts | 2 +- ui/src/pages/occurrences/occurrences.tsx | 4 --- .../project/entities/new-entity-dialog.tsx | 1 - .../project/sidebar/useSidebarSections.tsx | 5 ---- ui/src/pages/species/species.tsx | 4 --- .../add-taxa-list-taxon.tsx | 2 +- .../remove-taxa-list-taxon-dialog.tsx | 4 +-- .../taxa-list-details/taxa-list-details.tsx | 12 ++++---- ui/src/pages/taxa-lists/taxa-lists.tsx | 4 +-- ui/src/utils/language.ts | 4 +-- 14 files changed, 55 insertions(+), 42 deletions(-) diff --git a/ami/base/permissions.py b/ami/base/permissions.py index bb143be23..aaf9b4917 100644 --- a/ami/base/permissions.py +++ b/ami/base/permissions.py @@ -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. diff --git a/ami/main/api/views.py b/ami/main/api/views.py index e0d760f27..1ea72b0af 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 @@ -1265,12 +1266,12 @@ class TaxonTaxaListFilter(filters.BaseFilterBackend): """ Filters taxa based on a TaxaList. - By default, queries for taxa that are directly in the TaxaList. - If include_descendants=true, also includes descendants (children or deeper) 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 'true' to include descendants (default: false) + - include_descendants: Set to 'false' to exclude descendants (default: true) - not_taxa_list_id: ID of taxa list to exclude """ @@ -1666,16 +1667,17 @@ class TaxaListTaxonViewSet(viewsets.GenericViewSet, ProjectMixin): """ serializer_class = TaxaListTaxonSerializer - permission_classes = [] # Allow public access for now + permission_classes = [IsProjectMemberOrReadOnly] require_project = True def get_taxa_list(self): - """Get the parent taxa list from URL parameters.""" + """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) + return TaxaList.objects.get(pk=taxa_list_id, projects=project) except TaxaList.DoesNotExist: - raise api_exceptions.NotFound("Taxa list not found.") + raise api_exceptions.NotFound("Taxa list not found.") from None def get_queryset(self): """Return taxa in the specified taxa list.""" @@ -1705,7 +1707,7 @@ def create(self, request, taxalist_pk=None): ) # Add taxon - taxon = Taxon.objects.get(pk=taxon_id) + taxon = get_object_or_404(Taxon, pk=taxon_id) taxa_list.taxa.add(taxon) # Return the added taxon diff --git a/ui/src/components/breadcrumbs/breadcrumbs.tsx b/ui/src/components/breadcrumbs/breadcrumbs.tsx index 4dcb3c589..9f63a2969 100644 --- a/ui/src/components/breadcrumbs/breadcrumbs.tsx +++ b/ui/src/components/breadcrumbs/breadcrumbs.tsx @@ -29,7 +29,7 @@ export const Breadcrumbs = ({ return () => { setMainBreadcrumb(undefined) } - }, [navItems, activeNavItem]) + }, [navItems, activeNavItem, setMainBreadcrumb]) const breadcrumbs = [ pageBreadcrumb, diff --git a/ui/src/data-services/hooks/taxa-lists/useTaxaListDetails.ts b/ui/src/data-services/hooks/taxa-lists/useTaxaListDetails.ts index 8dec88823..8d40deec4 100644 --- a/ui/src/data-services/hooks/taxa-lists/useTaxaListDetails.ts +++ b/ui/src/data-services/hooks/taxa-lists/useTaxaListDetails.ts @@ -14,8 +14,8 @@ export const useTaxaListDetails = ( isFetching: boolean error?: unknown } => { - const { data, isLoading, isFetching, error } = useAuthorizedQuery({ - queryKey: [API_ROUTES.TAXA_LISTS, id], + const { data, isLoading, isFetching, error } = useAuthorizedQuery({ + queryKey: [API_ROUTES.TAXA_LISTS, projectId, id], url: `${API_URL}/${API_ROUTES.TAXA_LISTS}/${id}/?project_id=${projectId}`, }) diff --git a/ui/src/data-services/models/entity.ts b/ui/src/data-services/models/entity.ts index c262f2b1c..9656518f2 100644 --- a/ui/src/data-services/models/entity.ts +++ b/ui/src/data-services/models/entity.ts @@ -19,7 +19,7 @@ export class Entity { } get createdAt(): string | undefined { - if (!this._data.updated_at) { + if (!this._data.created_at) { return undefined } diff --git a/ui/src/pages/occurrences/occurrences.tsx b/ui/src/pages/occurrences/occurrences.tsx index 7611306c8..bf3059b6c 100644 --- a/ui/src/pages/occurrences/occurrences.tsx +++ b/ui/src/pages/occurrences/occurrences.tsx @@ -278,10 +278,6 @@ const OccurrenceDetailsDialog = ({ ariaCloselabel={translate(STRING.CLOSE)} error={error} isLoading={isLoading} - onOpenAutoFocus={(e) => { - /* Prevent tooltip auto focus */ - e.preventDefault() - }} > {occurrence ? ( { ) }, [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.tsx b/ui/src/pages/species/species.tsx index 60f75b1e2..3a2c6ba59 100644 --- a/ui/src/pages/species/species.tsx +++ b/ui/src/pages/species/species.tsx @@ -198,10 +198,6 @@ const SpeciesDetailsDialog = ({ id }: { id: string }) => { ariaCloselabel={translate(STRING.CLOSE)} error={error} isLoading={isLoading} - onOpenAutoFocus={(e) => { - /* Prevent tooltip auto focus */ - e.preventDefault() - }} > {species ? ( {translate(STRING.CANCEL)}
{ taxaListId: id as string, })} error={error} - isLoading={!id && isLoading} + isLoading={isLoading} items={species} onSortSettingsChange={setSort} sortable @@ -132,10 +134,6 @@ const SpeciesDetailsDialog = ({ ariaCloselabel={translate(STRING.CLOSE)} error={error} isLoading={isLoading} - onOpenAutoFocus={(e) => { - /* Prevent tooltip auto focus */ - e.preventDefault() - }} > {species ? ( { <> diff --git a/ui/src/utils/language.ts b/ui/src/utils/language.ts index 8d503d3ee..ccd0251f8 100644 --- a/ui/src/utils/language.ts +++ b/ui/src/utils/language.ts @@ -189,7 +189,7 @@ export enum STRING { MESSAGE_PERMISSIONS_MISSING, MESSAGE_PROCESS_NOW_TOOLTIP, MESSAGE_REMOVE_MEMBER_CONFIRM, - MESSAGE_MESSAGE_REMOVE_TAXA_LIST_TAXON_CONFIRM, + MESSAGE_REMOVE_TAXA_LIST_TAXON_CONFIRM, MESSAGE_RESET_INSTRUCTIONS_SENT, MESSAGE_RESULT_RANGE, MESSAGE_SIGNED_UP, @@ -514,7 +514,7 @@ 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_MESSAGE_REMOVE_TAXA_LIST_TAXON_CONFIRM]: + [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}}!', From d947756adeadafd94f3007f5def61f2ce181d676 Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Tue, 17 Feb 2026 17:09:24 -0800 Subject: [PATCH 29/38] fix: use uppercase rank values in taxa list tests Use "SPECIES" instead of "species" to match TaxonRank convention used throughout the codebase. Co-Authored-By: Claude --- ami/main/tests.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ami/main/tests.py b/ami/main/tests.py index 0dffe5669..a283618df 100644 --- a/ami/main/tests.py +++ b/ami/main/tests.py @@ -3454,8 +3454,8 @@ def setUp(self): 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.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}" @@ -3554,7 +3554,7 @@ def setUp(self): 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.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}" From 4cd067f75c138ed6ae9dbb93ec4da85e5070b4a6 Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Tue, 17 Feb 2026 17:09:35 -0800 Subject: [PATCH 30/38] fix: add ObjectPermission to TaxaListViewSet Match the permission pattern used by other project-scoped viewsets (ProjectViewSet, DeploymentViewSet, SiteViewSet, DeviceViewSet). Without this, non-staff project members cannot manage taxa lists. Co-Authored-By: Claude --- ami/main/api/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ami/main/api/views.py b/ami/main/api/views.py index 1ea72b0af..de32c985d 100644 --- a/ami/main/api/views.py +++ b/ami/main/api/views.py @@ -1634,6 +1634,7 @@ class TaxaListViewSet(DefaultViewSet, ProjectMixin): "created_at", "updated_at", ] + permission_classes = [ObjectPermission] require_project = True def get_queryset(self): From d19885b7e0bb0ea2f9ea625bd1bdd0f3a5b10ab7 Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Wed, 18 Feb 2026 03:00:25 -0800 Subject: [PATCH 31/38] chore: note dead code in ProcessingServiceSerializer.create() The project write field was removed from the serializer in favour of server-side assignment in ProcessingServiceViewSet.perform_create(). Co-Authored-By: Claude --- ami/ml/serializers.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ami/ml/serializers.py b/ami/ml/serializers.py index 35c18883b..893da01bf 100644 --- a/ami/ml/serializers.py +++ b/ami/ml/serializers.py @@ -159,6 +159,9 @@ def get_projects(self, obj): return list(obj.projects.values_list("id", flat=True)) def create(self, validated_data): + # TODO: Remove this dead code. The `project` write field was removed from this serializer + # in favour of server-side assignment in ProcessingServiceViewSet.perform_create(). + # The pop always returns None and the branch below is never reached. project = validated_data.pop("project", None) instance = super().create(validated_data) From 8f5144e258f0cb04a37af2cb68d65c51230c3c4a Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Wed, 18 Feb 2026 03:15:24 -0800 Subject: [PATCH 32/38] fix: update ProcessingService tests to use project_id query param Tests were sending `project` as a POST field, but the write-only field was removed from ProcessingServiceSerializer in favor of server-side assignment via `project_id` query parameter. Also removes dead code in the serializer's create() override and fixes Prettier formatting. Co-Authored-By: Claude --- ami/ml/serializers.py | 12 ------------ ami/ml/tests.py | 10 ++++++---- .../hooks/taxa-lists/useTaxaListDetails.ts | 9 +++++---- .../remove-taxa-list-taxon-dialog.tsx | 4 +--- 4 files changed, 12 insertions(+), 23 deletions(-) diff --git a/ami/ml/serializers.py b/ami/ml/serializers.py index 893da01bf..6c5782c8f 100644 --- a/ami/ml/serializers.py +++ b/ami/ml/serializers.py @@ -158,18 +158,6 @@ def get_projects(self, obj): """ return list(obj.projects.values_list("id", flat=True)) - def create(self, validated_data): - # TODO: Remove this dead code. The `project` write field was removed from this serializer - # in favour of server-side assignment in ProcessingServiceViewSet.perform_create(). - # The pop always returns None and the branch below is never reached. - project = validated_data.pop("project", None) - instance = super().create(validated_data) - - if project: - instance.projects.add(project) - - return instance - class PipelineRegistrationSerializer(serializers.Serializer): processing_service_name = serializers.CharField() 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/ui/src/data-services/hooks/taxa-lists/useTaxaListDetails.ts b/ui/src/data-services/hooks/taxa-lists/useTaxaListDetails.ts index 8d40deec4..1ad1b4e06 100644 --- a/ui/src/data-services/hooks/taxa-lists/useTaxaListDetails.ts +++ b/ui/src/data-services/hooks/taxa-lists/useTaxaListDetails.ts @@ -14,10 +14,11 @@ export const useTaxaListDetails = ( 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 { 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), 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 index 9cb7cbd1e..b5e628c86 100644 --- 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 @@ -41,9 +41,7 @@ export const RemoveTaxaListTaxonDialog = ({ )}