From a74e2e9d64d158d8c9456890ee5385873596f907 Mon Sep 17 00:00:00 2001 From: Anant Jain Date: Thu, 22 Oct 2026 17:28:05 +0530 Subject: [PATCH 1/2] feat(api): table queries for use cases, collaboratives, AI models, publications Add useCasesTable, collaborativesTable, aiModelsTable and publicationsTable, shaped like datasetsTable: client filters and sort options limited to an allowlist per module, limit/offset paging, the total after filters, and counts per status for the dashboard tabs (ignoring the status filter so tab labels stay stable). Each query shows the same rows as the module's existing list query, plus published (for AI models: public and active) rows when includePublic is set. The existing list queries are unchanged. StatusCount and status_counts() are shared in api/utils/qs_utils.py. No migration. --- api/schema/aimodel_schema.py | 95 +++++++++++++++++++++++++++ api/schema/collaborative_schema.py | 93 ++++++++++++++++++++++++++ api/schema/publication_schema.py | 84 ++++++++++++++++++++++++ api/schema/usecase_schema.py | 101 ++++++++++++++++++++++++++++- api/types/type_aimodel.py | 15 +++++ api/types/type_collaborative.py | 15 +++++ api/types/type_publication.py | 15 +++++ api/types/type_usecase.py | 15 +++++ api/utils/qs_utils.py | 26 +++++++- 9 files changed, 457 insertions(+), 2 deletions(-) diff --git a/api/schema/aimodel_schema.py b/api/schema/aimodel_schema.py index 0b059a45..abd76ae3 100644 --- a/api/schema/aimodel_schema.py +++ b/api/schema/aimodel_schema.py @@ -8,6 +8,7 @@ import strawberry import strawberry_django from django.core.exceptions import ValidationError as DjangoValidationError +from django.db.models import QuerySet from strawberry.types import Info from strawberry_django.pagination import OffsetPaginationInput @@ -21,6 +22,7 @@ AIModelLifecycleStageEnum, AIModelOrder, AIModelProviderEnum, + AIModelResponse, AIModelStatusEnum, AIModelTypeEnum, AIModelVersionFilter, @@ -34,6 +36,12 @@ TypeVersionProvider, ) from api.utils.graphql_telemetry import trace_resolver +from api.utils.qs_utils import ( + FilterSpec, + SortSpec, + get_pagination_window, + status_counts, +) from authorization.graphql_permissions import IsAuthenticated from authorization.models import OrganizationMembership, Role @@ -281,6 +289,57 @@ class UpdateVersionProviderInput: config: Optional[strawberry.scalars.JSON] = None +# Fields a table client may filter or sort on. Explicit allowlists are what +# stop arbitrary columns (and relations) from becoming queryable. +AI_MODELS_TABLE_FILTER_FIELDS = [ + "id", + "name", + "display_name", + "status", + "model_type", + "provider", + "domain", + "organization", + "organization__slug", + "sectors__id", + "sectors__slug", + "sectors__name", + "geographies__id", + "geographies__name", + "geographies__code", + "tags__value", + "created_at", + "updated_at", +] +AI_MODELS_TABLE_SORT_FIELDS = [ + "name", + "display_name", + "created_at", + "updated_at", + "status", + "model_type", + "provider", +] + + +def _visible_ai_models(info: Info, include_public: Optional[bool] = False) -> QuerySet: + """The AI models a caller may list. Same rules as `aiModels`.""" + organization = info.context.context.get("organization") + user = info.context.user + public = AIModel.objects.filter(is_public=True, is_active=True) + if organization: + queryset = AIModel.objects.filter(organization=organization) + elif not user.is_authenticated: + queryset = public + elif user.is_superuser: + queryset = AIModel.objects.all() + else: + queryset = AIModel.objects.filter(user=user, organization=None) + if include_public: + queryset = queryset | public + return queryset.distinct() + + @strawberry.type class Query: """Queries for AI Models.""" @@ -330,6 +389,42 @@ def ai_models( return TypeAIModel.from_django_list(list(queryset)) + @strawberry.field + @trace_resolver(name="ai_models_table", attributes={"component": "aimodel"}) + def ai_models_table( + self, + info: Info, + filters: Optional[List[FilterSpec]] = None, + sort_options: Optional[List[SortSpec]] = None, + limit: int = 50, + offset: int = 0, + include_public: Optional[bool] = False, + ) -> AIModelResponse: + """AI models for a table UI: client-driven filters, sortable columns and + page controls backed by a real total count. + + Additive next to `aiModels`, with the same visibility rules, plus + public, active models when `include_public` is set. Filters are `{field, + condition, value}` and sorts are `{field, direction}`, limited to the + allowlists above; anything else is a GraphQL error naming the allowed + fields. Default order: newest first. + """ + visible = _visible_ai_models(info, include_public) + window = get_pagination_window( + visible, + limit=limit, + offset=offset, + filters=filters, + filtering_allowed_fields=AI_MODELS_TABLE_FILTER_FIELDS, + sort_options=sort_options or [SortSpec(field="created_at", direction="desc")], + sorting_allowed_fields=AI_MODELS_TABLE_SORT_FIELDS, + ) + return AIModelResponse( + data=TypeAIModel.from_django_list(window["data"]), + total_items_count=window["total_items_count"], + status_counts=status_counts(visible, filters, AI_MODELS_TABLE_FILTER_FIELDS), + ) + @strawberry.field @trace_resolver(name="get_ai_model", attributes={"component": "aimodel"}) def get_ai_model(self, info: Info, model_id: int) -> Optional[TypeAIModel]: diff --git a/api/schema/collaborative_schema.py b/api/schema/collaborative_schema.py index 366f4aa6..4a7bed46 100644 --- a/api/schema/collaborative_schema.py +++ b/api/schema/collaborative_schema.py @@ -9,6 +9,7 @@ import strawberry import strawberry_django from django.db import models +from django.db.models import QuerySet from strawberry import auto from strawberry.file_uploads import Upload from strawberry.types import Info @@ -37,6 +38,7 @@ from api.types.type_collaborative import ( CollaborativeFilter, CollaborativeOrder, + CollaborativeResponse, TypeCollaborative, ) from api.types.type_collaborative_organization import ( @@ -48,6 +50,12 @@ from api.types.type_usecase import TypeUseCase from api.utils.enums import CollaborativeStatus, OrganizationRelationshipType from api.utils.graphql_telemetry import trace_resolver +from api.utils.qs_utils import ( + FilterSpec, + SortSpec, + get_pagination_window, + status_counts, +) from authorization.models import User from authorization.types import TypeUser @@ -95,6 +103,55 @@ class CollaborativeInputPartial: completed_on: Optional[datetime.date] = None +# Fields a table client may filter or sort on. Explicit allowlists are what +# stop arbitrary columns (and relations) from becoming queryable. +COLLABORATIVES_TABLE_FILTER_FIELDS = [ + "id", + "title", + "status", + "organization", + "organization__slug", + "sectors__id", + "sectors__slug", + "sectors__name", + "geographies__id", + "geographies__name", + "geographies__code", + "tags__value", + "sdgs__code", + "sdgs__slug", + "started_on", + "completed_on", + "created", + "modified", +] +COLLABORATIVES_TABLE_SORT_FIELDS = [ + "title", + "created", + "modified", + "status", + "started_on", + "completed_on", +] + + +def _visible_collaboratives(info: Info, include_public: Optional[bool] = False) -> QuerySet: + """The collaboratives a caller may list. Same rules as `collaboratives`.""" + user = info.context.user + organization = info.context.context.get("organization") + if organization: + queryset = Collaborative.objects.filter(organization=organization) + elif user.is_superuser: + queryset = Collaborative.objects.all() + elif user.is_authenticated: + queryset = Collaborative.objects.filter(user=user) + else: + queryset = Collaborative.objects.filter(status=CollaborativeStatus.PUBLISHED) + if include_public: + queryset = queryset | Collaborative.objects.filter(status=CollaborativeStatus.PUBLISHED) + return queryset.distinct() + + @strawberry.type(name="Query") class Query: """Queries for collaboratives.""" @@ -138,6 +195,42 @@ def collaboratives( return TypeCollaborative.from_django_list(queryset) + @strawberry.field + @trace_resolver(name="collaboratives_table", attributes={"component": "collaborative"}) + def collaboratives_table( + self, + info: Info, + filters: Optional[List[FilterSpec]] = None, + sort_options: Optional[List[SortSpec]] = None, + limit: int = 50, + offset: int = 0, + include_public: Optional[bool] = False, + ) -> CollaborativeResponse: + """Collaboratives for a table UI: client-driven filters, sortable columns and + page controls backed by a real total count. + + Additive next to `collaboratives`, with the same visibility rules, plus + published collaboratives when `include_public` is set. Filters are `{field, + condition, value}` and sorts are `{field, direction}`, limited to the + allowlists above; anything else is a GraphQL error naming the allowed + fields. Default order: newest first. + """ + visible = _visible_collaboratives(info, include_public) + window = get_pagination_window( + visible, + limit=limit, + offset=offset, + filters=filters, + filtering_allowed_fields=COLLABORATIVES_TABLE_FILTER_FIELDS, + sort_options=sort_options or [SortSpec(field="created", direction="desc")], + sorting_allowed_fields=COLLABORATIVES_TABLE_SORT_FIELDS, + ) + return CollaborativeResponse( + data=TypeCollaborative.from_django_list(window["data"]), + total_items_count=window["total_items_count"], + status_counts=status_counts(visible, filters, COLLABORATIVES_TABLE_FILTER_FIELDS), + ) + @strawberry_django.field @trace_resolver(name="get_published_collaboratives", attributes={"component": "collaborative"}) def published_collaboratives( diff --git a/api/schema/publication_schema.py b/api/schema/publication_schema.py index 8b0e354a..53eecd97 100644 --- a/api/schema/publication_schema.py +++ b/api/schema/publication_schema.py @@ -16,6 +16,7 @@ import strawberry import strawberry_django from django.core.exceptions import ValidationError as DjangoValidationError +from django.db.models import QuerySet from strawberry.file_uploads import Upload from strawberry.types import Info @@ -41,6 +42,7 @@ from api.types.type_publication import ( PublicationFilter, PublicationOrder, + PublicationResponse, TypePublication, TypePublicationBlock, TypeResourceType, @@ -48,6 +50,12 @@ ) from api.utils.enums import PublicationStatus from api.utils.graphql_telemetry import trace_resolver +from api.utils.qs_utils import ( + FilterSpec, + SortSpec, + get_pagination_window, + status_counts, +) from authorization.permissions import ( AllowPublishedPublications, ChangePublicationPermission, @@ -93,6 +101,46 @@ class UpdatePublicationInput: external_source_link: Optional[str] = strawberry.UNSET +# Fields a table client may filter or sort on. Explicit allowlists are what +# stop arbitrary columns (and relations) from becoming queryable. +PUBLICATIONS_TABLE_FILTER_FIELDS = [ + "id", + "title", + "status", + "license", + "resource_type", + "resource_type__slug", + "organization", + "organization__slug", + "sectors__id", + "sectors__slug", + "sectors__name", + "geographies__id", + "geographies__name", + "geographies__code", + "publication_date", + "created", + "modified", +] +PUBLICATIONS_TABLE_SORT_FIELDS = [ + "title", + "created", + "modified", + "status", + "publication_date", + "download_count", +] + + +def _visible_publications(info: Info, include_public: Optional[bool] = False) -> QuerySet: + """The publications a caller may list. Same rules as `publications`.""" + return get_scoped_publications( + user=info.context.user, + organization=info.context.context.get("organization"), + include_public=bool(include_public), + ) + + @strawberry.type(name="Query") class Query: """Queries for publications.""" @@ -147,6 +195,42 @@ def publications( ) return TypePublication.from_django_list(queryset[offset : offset + limit]) + @strawberry.field + @trace_resolver(name="publications_table", attributes={"component": "publication"}) + def publications_table( + self, + info: Info, + filters: Optional[List[FilterSpec]] = None, + sort_options: Optional[List[SortSpec]] = None, + limit: int = 50, + offset: int = 0, + include_public: Optional[bool] = False, + ) -> PublicationResponse: + """Publications for a table UI: client-driven filters, sortable columns and + page controls backed by a real total count. + + Additive next to `publications`, with the same visibility rules, plus + published publications when `include_public` is set. Filters are `{field, + condition, value}` and sorts are `{field, direction}`, limited to the + allowlists above; anything else is a GraphQL error naming the allowed + fields. Default order: newest first. + """ + visible = _visible_publications(info, include_public) + window = get_pagination_window( + visible, + limit=limit, + offset=offset, + filters=filters, + filtering_allowed_fields=PUBLICATIONS_TABLE_FILTER_FIELDS, + sort_options=sort_options or [SortSpec(field="created", direction="desc")], + sorting_allowed_fields=PUBLICATIONS_TABLE_SORT_FIELDS, + ) + return PublicationResponse( + data=TypePublication.from_django_list(window["data"]), + total_items_count=window["total_items_count"], + status_counts=status_counts(visible, filters, PUBLICATIONS_TABLE_FILTER_FIELDS), + ) + @strawberry.type(name="Mutation") class Mutation: diff --git a/api/schema/usecase_schema.py b/api/schema/usecase_schema.py index 7d3c67fe..6b133d6a 100644 --- a/api/schema/usecase_schema.py +++ b/api/schema/usecase_schema.py @@ -10,6 +10,7 @@ import strawberry import strawberry_django from django.db import models +from django.db.models import QuerySet from strawberry import auto from strawberry.file_uploads import Upload from strawberry.types import Info @@ -36,7 +37,12 @@ ) from api.types.type_dataset import TypeDataset from api.types.type_organization import TypeOrganization -from api.types.type_usecase import TypeUseCase, UseCaseFilter, UseCaseOrder +from api.types.type_usecase import ( + TypeUseCase, + UseCaseFilter, + UseCaseOrder, + UseCaseResponse, +) from api.types.type_usecase_organization import ( TypeUseCaseOrganizationRelationship, relationship_type, @@ -47,6 +53,12 @@ UseCaseStatus, ) from api.utils.graphql_telemetry import trace_resolver +from api.utils.qs_utils import ( + FilterSpec, + SortSpec, + get_pagination_window, + status_counts, +) from authorization.models import User from authorization.types import TypeUser @@ -95,6 +107,57 @@ class UseCaseInputPartial: completed_on: Optional[datetime.date] = None +# Fields a table client may filter or sort on. Explicit allowlists are what +# stop arbitrary columns (and relations) from becoming queryable. +USE_CASES_TABLE_FILTER_FIELDS = [ + "id", + "title", + "status", + "running_status", + "organization", + "organization__slug", + "sectors__id", + "sectors__slug", + "sectors__name", + "geographies__id", + "geographies__name", + "geographies__code", + "tags__value", + "sdgs__code", + "sdgs__slug", + "started_on", + "completed_on", + "created", + "modified", +] +USE_CASES_TABLE_SORT_FIELDS = [ + "title", + "created", + "modified", + "status", + "running_status", + "started_on", + "completed_on", +] + + +def _visible_use_cases(info: Info, include_public: Optional[bool] = False) -> QuerySet: + """The use cases a caller may list. Same rules as `useCases`.""" + user = info.context.user + organization = info.context.context.get("organization") + if organization: + queryset = UseCase.objects.filter(organization=organization) + elif user.is_superuser: + queryset = UseCase.objects.all() + elif user.is_authenticated: + queryset = UseCase.objects.filter(user=user) + else: + queryset = UseCase.objects.filter(status=UseCaseStatus.PUBLISHED) + if include_public: + queryset = queryset | UseCase.objects.filter(status=UseCaseStatus.PUBLISHED) + return queryset.distinct() + + @strawberry.type(name="Query") class Query: """Queries for use cases.""" @@ -138,6 +201,42 @@ def use_cases( return TypeUseCase.from_django_list(queryset) + @strawberry.field + @trace_resolver(name="use_cases_table", attributes={"component": "usecase"}) + def use_cases_table( + self, + info: Info, + filters: Optional[List[FilterSpec]] = None, + sort_options: Optional[List[SortSpec]] = None, + limit: int = 50, + offset: int = 0, + include_public: Optional[bool] = False, + ) -> UseCaseResponse: + """Use cases for a table UI: client-driven filters, sortable columns and + page controls backed by a real total count. + + Additive next to `useCases`, with the same visibility rules, plus + published use cases when `include_public` is set. Filters are `{field, + condition, value}` and sorts are `{field, direction}`, limited to the + allowlists above; anything else is a GraphQL error naming the allowed + fields. Default order: newest first. + """ + visible = _visible_use_cases(info, include_public) + window = get_pagination_window( + visible, + limit=limit, + offset=offset, + filters=filters, + filtering_allowed_fields=USE_CASES_TABLE_FILTER_FIELDS, + sort_options=sort_options or [SortSpec(field="created", direction="desc")], + sorting_allowed_fields=USE_CASES_TABLE_SORT_FIELDS, + ) + return UseCaseResponse( + data=TypeUseCase.from_django_list(window["data"]), + total_items_count=window["total_items_count"], + status_counts=status_counts(visible, filters, USE_CASES_TABLE_FILTER_FIELDS), + ) + @strawberry_django.field @trace_resolver(name="get_published_use_cases", attributes={"component": "usecase"}) def published_use_cases( diff --git a/api/types/type_aimodel.py b/api/types/type_aimodel.py index 60c7c7b7..e06baca6 100644 --- a/api/types/type_aimodel.py +++ b/api/types/type_aimodel.py @@ -30,6 +30,7 @@ HFModelClass, PromptDomain, ) +from api.utils.qs_utils import StatusCount from authorization.types import TypeUser logger = structlog.get_logger("dataspace.type_aimodel") @@ -334,3 +335,17 @@ def ai_model(self) -> Optional[TypeAIModel]: return TypeAIModel.from_django(django_instance.ai_model) except Exception: return None + + +@strawberry.type +class AIModelResponse: + """One page of a table listing (`aiModelsTable`): rows, the total after + filters, and per-status counts for tab labels.""" + + data: List[TypeAIModel] + total_items_count: int + status_counts: List[StatusCount] = strawberry.field( + default_factory=list, + description="Counts per status with the same filters applied, except any status filter, " + "so tab labels stay stable when switching tabs.", + ) diff --git a/api/types/type_collaborative.py b/api/types/type_collaborative.py index aa624632..e5bb1d13 100644 --- a/api/types/type_collaborative.py +++ b/api/types/type_collaborative.py @@ -31,6 +31,7 @@ OrganizationRelationshipType, PublicationStatus, ) +from api.utils.qs_utils import StatusCount from authorization.types import TypeUser collaborative_status = strawberry.enum(CollaborativeStatus) # type: ignore @@ -261,3 +262,17 @@ def partner_organizations(self) -> Optional[List["TypeOrganization"]]: return TypeOrganization.from_django_list(organizations) except Exception: return [] + + +@strawberry.type +class CollaborativeResponse: + """One page of a table listing (`collaborativesTable`): rows, the total after + filters, and per-status counts for tab labels.""" + + data: List[TypeCollaborative] + total_items_count: int + status_counts: List[StatusCount] = strawberry.field( + default_factory=list, + description="Counts per status with the same filters applied, except any status filter, " + "so tab labels stay stable when switching tabs.", + ) diff --git a/api/types/type_publication.py b/api/types/type_publication.py index ee2781d1..cfb40a51 100644 --- a/api/types/type_publication.py +++ b/api/types/type_publication.py @@ -25,6 +25,7 @@ PublicationStatus, UseCaseStatus, ) +from api.utils.qs_utils import StatusCount from authorization.types import TypeUser @@ -236,3 +237,17 @@ def linked_count(self, info: Info) -> int: return usecases + collabs except (AttributeError, Publication.DoesNotExist): return 0 + + +@strawberry.type +class PublicationResponse: + """One page of a table listing (`publicationsTable`): rows, the total after + filters, and per-status counts for tab labels.""" + + data: List[TypePublication] + total_items_count: int + status_counts: List[StatusCount] = strawberry.field( + default_factory=list, + description="Counts per status with the same filters applied, except any status filter, " + "so tab labels stay stable when switching tabs.", + ) diff --git a/api/types/type_usecase.py b/api/types/type_usecase.py index eb801ff6..60e8d6a0 100644 --- a/api/types/type_usecase.py +++ b/api/types/type_usecase.py @@ -30,6 +30,7 @@ PublicationStatus, UseCaseStatus, ) +from api.utils.qs_utils import StatusCount from authorization.types import TypeUser use_case_status = strawberry.enum(UseCaseStatus) # type: ignore @@ -240,3 +241,17 @@ def usecase_dashboard(self) -> Optional[List["TypeUseCaseDashboard"]]: return TypeUseCaseDashboard.from_django_list(usecase_dashboards) except Exception: return [] + + +@strawberry.type +class UseCaseResponse: + """One page of a table listing (`useCasesTable`): rows, the total after + filters, and per-status counts for tab labels.""" + + data: List[TypeUseCase] + total_items_count: int + status_counts: List[StatusCount] = strawberry.field( + default_factory=list, + description="Counts per status with the same filters applied, except any status filter, " + "so tab labels stay stable when switching tabs.", + ) diff --git a/api/utils/qs_utils.py b/api/utils/qs_utils.py index 5e6fc2c6..15846baf 100644 --- a/api/utils/qs_utils.py +++ b/api/utils/qs_utils.py @@ -11,7 +11,7 @@ from typing import Any, Dict, List, Optional, Sequence import strawberry -from django.db.models import Q, QuerySet +from django.db.models import Count, Q, QuerySet MAX_LIMIT = 100 @@ -50,6 +50,12 @@ class SortSpec: direction: str = "asc" +@strawberry.type(description="Rows per status, for a table's tab labels.") +class StatusCount: + status: str + count: int + + def _check_allowed(kind: str, field: str, allowed: Optional[Sequence[str]]) -> None: if allowed is not None and field not in allowed: raise ValueError( @@ -128,3 +134,21 @@ def get_pagination_window( if sort_options: queryset = apply_sorting(queryset, sort_options, sorting_allowed_fields) return apply_pagination(queryset, limit, offset) + + +def status_counts( + queryset: QuerySet, + filters: Optional[Sequence[FilterSpec]] = None, + allowed_fields: Optional[Sequence[str]] = None, + status_field: str = "status", +) -> List[StatusCount]: + """Count rows per status under every filter except the status one, so tab + labels stay the same whichever tab is open.""" + others = [f for f in (filters or []) if (f.field or "").strip() != status_field] + rows = ( + apply_filters(queryset, others, allowed_fields) + .order_by() + .values(status_field) + .annotate(n=Count("pk", distinct=True)) + ) + return [StatusCount(status=row[status_field], count=row["n"]) for row in rows] From 4b57735497253f683b77ba498ffa3ec354f40c92 Mon Sep 17 00:00:00 2001 From: Anant Jain Date: Thu, 22 Oct 2026 20:17:41 +0530 Subject: [PATCH 2/2] refactor(api): datasetsTable uses the shared status_counts helper The tab counts in datasetsTable were an inline copy of what status_counts() in api/utils/qs_utils.py now does for the other four tables. Call the helper instead, so all five tables count statuses in one place. status_counts() takes an optional item_type so the dataset table keeps its DatasetStatusCount type; the GraphQL schema is unchanged. --- api/schema/dataset_schema.py | 16 ++++------------ api/utils/qs_utils.py | 10 ++++++---- 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/api/schema/dataset_schema.py b/api/schema/dataset_schema.py index 40d89f12..2aab35a2 100644 --- a/api/schema/dataset_schema.py +++ b/api/schema/dataset_schema.py @@ -58,8 +58,8 @@ from api.utils.qs_utils import ( FilterSpec, SortSpec, - apply_filters, get_pagination_window, + status_counts, ) from authorization.models import DatasetPermission, OrganizationMembership, Role, User from authorization.permissions import ( @@ -539,20 +539,12 @@ def datasets_table( sort_options=sort_options or [SortSpec(field="created", direction="desc")], sorting_allowed_fields=DATASETS_TABLE_SORT_FIELDS, ) - # Tab labels: counts per status under every filter except status itself. - without_status = [f for f in (filters or []) if f.field.strip() != "status"] - status_rows = ( - apply_filters(visible, without_status, DATASETS_TABLE_FILTER_FIELDS) - .order_by() - .values("status") - .annotate(n=Count("id", distinct=True)) - ) return DatasetResponse( data=TypeDataset.from_django_list(window["data"]), total_items_count=window["total_items_count"], - status_counts=[ - DatasetStatusCount(status=row["status"], count=row["n"]) for row in status_rows - ], + status_counts=status_counts( + visible, filters, DATASETS_TABLE_FILTER_FIELDS, item_type=DatasetStatusCount + ), ) @strawberry.field( diff --git a/api/utils/qs_utils.py b/api/utils/qs_utils.py index 15846baf..803384ad 100644 --- a/api/utils/qs_utils.py +++ b/api/utils/qs_utils.py @@ -8,7 +8,7 @@ from __future__ import annotations -from typing import Any, Dict, List, Optional, Sequence +from typing import Any, Dict, List, Optional, Sequence, Type import strawberry from django.db.models import Count, Q, QuerySet @@ -141,9 +141,11 @@ def status_counts( filters: Optional[Sequence[FilterSpec]] = None, allowed_fields: Optional[Sequence[str]] = None, status_field: str = "status", -) -> List[StatusCount]: + item_type: Type[Any] = StatusCount, +) -> List[Any]: """Count rows per status under every filter except the status one, so tab - labels stay the same whichever tab is open.""" + labels stay the same whichever tab is open. ``item_type`` lets a module keep + its own GraphQL type name (``DatasetStatusCount``).""" others = [f for f in (filters or []) if (f.field or "").strip() != status_field] rows = ( apply_filters(queryset, others, allowed_fields) @@ -151,4 +153,4 @@ def status_counts( .values(status_field) .annotate(n=Count("pk", distinct=True)) ) - return [StatusCount(status=row[status_field], count=row["n"]) for row in rows] + return [item_type(status=row[status_field], count=row["n"]) for row in rows]