Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")


Expand Down
2 changes: 2 additions & 0 deletions backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
188 changes: 188 additions & 0 deletions backend/routers/export.py
Original file line number Diff line number Diff line change
@@ -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(
"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<style>
@page {
size: A4 {{ orientation }};
margin: 1.4cm 1.2cm 1.6cm 1.2cm;
@bottom-center {
content: "{{ page_label }} " counter(page) " / " counter(pages);
font-size: 8pt;
color: #6b7280;
}
@bottom-right {
content: "{{ generated_label }} {{ generated_at }}";
font-size: 8pt;
color: #6b7280;
}
}
* { box-sizing: border-box; }
body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
color: #1f2933;
font-size: 9pt;
margin: 0;
}
header { margin-bottom: 0.6cm; }
.brand { color: #2563eb; font-size: 8pt; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
h1 { font-size: 16pt; margin: 2px 0 2px 0; }
.subtitle { color: #4b5563; font-size: 10pt; margin: 0 0 6px 0; }
.meta { color: #4b5563; font-size: 8.5pt; margin-top: 4px; }
.meta span { margin-right: 14px; }
.meta b { color: #1f2933; font-weight: 600; }
table { width: 100%; border-collapse: collapse; table-layout: auto; }
thead { display: table-header-group; }
tr { page-break-inside: avoid; }
th {
text-align: left;
background: #f3f4f6;
color: #374151;
font-weight: 700;
border-bottom: 1px solid #d1d5db;
padding: 5px 7px;
font-size: 8.5pt;
}
td {
border-bottom: 1px solid #e5e7eb;
padding: 4px 7px;
vertical-align: top;
word-break: break-word;
overflow-wrap: anywhere;
}
tbody tr:nth-child(even) { background: #fafafa; }
.empty { color: #6b7280; font-style: italic; padding: 12px 0; }
</style>
</head>
<body>
<header>
<div class="brand">helpwave tasks</div>
<h1>{{ title }}</h1>
{% if subtitle %}<div class="subtitle">{{ subtitle }}</div>{% endif %}
<div class="meta">
<span><b>{{ rows_label }}:</b> {{ rows|length }}</span>
{% for key, value in meta.items() %}<span><b>{{ key }}:</b> {{ value }}</span>{% endfor %}
</div>
</header>
{% if rows %}
<table>
<thead>
<tr>{% for column in columns %}<th>{{ column }}</th>{% endfor %}</tr>
</thead>
<tbody>
{% for row in rows %}
<tr>{% for cell in row %}<td>{{ cell }}</td>{% endfor %}</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty">{{ empty_label }}</div>
{% endif %}
</body>
</html>"""
)


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"
60 changes: 60 additions & 0 deletions backend/tests/unit/test_export_pdf.py
Original file line number Diff line number Diff line change
@@ -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 "<h1>My Tasks</h1>" in html
assert "<th>Title</th>" 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=[["<script>alert(1)</script>", "x"]]))
assert "<script>alert(1)</script>" not in html
assert "&lt;script&gt;" 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 "<tbody>" 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"
2 changes: 2 additions & 0 deletions web/components/tables/PatientList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -1118,6 +1119,7 @@ export const PatientList = forwardRef<PatientListRef, PatientListProps>(({ initi
>
<LayoutGrid className="size-5" />
</IconButton>
<TableExportButton title={translation('patients')} />
{!derivedVirtualMode && (
<IconButton
tooltip={translation('addPatient')}
Expand Down
54 changes: 54 additions & 0 deletions web/components/tables/TableExportButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { useCallback, useState } from 'react'
import { IconButton, useTableStateContext } from '@helpwave/hightide'
import { Loader2, Printer } from 'lucide-react'
import { useTasksTranslation } from '@/i18n/useTasksTranslation'
import { cellToText, downloadTablePdf } from '@/utils/tableExport'

type TableExportButtonProps = {
title: string,
subtitle?: string,
meta?: Record<string, string>,
/** 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 (
<IconButton
tooltip={translation('print')}
color="neutral"
className="min-h-11 min-w-11 print:hidden"
disabled={isExporting}
onClick={() => void handleExport()}
>
{isExporting ? <Loader2 className="size-5 animate-spin" /> : <Printer className="size-5" />}
</IconButton>
)
}
2 changes: 2 additions & 0 deletions web/components/tables/TaskList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -1028,6 +1029,7 @@ export const TaskList = forwardRef<TaskListRef, TaskListProps>(({ tasks: initial
>
<LayoutGrid className="size-5" />
</IconButton>
<TableExportButton title={translation('tasks')} excludeColumnIds={['done']} />
<IconButton
tooltip={translation('addTask')}
color="primary"
Expand Down
Loading
Loading