chilltrack-api / pdf_export.py
rairo's picture
Claude Sonnet 5
Initial ChillTrack API: Flask backend for cold-chain temperature logging
432e043
Raw History Blame Contribute Delete
5.12 kB
"""
ChillTrack API — room-temperature log PDF export.
Recreates the paper form: one row per unit, one column per daily slot.
Built entirely in memory with an io.BytesIO() buffer (see sqx-api's
cohort_reports.build_pdf pattern) — nothing is ever written to disk.
"""
import io
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4, landscape
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import mm
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle
from slots import SLOT_TIMES
INK = colors.HexColor("#172033")
MUTED = colors.HexColor("#64748B")
BRAND = colors.HexColor("#1D4ED8")
OUT_OF_TOLERANCE_BG = colors.HexColor("#FDE2E1")
OUT_OF_TOLERANCE_TEXT = colors.HexColor("#B42318")
HEADER_BG = colors.HexColor("#1A1A2E")
def _styles():
base = getSampleStyleSheet()
return {
"title": ParagraphStyle("title", parent=base["Title"], fontName="Helvetica-Bold",
fontSize=16, leading=19, textColor=INK),
"subtitle": ParagraphStyle("subtitle", parent=base["Normal"], fontSize=9,
leading=12, textColor=MUTED),
"small": ParagraphStyle("small", parent=base["Normal"], fontSize=7.5,
leading=10, textColor=MUTED),
}
def build_room_temperature_pdf(units: list, readings: list, shift_date: str, roster: dict | None = None) -> io.BytesIO:
"""Builds the room-temperature log PDF for one shift_date.
units: list of ctk_units dicts (each needs id/name/setpoint_c/tolerance_c).
readings: list of ctk_readings dicts for this shift_date (any units/slots).
roster: optional {slot_label_or_range: [names]} for the footer signature block.
Returns a BytesIO positioned at 0, ready for send_file(...).
"""
buffer = io.BytesIO()
styles = _styles()
doc = SimpleDocTemplate(
buffer,
pagesize=landscape(A4),
leftMargin=10 * mm,
rightMargin=10 * mm,
topMargin=10 * mm,
bottomMargin=10 * mm,
title=f"Room Temperature Log — {shift_date}",
)
# Index readings by (unit_id, slot_index) for O(1) cell lookup.
by_cell = {}
for r in readings:
key = (r.get("unit_id"), r.get("slot_index"))
by_cell[key] = r
story = [
Paragraph("COLCOM — ROOM TEMPERATURE LOG", styles["title"]),
Paragraph(f"Shift date: {shift_date} (08:00 to 08:00 next day)", styles["subtitle"]),
Spacer(1, 3 * mm),
]
header_row = ["Unit (setpoint)"] + SLOT_TIMES
table_data = [header_row]
cell_styles = [] # list of TableStyle commands for out-of-tolerance cells
for row_idx, unit in enumerate(units, start=1):
unit_id = unit.get("id") or unit.get("unit_id")
label = f"{unit.get('name', '')} ({unit.get('setpoint_c')}°C ±{unit.get('tolerance_c')})"
row = [label]
for slot_index in range(len(SLOT_TIMES)):
reading = by_cell.get((unit_id, slot_index))
if reading is None:
row.append("")
else:
value = reading.get("value_c")
row.append(f"{value}" if value is not None else "")
if reading.get("within_tolerance") is False:
cell_styles.append(("BACKGROUND", (slot_index + 1, row_idx), (slot_index + 1, row_idx), OUT_OF_TOLERANCE_BG))
cell_styles.append(("TEXTCOLOR", (slot_index + 1, row_idx), (slot_index + 1, row_idx), OUT_OF_TOLERANCE_TEXT))
cell_styles.append(("FONTNAME", (slot_index + 1, row_idx), (slot_index + 1, row_idx), "Helvetica-Bold"))
table_data.append(row)
col_widths = [55 * mm] + [18 * mm] * len(SLOT_TIMES)
table = Table(table_data, colWidths=col_widths, repeatRows=1, hAlign="LEFT")
base_style = [
("FONTNAME", (0, 0), (-1, -1), "Helvetica"),
("FONTSIZE", (0, 0), (-1, -1), 7),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("ALIGN", (1, 0), (-1, -1), "CENTER"),
("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#CBD5E1")),
("BACKGROUND", (0, 0), (-1, 0), HEADER_BG),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, 0), 7.5),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]),
("LEFTPADDING", (0, 0), (-1, -1), 3),
("RIGHTPADDING", (0, 0), (-1, -1), 3),
("TOPPADDING", (0, 0), (-1, -1), 3),
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
] + cell_styles
table.setStyle(TableStyle(base_style))
story.append(table)
if roster:
story.append(Spacer(1, 5 * mm))
story.append(Paragraph("Shift roster", styles["subtitle"]))
for label, names in roster.items():
names_str = ", ".join(names) if isinstance(names, list) else str(names)
story.append(Paragraph(f"{label}: {names_str}", styles["small"]))
doc.build(story)
buffer.seek(0)
return buffer