From 6842cbfc55d7663d875eeb7250967724f682b708 Mon Sep 17 00:00:00 2001 From: Anant Jain Date: Tue, 27 Oct 2026 15:55:00 +0530 Subject: [PATCH 1/2] feat(api): add chartsTable with filters, sorting and pagination The charts dashboard loads every built chart and chart image through getChartData and searches them in the browser. chartsTable returns one page of both kinds in a single list, with the total count and per-status counts, using the shared table helpers. Both tables are merged in one SQL UNION, so counting, sorting and slicing happen in the database; only the rows of the page are then loaded. Visibility and permission are the same as getChartData. Rows keep their types (TypeResourceChart, TypeResourceChartImage), so existing fragments work unchanged. getChartData is untouched. --- api/schema/resource_chart_schema.py | 141 +++++++++++++++++++++++++++- api/types/type_resource_chart.py | 16 ++++ 2 files changed, 155 insertions(+), 2 deletions(-) diff --git a/api/schema/resource_chart_schema.py b/api/schema/resource_chart_schema.py index bf8d04da..1b51d234 100644 --- a/api/schema/resource_chart_schema.py +++ b/api/schema/resource_chart_schema.py @@ -6,17 +6,80 @@ import strawberry import strawberry_django +from django.db.models import CharField, QuerySet, Value from strawberry.types import Info from strawberry_django.mutations import mutations -from api.models import Resource, ResourceChartDetails, ResourceSchema +from api.models import ( + Resource, + ResourceChartDetails, + ResourceChartImage, + ResourceSchema, +) +from api.schema.dataset_schema import ChartDataPermission from api.schema.extensions import TrackModelActivity -from api.types.type_resource_chart import TypeResourceChart +from api.types.type_resource_chart import ChartResponse, TypeResourceChart +from api.types.type_resource_chart_image import TypeResourceChartImage from api.utils.enums import AggregateType, ChartStatus, ChartTypes +from api.utils.graphql_telemetry import trace_resolver +from api.utils.qs_utils import ( + FilterSpec, + SortSpec, + StatusCount, + apply_filters, + apply_pagination, + apply_sorting, + status_counts, +) ChartTypeEnum = strawberry.enum(ChartTypes) AggregateTypeEnum = strawberry.enum(AggregateType) +# Fields chartsTable accepts. Names are the chart image's; a built chart reaches +# its dataset through its resource, so `dataset…` is rewritten for it. +# `kind` is CHART (built in the wizard) or IMAGE (uploaded). +CHARTS_TABLE_FILTER_FIELDS = [ + "id", + "name", + "status", + "chart_type", + "kind", + "dataset", + "dataset__slug", + "dataset__title", + "modified", +] +CHARTS_TABLE_SORT_FIELDS = ["name", "status", "chart_type", "kind", "modified"] + +# Columns shared by both tables for the UNION. Model fields first, then the +# annotations, so both sides select them in the same order. +_CHART_COLUMNS = ("id", "name", "status", "modified", "chart_type", "kind") + + +def _for_built_charts(field: str) -> str: + if field == "dataset" or field.startswith("dataset__"): + return f"resource__{field}" + return field + + +def _visible_charts(info: Info) -> "tuple[QuerySet, QuerySet]": + """Built charts and chart images the caller may list: the same rules as + `getChartData` without a dataset (the organisation's, else the user's).""" + organization = info.context.context.get("organization") + user = info.context.user + if organization: + charts = ResourceChartDetails.objects.filter(resource__dataset__organization=organization) + images = ResourceChartImage.objects.filter(dataset__organization=organization) + else: + charts = ResourceChartDetails.objects.filter(resource__dataset__user=user) + images = ResourceChartImage.objects.filter(dataset__user=user) + charts = charts.annotate(kind=Value("CHART", output_field=CharField())) + images = images.annotate( + chart_type=Value(None, output_field=CharField()), + kind=Value("IMAGE", output_field=CharField()), + ) + return charts, images + @strawberry.type(name="Query") class Query: @@ -30,6 +93,80 @@ def resource_chart(self, info: Info, chart_details_id: uuid.UUID) -> TypeResourc chart = ResourceChartDetails.objects.get(id=chart_details_id) return TypeResourceChart.from_django(chart) + @strawberry.field( + permission_classes=[ChartDataPermission], # type: ignore[list-item] + ) + @trace_resolver(name="charts_table", attributes={"component": "chart"}) + def charts_table( + self, + info: Info, + filters: Optional[List[FilterSpec]] = None, + sort_options: Optional[List[SortSpec]] = None, + limit: int = 50, + offset: int = 0, + ) -> ChartResponse: + """Built charts and chart images in one table: client-driven filters, + sortable columns and page controls backed by a real total count. + + Additive next to `getChartData`, with the same visibility rules. Both + tables are merged in one SQL UNION, so counting, sorting and slicing + happen in the database; only the rows of the page are then loaded. + Default order: last modified first. + """ + visible_charts, visible_images = _visible_charts(info) + # Images first: their field names are the public ones, so a disallowed + # field is reported with the allowlist as clients know it. + images = apply_filters(visible_images, filters or [], CHARTS_TABLE_FILTER_FIELDS) + chart_filters = [ + FilterSpec(field=_for_built_charts(f.field or ""), condition=f.condition, value=f.value) + for f in filters or [] + ] + chart_fields = [_for_built_charts(f) for f in CHARTS_TABLE_FILTER_FIELDS] + charts = apply_filters(visible_charts, chart_filters, chart_fields) + + # order_by() drops the models' default ordering, not allowed inside a UNION. + rows = ( + charts.order_by() + .values(*_CHART_COLUMNS) + .union(images.order_by().values(*_CHART_COLUMNS)) + ) + rows = apply_sorting( + rows, + sort_options or [SortSpec(field="modified", direction="desc")], + CHARTS_TABLE_SORT_FIELDS, + ) + # Tie-breaker so equal sort values do not move rows between pages. + rows = rows.order_by(*rows.query.order_by, "id") + window = apply_pagination(rows, limit, offset) + page = list(window["data"]) + + # from_django reads every foreign key of the chart's dataset. + built = ResourceChartDetails.objects.select_related( + "resource__dataset__organization", + "resource__dataset__user", + "resource__dataset__dataspace", + ).in_bulk([r["id"] for r in page if r["kind"] == "CHART"]) + uploaded = ResourceChartImage.objects.select_related("dataset").in_bulk( + [r["id"] for r in page if r["kind"] == "IMAGE"] + ) + data: List[Any] = [] + for r in page: + if r["kind"] == "CHART" and r["id"] in built: + data.append(TypeResourceChart.from_django(built[r["id"]])) + elif r["kind"] == "IMAGE" and r["id"] in uploaded: + data.append(TypeResourceChartImage.from_django(uploaded[r["id"]])) + + counts: Dict[str, int] = {} + for item in status_counts(visible_charts, chart_filters, chart_fields) + status_counts( + visible_images, filters, CHARTS_TABLE_FILTER_FIELDS + ): + counts[item.status] = counts.get(item.status, 0) + item.count + return ChartResponse( + data=data, + total_items_count=window["total_items_count"], + status_counts=[StatusCount(status=k, count=v) for k, v in sorted(counts.items())], + ) + @strawberry.input class FilterInput: diff --git a/api/types/type_resource_chart.py b/api/types/type_resource_chart.py index c4236ac5..59b7c353 100644 --- a/api/types/type_resource_chart.py +++ b/api/types/type_resource_chart.py @@ -19,8 +19,10 @@ from api.types.charts.chart_registry import CHART_REGISTRY from api.types.type_dataset import TypeDataset from api.types.type_resource import TypeResource, TypeResourceSchema +from api.types.type_resource_chart_image import TypeResourceChartImage from api.utils.enums import ChartStatus, ChartTypes from api.utils.file_utils import load_csv +from api.utils.qs_utils import StatusCount logger = structlog.get_logger(__name__) T = TypeVar("T", bound="TypeResourceChart") @@ -362,3 +364,17 @@ def chart(self, info: Info) -> Optional[ChartConfig]: height=chart_instance.height, renderer=chart_instance.renderer, ) + + +@strawberry.type +class ChartResponse: + """One page of the charts table (`chartsTable`): built charts and chart + images in one list, the total after filters, and per-status counts.""" + + data: List[Union[TypeResourceChartImage, TypeResourceChart]] + 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 do not change when switching tabs.", + ) From bf8e275eb8d31ce7fa865069985d8e2f9d2332ee Mon Sep 17 00:00:00 2001 From: Anant Jain Date: Tue, 27 Oct 2026 16:48:53 +0530 Subject: [PATCH 2/2] refactor(api): page chartsTable through get_pagination_window Sorting and paging now go through the same helper as the other table queries. Only filtering stays per table, because Django cannot filter after a UNION. Drops the extra id tie-breaker, matching the others. --- api/schema/resource_chart_schema.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/api/schema/resource_chart_schema.py b/api/schema/resource_chart_schema.py index 1b51d234..a4e3b3b6 100644 --- a/api/schema/resource_chart_schema.py +++ b/api/schema/resource_chart_schema.py @@ -27,8 +27,7 @@ SortSpec, StatusCount, apply_filters, - apply_pagination, - apply_sorting, + get_pagination_window, status_counts, ) @@ -130,14 +129,14 @@ def charts_table( .values(*_CHART_COLUMNS) .union(images.order_by().values(*_CHART_COLUMNS)) ) - rows = apply_sorting( + # Filters are already applied per table above (not allowed after a UNION). + window = get_pagination_window( rows, - sort_options or [SortSpec(field="modified", direction="desc")], - CHARTS_TABLE_SORT_FIELDS, + limit=limit, + offset=offset, + sort_options=sort_options or [SortSpec(field="modified", direction="desc")], + sorting_allowed_fields=CHARTS_TABLE_SORT_FIELDS, ) - # Tie-breaker so equal sort values do not move rows between pages. - rows = rows.order_by(*rows.query.order_by, "id") - window = apply_pagination(rows, limit, offset) page = list(window["data"]) # from_django reads every foreign key of the chart's dataset.