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/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/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..803384ad 100644 --- a/api/utils/qs_utils.py +++ b/api/utils/qs_utils.py @@ -8,10 +8,10 @@ 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 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,23 @@ 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", + 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. ``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) + .order_by() + .values(status_field) + .annotate(n=Count("pk", distinct=True)) + ) + return [item_type(status=row[status_field], count=row["n"]) for row in rows]