From c5ff2ce1afed9322616d3ec87307c39f5dc6c9a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 21:09:34 +0000 Subject: [PATCH] revert: remove server-side PDF table export Removes the table Print button and the backend PDF rendering it relied on, along with the dependencies introduced for it. Frontend: - Delete TableExportButton and the tableExport util. - Drop the button from the task and patient table toolbars. - Remove the now-unused 'print' translation key from every locale. Backend: - Delete the /export/table.pdf router and its unit tests. - Stop registering the export router in main.py. - Drop weasyprint and jinja2 from requirements and the Pango/fontconfig/font runtime libraries from the Dockerfile. Proxy: - Stop forwarding /export/* to the backend. The browser's native print view (Ctrl+P) is unaffected. --- backend/Dockerfile | 10 -- backend/main.py | 3 +- backend/requirements.txt | 2 - backend/routers/export.py | 188 -------------------- backend/tests/unit/test_export_pdf.py | 60 ------- proxy/README.md | 1 - proxy/nginx.conf | 2 +- web/components/tables/PatientList.tsx | 2 - web/components/tables/TableExportButton.tsx | 54 ------ web/components/tables/TaskList.tsx | 2 - web/i18n/translations.ts | 7 - web/locales/de-DE.arb | 1 - web/locales/en-US.arb | 1 - web/locales/es-ES.arb | 1 - web/locales/fr-FR.arb | 1 - web/locales/nl-NL.arb | 1 - web/locales/pt-BR.arb | 1 - web/utils/tableExport.ts | 65 ------- 18 files changed, 2 insertions(+), 400 deletions(-) delete mode 100644 backend/routers/export.py delete mode 100644 backend/tests/unit/test_export_pdf.py delete mode 100644 web/components/tables/TableExportButton.tsx delete mode 100644 web/utils/tableExport.ts diff --git a/backend/Dockerfile b/backend/Dockerfile index 7e4fd942..509dfde3 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -39,16 +39,6 @@ ENV PYTHONUNBUFFERED=1 ENV PORT=80 ENV HOST="0.0.0.0" -# Native libraries required by WeasyPrint for PDF rendering (Pango/Cairo/fonts). -# Versions are intentionally unpinned: they must match the base image's Alpine -# branch, which is resolved at build time. Pin them to match the convention of -# the builder stage above once building against the pinned base image digest. -# hadolint ignore=DL3018 -RUN apk add --no-cache \ - font-dejavu \ - fontconfig \ - pango - COPY --from=builder /build/venv /usr/local/ COPY . /app diff --git a/backend/main.py b/backend/main.py index e7a8ee69..6b755093 100644 --- a/backend/main.py +++ b/backend/main.py @@ -10,7 +10,7 @@ from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse -from routers import auth, export +from routers import auth from scaffold import load_scaffold_data from starlette.requests import ClientDisconnect from strawberry import Schema @@ -61,7 +61,6 @@ async def client_disconnect_handler(request: Request, exc: ClientDisconnect): ) app.include_router(auth.router) -app.include_router(export.router) app.include_router(graphql_app, prefix="/graphql") diff --git a/backend/requirements.txt b/backend/requirements.txt index eb47c308..d135fb3c 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -9,8 +9,6 @@ sqlalchemy==2.0.45 strawberry-graphql[fastapi]==0.287.3 uvicorn[standard]==0.38.0 influxdb_client==1.49.0 -jinja2==3.1.4 -weasyprint==63.1 pytest==8.3.4 pytest-asyncio==0.24.0 pytest-cov==5.0.0 diff --git a/backend/routers/export.py b/backend/routers/export.py deleted file mode 100644 index 8331cce6..00000000 --- a/backend/routers/export.py +++ /dev/null @@ -1,188 +0,0 @@ -from datetime import datetime, timezone - -from api.context import Context, get_context -from database.session import get_db_session -from fastapi import APIRouter, Depends, HTTPException, Request -from fastapi.responses import Response -from jinja2 import Environment, select_autoescape -from pydantic import BaseModel, Field - -router = APIRouter() - -_MAX_ROWS = 5000 -_MAX_COLUMNS = 40 - -_jinja_env = Environment(autoescape=select_autoescape(["html", "xml"])) - -_TABLE_TEMPLATE = _jinja_env.from_string( - """ - - - - - - -
-
helpwave tasks
-

{{ title }}

- {% if subtitle %}
{{ subtitle }}
{% endif %} -
- {{ rows_label }}: {{ rows|length }} - {% for key, value in meta.items() %}{{ key }}: {{ value }}{% endfor %} -
-
- {% if rows %} - - - {% for column in columns %}{% endfor %} - - - {% for row in rows %} - {% for cell in row %}{% endfor %} - {% endfor %} - -
{{ column }}
{{ cell }}
- {% else %} -
{{ empty_label }}
- {% endif %} - -""" -) - - -class TableExportRequest(BaseModel): - title: str = Field(..., max_length=200) - subtitle: str | None = Field(default=None, max_length=400) - columns: list[str] - rows: list[list[str]] - orientation: str = "landscape" - meta: dict[str, str] = Field(default_factory=dict) - rows_label: str = "Rows" - empty_label: str = "No entries" - generated_label: str = "Generated" - page_label: str = "Page" - - -def _normalize(request: TableExportRequest) -> TableExportRequest: - if not request.columns: - raise HTTPException(status_code=422, detail="At least one column is required") - if len(request.columns) > _MAX_COLUMNS: - raise HTTPException(status_code=422, detail="Too many columns") - if len(request.rows) > _MAX_ROWS: - raise HTTPException(status_code=422, detail="Too many rows to export") - orientation = request.orientation if request.orientation in ("portrait", "landscape") else "landscape" - width = len(request.columns) - rows: list[list[str]] = [] - for row in request.rows: - cells = ["" if cell is None else str(cell) for cell in row[:width]] - cells += [""] * (width - len(cells)) - rows.append(cells) - return request.model_copy(update={"orientation": orientation, "rows": rows}) - - -def render_table_html(request: TableExportRequest) -> str: - normalized = _normalize(request) - return _TABLE_TEMPLATE.render( - title=normalized.title, - subtitle=normalized.subtitle, - columns=normalized.columns, - rows=normalized.rows, - orientation=normalized.orientation, - meta=normalized.meta, - rows_label=normalized.rows_label, - empty_label=normalized.empty_label, - generated_label=normalized.generated_label, - page_label=normalized.page_label, - generated_at=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"), - ) - - -def render_table_pdf(request: TableExportRequest) -> bytes: - html = render_table_html(request) - try: - from weasyprint import HTML - except (ImportError, OSError) as exc: # missing python pkg or native libs - raise HTTPException( - status_code=503, - detail="PDF rendering is not available on this server", - ) from exc - return HTML(string=html).write_pdf() - - -async def _require_context( - request: Request, - session=Depends(get_db_session), -) -> Context: - return await get_context(request, session) - - -@router.post("/export/table.pdf") -async def export_table_pdf( - body: TableExportRequest, - context: Context = Depends(_require_context), -) -> Response: - if context.user is None: - raise HTTPException(status_code=401, detail="Not authenticated") - pdf = render_table_pdf(body) - filename = _safe_filename(body.title) - return Response( - content=pdf, - media_type="application/pdf", - headers={"Content-Disposition": f'inline; filename="{filename}.pdf"'}, - ) - - -def _safe_filename(title: str) -> str: - cleaned = "".join(c if c.isalnum() or c in (" ", "-", "_") else "" for c in title).strip() - cleaned = cleaned.replace(" ", "-").lower() - return cleaned or "table-export" diff --git a/backend/tests/unit/test_export_pdf.py b/backend/tests/unit/test_export_pdf.py deleted file mode 100644 index d3729e1f..00000000 --- a/backend/tests/unit/test_export_pdf.py +++ /dev/null @@ -1,60 +0,0 @@ -import pytest -from fastapi import HTTPException -from routers.export import ( - TableExportRequest, - _normalize, - _safe_filename, - render_table_html, -) - - -def _request(**overrides) -> TableExportRequest: - base = { - "title": "My Tasks", - "columns": ["Title", "Patient"], - "rows": [["Take blood", "John Doe"], ["Round", "Jane Roe"]], - } - base.update(overrides) - return TableExportRequest(**base) - - -def test_render_table_html_contains_headers_and_rows(): - html = render_table_html(_request()) - assert "

My Tasks

" in html - assert "Title" in html - assert "Take blood" in html - assert "John Doe" in html - assert "size: A4 landscape" in html - - -def test_render_table_html_escapes_cell_content(): - html = render_table_html(_request(rows=[["", "x"]])) - assert "" not in html - assert "<script>" in html - - -def test_render_table_html_empty_rows_shows_placeholder(): - html = render_table_html(_request(rows=[], empty_label="Nothing here")) - assert "Nothing here" in html - assert "" not in html - - -def test_normalize_pads_and_truncates_rows_to_column_count(): - normalized = _normalize(_request(rows=[["only-one"], ["a", "b", "c-extra"]])) - assert normalized.rows == [["only-one", ""], ["a", "b"]] - - -def test_normalize_rejects_empty_columns(): - with pytest.raises(HTTPException) as exc: - _normalize(_request(columns=[])) - assert exc.value.status_code == 422 - - -def test_normalize_defaults_invalid_orientation_to_landscape(): - assert _normalize(_request(orientation="sideways")).orientation == "landscape" - assert _normalize(_request(orientation="portrait")).orientation == "portrait" - - -def test_safe_filename(): - assert _safe_filename("My Tasks 2026") == "my-tasks-2026" - assert _safe_filename("///") == "table-export" diff --git a/proxy/README.md b/proxy/README.md index 0c29515a..921db2f7 100644 --- a/proxy/README.md +++ b/proxy/README.md @@ -34,7 +34,6 @@ docker run --rm \ - `/` - Frontend application - `/graphql` - Backend GraphQL API -- `/export/*` - Backend export endpoints (e.g. table PDF export) - `/keycloak` - Keycloak authentication service ## Production diff --git a/proxy/nginx.conf b/proxy/nginx.conf index 090b4ad3..e6b776fb 100644 --- a/proxy/nginx.conf +++ b/proxy/nginx.conf @@ -25,7 +25,7 @@ http { listen 80; server_name localhost; - location ~ ^/(graphql|callback|export/.+)$ { + location ~ ^/(graphql|callback)$ { proxy_pass http://backend_upstream; proxy_set_header Host $host; diff --git a/web/components/tables/PatientList.tsx b/web/components/tables/PatientList.tsx index 00bf1650..eeb1f3f8 100644 --- a/web/components/tables/PatientList.tsx +++ b/web/components/tables/PatientList.tsx @@ -20,7 +20,6 @@ import { getPropertyColumnIds, useColumnVisibilityWithPropertyDefaults } from '@ import { columnFiltersToQueryFilterClauses, sortingStateToQuerySortClauses } from '@/utils/tableStateToApi' import { LIST_PAGE_SIZE } from '@/utils/listPaging' import { useAccumulatedPagination } from '@/hooks/useAccumulatedPagination' -import { TableExportButton } from '@/components/tables/TableExportButton' import { RowRefreshingGate } from '@/components/tables/RowRefreshingGate' import { InfiniteScrollSentinel } from '@/components/common/InfiniteScrollSentinel' import { DateDisplay } from '@/components/Date/DateDisplay' @@ -1119,7 +1118,6 @@ export const PatientList = forwardRef(({ initi > - {!derivedVirtualMode && ( , - /** Column ids to omit from the export (e.g. selection/action columns). */ - excludeColumnIds?: string[], -} - -/** - * Renders a print button that exports the table's currently visible columns and - * loaded rows to a server-generated PDF. Must be rendered inside a TableProvider. - */ -export function TableExportButton({ title, subtitle, meta, excludeColumnIds }: TableExportButtonProps) { - const translation = useTasksTranslation() - const { table } = useTableStateContext() - const [isExporting, setIsExporting] = useState(false) - - const handleExport = useCallback(async () => { - const skip = new Set(excludeColumnIds ?? []) - const exportColumns = table.getVisibleLeafColumns().filter((column) => { - const header = column.columnDef.header - return typeof header === 'string' && header.length > 0 && !skip.has(column.id) - }) - if (exportColumns.length === 0) return - const columns = exportColumns.map((column) => column.columnDef.header as string) - const rows = table.getRowModel().rows.map( - (row) => exportColumns.map((column) => cellToText(row.getValue(column.id))) - ) - setIsExporting(true) - try { - await downloadTablePdf({ title, subtitle, columns, rows, meta }) - } finally { - setIsExporting(false) - } - }, [table, title, subtitle, meta, excludeColumnIds]) - - return ( - void handleExport()} - > - {isExporting ? : } - - ) -} diff --git a/web/components/tables/TaskList.tsx b/web/components/tables/TaskList.tsx index b2772a06..fc7da8fa 100644 --- a/web/components/tables/TaskList.tsx +++ b/web/components/tables/TaskList.tsx @@ -30,7 +30,6 @@ import { queryableFieldsToFilterListItems, queryableFieldsToSortingListItems, ty import { LIST_PAGE_SIZE } from '@/utils/listPaging' import { TaskCardView } from '@/components/tasks/TaskCardView' import { RefreshingTaskIdsContext, TaskRowRefreshingGate } from '@/components/tables/TaskRowRefreshingGate' -import { TableExportButton } from '@/components/tables/TableExportButton' import { InfiniteScrollSentinel } from '@/components/common/InfiniteScrollSentinel' import { ExpandableTextBlock } from '@/components/common/ExpandableTextBlock' import { InTableTextEditPopUp } from '@/components/tables/in-table-edit/InTableTextEditPopUp' @@ -1029,7 +1028,6 @@ export const TaskList = forwardRef(({ tasks: initial > - string, 'priorityLabel': string, 'priorityNone': string, @@ -560,7 +559,6 @@ export const tasksTranslation: Translation { return TranslationGen.resolveSelect(priority, { 'P1': `Normal`, @@ -979,7 +977,6 @@ export const tasksTranslation: Translation { return TranslationGen.resolveSelect(priority, { 'P1': `Normal`, @@ -1397,7 +1394,6 @@ export const tasksTranslation: Translation { return TranslationGen.resolveSelect(priority, { 'P1': `Normal`, @@ -1815,7 +1811,6 @@ export const tasksTranslation: Translation { return TranslationGen.resolveSelect(priority, { 'P1': `Normal`, @@ -2233,7 +2228,6 @@ export const tasksTranslation: Translation { return TranslationGen.resolveSelect(priority, { 'P1': `Normaal`, @@ -2654,7 +2648,6 @@ export const tasksTranslation: Translation { return TranslationGen.resolveSelect(priority, { 'P1': `Normal`, diff --git a/web/locales/de-DE.arb b/web/locales/de-DE.arb index 9a6ca5be..bc05d439 100644 --- a/web/locales/de-DE.arb +++ b/web/locales/de-DE.arb @@ -121,7 +121,6 @@ "listViewCard": "Kartenansicht", "listViewTable": "Tabellenansicht", "loadMore": "Mehr laden", - "print": "Drucken", "more": "Mehr", "location": "Ort", "locationBed": "Bett", diff --git a/web/locales/en-US.arb b/web/locales/en-US.arb index 21456f43..9c905eac 100644 --- a/web/locales/en-US.arb +++ b/web/locales/en-US.arb @@ -121,7 +121,6 @@ "listViewCard": "Card view", "listViewTable": "Table view", "loadMore": "Load more", - "print": "Print", "more": "More", "location": "Location", "locationBed": "Bed", diff --git a/web/locales/es-ES.arb b/web/locales/es-ES.arb index 7fb69c38..0deb97d7 100644 --- a/web/locales/es-ES.arb +++ b/web/locales/es-ES.arb @@ -81,7 +81,6 @@ "listViewCard": "Vista de tarjetas", "listViewTable": "Vista de tabla", "loadMore": "Cargar más", - "print": "Imprimir", "more": "Más", "location": "Ubicación", "locationBed": "Cama", diff --git a/web/locales/fr-FR.arb b/web/locales/fr-FR.arb index d2065b73..6c853cb4 100644 --- a/web/locales/fr-FR.arb +++ b/web/locales/fr-FR.arb @@ -81,7 +81,6 @@ "listViewCard": "Vue cartes", "listViewTable": "Vue tableau", "loadMore": "Charger plus", - "print": "Imprimer", "more": "Plus", "location": "Emplacement", "locationBed": "Lit", diff --git a/web/locales/nl-NL.arb b/web/locales/nl-NL.arb index 53fdb09e..cf7c2f4a 100644 --- a/web/locales/nl-NL.arb +++ b/web/locales/nl-NL.arb @@ -81,7 +81,6 @@ "listViewCard": "Kaartweergave", "listViewTable": "Tabelweergave", "loadMore": "Meer laden", - "print": "Afdrukken", "more": "Meer", "location": "Locatie", "locationBed": "Bed", diff --git a/web/locales/pt-BR.arb b/web/locales/pt-BR.arb index f338b735..f37f2ef2 100644 --- a/web/locales/pt-BR.arb +++ b/web/locales/pt-BR.arb @@ -81,7 +81,6 @@ "listViewCard": "Visualização em cartões", "listViewTable": "Visualização em tabela", "loadMore": "Carregar mais", - "print": "Imprimir", "more": "Mais", "location": "Localização", "locationBed": "Leito", diff --git a/web/utils/tableExport.ts b/web/utils/tableExport.ts deleted file mode 100644 index c4b61162..00000000 --- a/web/utils/tableExport.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { getConfig } from '@/utils/config' -import { getUser } from '@/api/auth/authService' - -export type TableExportPayload = { - title: string, - subtitle?: string, - columns: string[], - rows: string[][], - orientation?: 'portrait' | 'landscape', - meta?: Record, - rowsLabel?: string, - emptyLabel?: string, - generatedLabel?: string, - pageLabel?: string, -} - -function exportEndpoint(): string { - const { graphqlEndpoint } = getConfig() - return graphqlEndpoint.replace(/\/graphql\/?$/, '') + '/export/table.pdf' -} - -export function cellToText(value: unknown): string { - if (value == null) return '' - if (value instanceof Date) return value.toLocaleString() - if (Array.isArray(value)) return value.map(cellToText).filter(Boolean).join(', ') - if (typeof value === 'object') return '' - return String(value) -} - -export async function downloadTablePdf(payload: TableExportPayload): Promise { - const user = await getUser() - const token = user?.access_token - const response = await fetch(exportEndpoint(), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - body: JSON.stringify({ - title: payload.title, - subtitle: payload.subtitle, - columns: payload.columns, - rows: payload.rows, - orientation: payload.orientation ?? 'landscape', - meta: payload.meta ?? {}, - ...(payload.rowsLabel ? { rows_label: payload.rowsLabel } : {}), - ...(payload.emptyLabel ? { empty_label: payload.emptyLabel } : {}), - ...(payload.generatedLabel ? { generated_label: payload.generatedLabel } : {}), - ...(payload.pageLabel ? { page_label: payload.pageLabel } : {}), - }), - }) - if (!response.ok) { - throw new Error(`PDF export failed with status ${response.status}`) - } - const blob = await response.blob() - const url = URL.createObjectURL(blob) - const opened = window.open(url, '_blank') - if (!opened) { - const link = document.createElement('a') - link.href = url - link.download = `${payload.title || 'table-export'}.pdf` - link.click() - } - window.setTimeout(() => URL.revokeObjectURL(url), 60_000) -}