diff --git a/backend/Dockerfile b/backend/Dockerfile index 509dfde3..7e4fd942 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -39,6 +39,16 @@ 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 6b755093..e7a8ee69 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 +from routers import auth, export from scaffold import load_scaffold_data from starlette.requests import ClientDisconnect from strawberry import Schema @@ -61,6 +61,7 @@ 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 d135fb3c..eb47c308 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -9,6 +9,8 @@ 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 new file mode 100644 index 00000000..8331cce6 --- /dev/null +++ b/backend/routers/export.py @@ -0,0 +1,188 @@ +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 new file mode 100644 index 00000000..d3729e1f --- /dev/null +++ b/backend/tests/unit/test_export_pdf.py @@ -0,0 +1,60 @@ +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/web/components/tables/PatientList.tsx b/web/components/tables/PatientList.tsx index eeb1f3f8..00bf1650 100644 --- a/web/components/tables/PatientList.tsx +++ b/web/components/tables/PatientList.tsx @@ -20,6 +20,7 @@ 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' @@ -1118,6 +1119,7 @@ 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 fc7da8fa..b2772a06 100644 --- a/web/components/tables/TaskList.tsx +++ b/web/components/tables/TaskList.tsx @@ -30,6 +30,7 @@ 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' @@ -1028,6 +1029,7 @@ export const TaskList = forwardRef(({ tasks: initial > + string, 'priorityLabel': string, 'priorityNone': string, @@ -556,6 +557,7 @@ export const tasksTranslation: Translation { return TranslationGen.resolveSelect(priority, { 'P1': `Normal`, @@ -972,6 +974,7 @@ export const tasksTranslation: Translation { return TranslationGen.resolveSelect(priority, { 'P1': `Normal`, @@ -1387,6 +1390,7 @@ export const tasksTranslation: Translation { return TranslationGen.resolveSelect(priority, { 'P1': `Normal`, @@ -1802,6 +1806,7 @@ export const tasksTranslation: Translation { return TranslationGen.resolveSelect(priority, { 'P1': `Normal`, @@ -2217,6 +2222,7 @@ export const tasksTranslation: Translation { return TranslationGen.resolveSelect(priority, { 'P1': `Normaal`, @@ -2635,6 +2641,7 @@ 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 fb01756e..9825b68c 100644 --- a/web/locales/de-DE.arb +++ b/web/locales/de-DE.arb @@ -121,6 +121,7 @@ "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 ebf5705b..13f83c47 100644 --- a/web/locales/en-US.arb +++ b/web/locales/en-US.arb @@ -121,6 +121,7 @@ "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 43e23f57..8c8cb3e6 100644 --- a/web/locales/es-ES.arb +++ b/web/locales/es-ES.arb @@ -81,6 +81,7 @@ "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 fb546a47..73a87fd6 100644 --- a/web/locales/fr-FR.arb +++ b/web/locales/fr-FR.arb @@ -81,6 +81,7 @@ "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 70b3cd5a..3afa945a 100644 --- a/web/locales/nl-NL.arb +++ b/web/locales/nl-NL.arb @@ -81,6 +81,7 @@ "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 09d91dcc..dca4ab5d 100644 --- a/web/locales/pt-BR.arb +++ b/web/locales/pt-BR.arb @@ -81,6 +81,7 @@ "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/next-env.d.ts b/web/next-env.d.ts index 4bc78492..4503dcfd 100644 --- a/web/next-env.d.ts +++ b/web/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./build/dev/types/routes.d.ts"; +import "./build/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/pages/api-reference/config/typescript for more information. diff --git a/web/utils/tableExport.ts b/web/utils/tableExport.ts new file mode 100644 index 00000000..c4b61162 --- /dev/null +++ b/web/utils/tableExport.ts @@ -0,0 +1,65 @@ +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) +}