"""
pdf_export.py
-------------
Generates a professional PDF report for a completed inspection.
Uses ReportLab Platypus (flow-based layout) so the document repaginates
cleanly regardless of how many form fields or issues exist.
Public API
----------
generate_inspection_pdf(inspection, form_fields, form_data, issues,
static_folder) -> bytes
"""
import io
import os
import json
from datetime import datetime
try:
from PIL import Image as PILImage
_PIL_AVAILABLE = True
except ImportError:
_PIL_AVAILABLE = False
from reportlab.lib.pagesizes import letter
from reportlab.lib import colors
from reportlab.lib.units import inch
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_RIGHT
from reportlab.platypus import (
SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle,
HRFlowable, KeepTogether, Image as RLImage
)
# ── Colour palette (matches the web UI) ──────────────────────────────────────
C_DARK = colors.HexColor('#1a1d23')
C_BLUE = colors.HexColor('#2563eb')
C_SLATE = colors.HexColor('#64748b')
C_LIGHT = colors.HexColor('#f1f5f9')
C_BORDER = colors.HexColor('#e2e8f0')
C_GREEN = colors.HexColor('#16a34a')
C_YELLOW = colors.HexColor('#d97706')
C_RED = colors.HexColor('#dc2626')
C_WHITE = colors.white
SEVERITY_COLORS = {
'critical': C_RED,
'high': C_RED,
'medium': C_YELLOW,
'low': C_SLATE,
}
STATUS_COLORS = {
'completed': C_GREEN,
'flagged': C_RED,
'in_progress': C_YELLOW,
}
# ── Style sheet ───────────────────────────────────────────────────────────────
def _build_styles():
base = getSampleStyleSheet()
def add(name, parent='Normal', **kw):
base.add(ParagraphStyle(name=name, parent=base[parent], **kw))
add('ReportTitle', parent='Normal',
fontSize=18, textColor=C_WHITE, fontName='Helvetica-Bold',
spaceAfter=2)
add('ReportSub', parent='Normal',
fontSize=9, textColor=colors.HexColor('#94a3b8'),
fontName='Helvetica', spaceAfter=0)
add('SectionHead', parent='Normal',
fontSize=10, textColor=C_DARK, fontName='Helvetica-Bold',
spaceBefore=8, spaceAfter=4)
add('FieldLabel', parent='Normal',
fontSize=7.5, textColor=C_SLATE, fontName='Helvetica',
spaceAfter=1)
add('FieldValue', parent='Normal',
fontSize=8.5, textColor=C_DARK, fontName='Helvetica',
spaceAfter=2)
add('MetaLabel', parent='Normal',
fontSize=7, textColor=C_SLATE, fontName='Helvetica',
spaceAfter=0)
add('MetaValue', parent='Normal',
fontSize=9, textColor=C_DARK, fontName='Helvetica-Bold',
spaceAfter=0)
add('IssueDesc', parent='Normal',
fontSize=8, textColor=C_DARK, fontName='Helvetica',
spaceAfter=2)
add('FooterStyle', parent='Normal',
fontSize=7, textColor=C_SLATE, fontName='Helvetica',
alignment=TA_CENTER)
return base
STYLES = _build_styles()
# ── Image compression helper ──────────────────────────────────────────────────
# Max pixel dimension for any side of an image embedded in the PDF.
# Phone cameras produce 12–48 MP images; 800px is plenty for a printed report.
_IMG_MAX_PX = 800
# JPEG quality for re-encoded images (0–95). 55 is visually acceptable for
# a printed report and reduces a typical phone photo by ~90% vs the original.
_IMG_JPEG_QUALITY = 55
def _compress_image(src_path: str) -> io.BytesIO | None:
"""
Load an image from disk, resize it to fit within _IMG_MAX_PX on any side,
re-encode as JPEG at _IMG_JPEG_QUALITY, and return a BytesIO buffer.
Returns None if PIL is unavailable or the image cannot be processed,
signalling the caller to fall back to the original file path.
"""
if not _PIL_AVAILABLE:
return None
try:
from PIL import ImageOps
img = PILImage.open(src_path)
# Apply EXIF orientation tag so rotated phone photos appear upright.
img = ImageOps.exif_transpose(img)
# Strip transparency — JPEG does not support alpha channels.
# Convert palette / RGBA / LA → RGB before saving.
if img.mode in ('RGBA', 'LA', 'P'):
background = PILImage.new('RGB', img.size, (255, 255, 255))
if img.mode == 'P':
img = img.convert('RGBA')
background.paste(img, mask=img.split()[-1] if img.mode in ('RGBA', 'LA') else None)
img = background
elif img.mode != 'RGB':
img = img.convert('RGB')
# Resize proportionally so neither dimension exceeds _IMG_MAX_PX.
img.thumbnail((_IMG_MAX_PX, _IMG_MAX_PX), PILImage.LANCZOS)
buf = io.BytesIO()
img.save(buf, format='JPEG', quality=_IMG_JPEG_QUALITY, optimize=True)
buf.seek(0)
return buf
except Exception:
return None
# ── Header / footer callbacks ─────────────────────────────────────────────────
def _on_page(canvas, doc, title, generated_at):
"""Draw page header band and footer on every page."""
w, h = letter
margin = 0.65 * inch
# ── Dark header band ──
canvas.saveState()
canvas.setFillColor(C_DARK)
canvas.rect(0, h - 1.1 * inch, w, 1.1 * inch, stroke=0, fill=1)
canvas.setFont('Helvetica-Bold', 13)
canvas.setFillColor(C_WHITE)
canvas.drawString(margin, h - 0.55 * inch, title)
canvas.setFont('Helvetica', 8)
canvas.setFillColor(colors.HexColor('#94a3b8'))
canvas.drawString(margin, h - 0.78 * inch, 'Janitorial Quality Control System')
# Page number — right-aligned
page_txt = f'Page {doc.page}'
canvas.setFont('Helvetica', 8)
canvas.setFillColor(colors.HexColor('#94a3b8'))
canvas.drawRightString(w - margin, h - 0.66 * inch, page_txt)
canvas.restoreState()
# ── Footer ──
canvas.saveState()
canvas.setStrokeColor(C_BORDER)
canvas.line(margin, 0.55 * inch, w - margin, 0.55 * inch)
canvas.setFont('Helvetica', 7)
canvas.setFillColor(C_SLATE)
canvas.drawString(margin, 0.35 * inch,
f'Generated: {generated_at} | Janitorial QC System')
canvas.drawRightString(w - margin, 0.35 * inch, 'CONFIDENTIAL')
canvas.restoreState()
# ── Score badge helper ────────────────────────────────────────────────────────
def _score_color(score):
if score is None:
return C_SLATE
s = float(score)
if s >= 90:
return C_GREEN
if s >= 70:
return C_YELLOW
return C_RED
# ── Meta info table ───────────────────────────────────────────────────────────
def _meta_table(inspection):
"""Render a 6-column label/value grid covering the key inspection metadata."""
date_str = inspection.inspection_date.strftime('%B %d, %Y')
time_str = inspection.inspection_date.strftime('%I:%M %p ET')
completed = (inspection.completed_at.strftime('%B %d, %Y %I:%M %p ET')
if inspection.completed_at else '—')
score_val = (f'{float(inspection.overall_score):.1f}%'
if inspection.overall_score is not None else '—')
status_txt = inspection.status.replace('_', ' ').title()
area_txt = inspection.area.name if inspection.area else '—'
def lbl(text):
return Paragraph(text, STYLES['MetaLabel'])
def val(text):
return Paragraph(str(text), STYLES['MetaValue'])
# Each row: alternating label / value columns (6 cols total)
rows = [
[lbl('INSPECTOR'), val(inspection.inspector.username),
lbl('DATE'), val(date_str),
lbl('TIME'), val(time_str)],
[lbl('FACILITY'), val(inspection.facility.name),
lbl('AREA'), val(area_txt),
lbl('COMPLETED'), val(completed)],
[lbl('TEMPLATE'), val(inspection.template.name),
lbl('FREQUENCY'), val(inspection.template.frequency.title()),
lbl('STATUS / SCORE'), val(f'{status_txt} {score_val}')],
]
col_w = (letter[0] - 1.3 * inch) / 6
tbl = Table(rows, colWidths=[col_w] * 6)
tbl.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, -1), C_LIGHT),
('BOX', (0, 0), (-1, -1), 0.5, C_BORDER),
('INNERGRID', (0, 0), (-1, -1), 0.5, C_BORDER),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('LEFTPADDING', (0, 0), (-1, -1), 8),
('RIGHTPADDING', (0, 0), (-1, -1), 8),
('TOPPADDING', (0, 0), (-1, -1), 5),
('BOTTOMPADDING',(0, 0), (-1, -1), 5),
# Label rows get a lighter text treatment (already in STYLES['MetaLabel'])
('TEXTCOLOR', (0, 0), (-1, -1), C_DARK),
]))
return tbl
# ── Score banner ──────────────────────────────────────────────────────────────
def _score_banner(inspection):
score = inspection.overall_score
score_txt = f'{float(score):.1f}%' if score is not None else 'N/A'
sc = _score_color(score)
grade = 'PASS' if (score is not None and float(score) >= 70) else 'FAIL'
grade_color = C_GREEN if grade == 'PASS' else C_RED
data = [[
Paragraph(f'Overall Score',
ParagraphStyle('x', fontName='Helvetica-Bold', fontSize=9,
textColor=C_WHITE, alignment=TA_CENTER)),
Paragraph(f'{score_txt}',
ParagraphStyle('x2', fontName='Helvetica-Bold', fontSize=22,
textColor=C_WHITE, alignment=TA_CENTER)),
Paragraph(f'{grade}',
ParagraphStyle('x3', fontName='Helvetica-Bold', fontSize=14,
textColor=C_WHITE, alignment=TA_CENTER)),
]]
w = letter[0] - 1.3 * inch
tbl = Table(data, colWidths=[w * 0.35, w * 0.35, w * 0.30])
tbl.setStyle(TableStyle([
('BACKGROUND', (0, 0), (1, 0), sc),
('BACKGROUND', (2, 0), (2, 0), grade_color),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('TOPPADDING', (0, 0), (-1, -1), 10),
('BOTTOMPADDING',(0, 0), (-1, -1), 10),
('LEFTPADDING', (0, 0), (-1, -1), 8),
('RIGHTPADDING', (0, 0), (-1, -1), 8),
]))
return tbl
# ── Form fields section ───────────────────────────────────────────────────────
def _star_string(score, max_stars=5):
"""Return a star string using only the filled ★ glyph.
Hollow ☆ is not supported by Helvetica and renders as a box.
Unselected stars are rendered as grey ★ via ReportLab XML markup.
Returns a plain string for canvas use; use _star_markup() for Paragraph.
"""
filled = min(int(score), max_stars)
return '★' * filled + '★' * (max_stars - filled)
_SKIP_TYPES = {'button_submit', 'button_print', 'button_email'}
_GRID_COLS = 12 # matches the web UI 12-column grid
def _form_fields_section(form_fields, form_data, static_folder):
"""
Reconstruct the web UI grid layout in PDF form.
Fields are grouped by their grid row number. Within each row the fields
are placed side-by-side with column widths proportional to their colSpan.
Section headings (type='section') are rendered as full-width banners
between groups of rows, exactly as they appear in the web UI.
"""
story = []
story.append(Paragraph('Inspection Results', STYLES['SectionHead']))
story.append(HRFlowable(width='100%', thickness=1, color=C_BORDER, spaceAfter=6))
FULL_W = letter[0] - 1.3 * inch # usable page width
UNIT = FULL_W / _GRID_COLS # width of one grid column unit
# ── Group fields by row number, preserving original order ────────────────
from collections import defaultdict, OrderedDict
rows_map = OrderedDict() # row_num -> [field, ...]
sections = {} # row_num -> section label that precedes this row
pending_section = None
for field in form_fields:
ftype = field.get('type', '')
if ftype in ('button_submit', 'button_print', 'button_email'):
continue
if ftype == 'section':
pending_section = field.get('label', '')
continue
row_num = field.get('row', 0)
if row_num not in rows_map:
rows_map[row_num] = []
if pending_section is not None:
sections[row_num] = pending_section
pending_section = None
rows_map[row_num].append(field)
# ── Pre-compute which rows have at least one visible field ───────────────
# A row is visible if it contains a text/textarea/label field, OR any other
# field type that has a non-empty value. This is used to suppress section
# banners whose every subordinate row is empty.
_ALWAYS_SHOW_PRE = {'label'}
def _row_has_content(fields_in_row):
for f in fields_in_row:
ft = f.get('type', '')
fid = str(f.get('id', ''))
v = form_data.get(fid, '')
if ft in _ALWAYS_SHOW_PRE:
return True
if ft == 'rating' and str(v).isdigit() and int(v) > 0: return True
if ft == 'pass_fail' and v: return True
if ft == 'checkbox' and v in ('yes','true'): return True
if ft == 'checkbox_group'and isinstance(v, list) and v: return True
if ft == 'image' and v and os.path.exists(os.path.join(static_folder, v)): return True
if ft == 'signature' and v and str(v).startswith('data:'): return True
if ft == 'table' and isinstance(v, list) and v: return True
if ft in ('number','date','email','radio','select') and v: return True
if ft in ('text','textarea') and v: return True
return False
# Build a set of section-trigger row numbers that have visible content somewhere
# in their subordinate rows (from their row_num up to the next section's row_num).
section_row_nums = sorted(sections.keys())
all_row_nums = list(rows_map.keys())
def _section_has_visible_rows(sec_row_num):
idx = section_row_nums.index(sec_row_num)
next_sec = section_row_nums[idx + 1] if idx + 1 < len(section_row_nums) else None
for rn in all_row_nums:
if rn < sec_row_num:
continue
if next_sec is not None and rn >= next_sec:
break
if _row_has_content(rows_map[rn]):
return True
return False
# ── Render row by row ─────────────────────────────────────────────────────
for row_num, fields_in_row in rows_map.items():
# Emit section banner only if its subordinate rows have visible content
if row_num in sections:
if not _section_has_visible_rows(row_num):
continue # skip the banner — all rows beneath it are empty
story.append(Spacer(1, 6))
sec_label = sections[row_num]
story.append(Paragraph(
sec_label,
ParagraphStyle('SecBanner', fontName='Helvetica-Bold',
fontSize=10, textColor=C_DARK,
spaceBefore=8, spaceAfter=3),
))
story.append(HRFlowable(width='100%', thickness=0.75,
color=C_BORDER, spaceAfter=4))
# Build a fixed 12-column table for this grid row.
# Each of the 12 grid columns gets exactly UNIT width.
# Fields occupy their designated columns via SPAN directives —
# this is the ReportLab equivalent of CSS grid-column.
# Unanswered fields are simply left as empty cells; no placeholder
# bookkeeping is needed because the table always has all 12 columns.
NCOLS = _GRID_COLS # 12
cells_12 = [Paragraph('', STYLES['FieldValue']) for _ in range(NCOLS)]
spans = [] # SPAN TableStyle directives
has_content = False
for field in fields_in_row:
ftype = field.get('type', '')
col = max(1, int(field.get('col', 1))) # 1-indexed
col_span = max(1, int(field.get('colSpan', 1)))
ci = col - 1 # 0-indexed start
ci_end = min(ci + col_span - 1, NCOLS - 1) # 0-indexed end
cell_w = UNIT * col_span
if col_span > 1:
spans.append(('SPAN', (ci, 0), (ci_end, 0)))
# ── Inline label (free-standing text element) ─────────────────────
if ftype == 'label':
fs_map = {'small': 8, 'normal': 9, 'large': 11, 'x-large': 13}
fs = fs_map.get(field.get('font_size', 'normal'), 9)
fw = 'Helvetica-Bold' if field.get('font_weight') == 'bold' else 'Helvetica'
cells_12[ci] = Paragraph(
field.get('text_content', ''),
ParagraphStyle('li', fontName=fw, fontSize=fs,
textColor=C_DARK, leading=fs + 3),
)
has_content = True
continue
fid = str(field.get('id', ''))
val = form_data.get(fid, '')
lbl = field.get('label', '')
# ── Skip fields with no meaningful value — leave cell empty ────────
def _skip(ft, v):
if ft == 'rating':
return not str(v).isdigit() or int(v) == 0
if ft == 'image':
ip = os.path.join(static_folder, v) if v else ''
return not v or not os.path.exists(ip)
if ft == 'signature':
return not v or not str(v).startswith('data:')
if ft == 'checkbox':
return v not in ('yes', 'true')
if ft == 'pass_fail':
return not v
if ft in ('checkbox_group', 'table'):
return not v or not isinstance(v, list) or len(v) == 0
return not v
if _skip(ftype, val):
continue # cell stays empty; SPAN ensures correct column width
lbl_p = Paragraph(lbl, STYLES['FieldLabel'])
# ── Value content ────────────────────────────────────────────────
if ftype == 'rating':
score_int = int(val)
filled_stars = '' + ('★' * score_int) + ''
empty_stars = ('' + ('★' * (5 - score_int)) + ''
if score_int < 5 else '')
stars = filled_stars + empty_stars + f' {score_int}/5'
val_p = Paragraph(stars, ParagraphStyle(
'rv', fontName='Helvetica', fontSize=9, leading=12))
elif ftype == 'image':
img_path = os.path.join(static_folder, val)
try:
compressed = _compress_image(img_path)
img_src = compressed if compressed is not None else img_path
val_p = RLImage(img_src,
width=min(cell_w - 12, 1.4 * inch),
height=1.0 * inch,
kind='proportional')
except Exception:
continue # leave cell empty
elif ftype == 'signature':
val_p = Paragraph('[Signature captured]', STYLES['FieldValue'])
elif ftype == 'checkbox':
val_p = Paragraph('Yes', STYLES['FieldValue'])
elif ftype == 'pass_fail':
is_pass = str(val).lower() in ('pass', 'yes', 'ok', 'good', 'acceptable', 'compliant')
colour = C_GREEN if is_pass else C_RED
val_p = Paragraph(
f'{str(val)}',
STYLES['FieldValue'],
)
elif ftype == 'checkbox_group':
val_p = Paragraph(', '.join(val), STYLES['FieldValue'])
elif ftype == 'table':
headers = field.get('col_headers',
list(val[0].keys()) if val else [])
tbl_data = ([headers] +
[[r.get(h, '') for h in headers] for r in val])
val_p = Table(tbl_data)
val_p.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), C_LIGHT),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 7),
('INNERGRID', (0, 0), (-1, -1), 0.25, C_BORDER),
('BOX', (0, 0), (-1, -1), 0.5, C_BORDER),
('TOPPADDING', (0, 0), (-1, -1), 2),
('BOTTOMPADDING', (0, 0), (-1, -1), 2),
]))
else:
disp = str(val) if val else ''
val_p = Paragraph(disp, STYLES['FieldValue'])
# ── Wrap into label-over-value form box ──────────────────────────
box = Table([[lbl_p], [val_p]], colWidths=[cell_w - 4])
box.setStyle(TableStyle([
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('LEFTPADDING', (0, 0), (-1, -1), 5),
('RIGHTPADDING', (0, 0), (-1, -1), 5),
('TOPPADDING', (0, 0), (0, 0), 2),
('BOTTOMPADDING',(0, 0), (0, 0), 1),
('TOPPADDING', (0, 1), (0, 1), 3),
('BOTTOMPADDING',(0, 1), (0, 1), 4),
('BOX', (0, 1), (0, 1), 0.5, C_BORDER),
('BACKGROUND', (0, 1), (0, 1), colors.HexColor('#f8fafc')),
]))
cells_12[ci] = box
has_content = True
# Skip the entire row if no field had meaningful content
if not has_content:
continue
row_tbl = Table([cells_12], colWidths=[UNIT] * NCOLS, hAlign='LEFT')
row_tbl.setStyle(TableStyle(
[
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('LEFTPADDING', (0, 0), (-1, -1), 2),
('RIGHTPADDING', (0, 0), (-1, -1), 2),
('TOPPADDING', (0, 0), (-1, -1), 2),
('BOTTOMPADDING',(0, 0), (-1, -1), 2),
] + spans
))
story.append(row_tbl)
return story
# ── Issues section ────────────────────────────────────────────────────────────
def _issues_section(issues):
if not issues:
return []
story = [
Spacer(1, 10),
Paragraph('Flagged Issues', STYLES['SectionHead']),
HRFlowable(width='100%', thickness=1, color=C_RED, spaceAfter=6),
]
headers = ['#', 'Severity', 'Area', 'Description', 'Status']
col_w = [0.3 * inch, 0.7 * inch, 1.1 * inch, 3.5 * inch, 0.9 * inch]
rows = [headers]
for i, issue in enumerate(issues, 1):
desc = issue.description[:120] + ('…' if len(issue.description) > 120 else '')
rows.append([
str(i),
issue.severity.title(),
issue.area.name if issue.area else (issue.resolved_facility.name if issue.resolved_facility else '—'),
desc,
issue.status.replace('_', ' ').title(),
])
tbl = Table(rows, colWidths=col_w, repeatRows=1)
sev_styles = []
for r, issue in enumerate(issues, 1):
sc = SEVERITY_COLORS.get(issue.severity, C_SLATE)
sev_styles.append(('TEXTCOLOR', (1, r), (1, r), sc))
sev_styles.append(('FONTNAME', (1, r), (1, r), 'Helvetica-Bold'))
tbl.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), C_DARK),
('TEXTCOLOR', (0, 0), (-1, 0), C_WHITE),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 8),
('INNERGRID', (0, 0), (-1, -1), 0.25, C_BORDER),
('BOX', (0, 0), (-1, -1), 0.5, C_BORDER),
('ROWBACKGROUNDS',(0, 1), (-1, -1), [C_WHITE, C_LIGHT]),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('TOPPADDING', (0, 0), (-1, -1), 4),
('BOTTOMPADDING',(0, 0), (-1, -1), 4),
('LEFTPADDING', (0, 0), (-1, -1), 5),
('RIGHTPADDING', (0, 0), (-1, -1), 5),
*sev_styles,
]))
story.append(tbl)
return story
# ── Notes section ─────────────────────────────────────────────────────────────
def _notes_section(inspection):
notes_text = None
if inspection.notes:
try:
parsed = json.loads(inspection.notes)
if isinstance(parsed, dict):
notes_text = parsed.get('_inspector_notes') or parsed.get('notes')
except (json.JSONDecodeError, TypeError):
notes_text = inspection.notes
if not notes_text:
return []
return [
Spacer(1, 10),
Paragraph('Inspector Notes', STYLES['SectionHead']),
HRFlowable(width='100%', thickness=1, color=C_BORDER, spaceAfter=6),
Paragraph(str(notes_text),
ParagraphStyle('Notes', fontName='Helvetica', fontSize=8.5,
textColor=C_DARK, leading=13,
backColor=colors.HexColor('#fffbeb'),
borderPad=6, spaceAfter=6)),
]
# ── Public entry point ────────────────────────────────────────────────────────
def generate_inspection_pdf(inspection, form_fields, form_data, issues,
static_folder) -> bytes:
"""
Build and return a PDF byte-string for the given inspection.
Parameters
----------
inspection : Inspection model instance
form_fields : list of field dicts from template.get_form_schema()
form_data : dict of {field_id: value}
issues : list of Issue model instances
static_folder: absolute path to app/static (for resolving photo paths)
"""
buf = io.BytesIO()
generated_at = datetime.now().strftime('%B %d, %Y %I:%M %p ET')
report_title = f'Inspection Report — {inspection.template.name}'
doc = SimpleDocTemplate(
buf,
pagesize=letter,
leftMargin=0.65 * inch,
rightMargin=0.65 * inch,
topMargin=1.25 * inch, # leave room for the header band
bottomMargin=0.75 * inch,
title=report_title,
author='Janitorial QC System',
)
def _page_cb(canvas, doc):
_on_page(canvas, doc, report_title, generated_at)
story = []
# ── Score banner ──
story.append(_score_banner(inspection))
story.append(Spacer(1, 8))
# ── Meta table ──
story.append(_meta_table(inspection))
story.append(Spacer(1, 12))
# ── Form fields ──
story.extend(_form_fields_section(form_fields, form_data, static_folder))
# ── Inspector notes ──
story.extend(_notes_section(inspection))
# ── Issues ──
story.extend(_issues_section(issues))
# ── Signature line ──
story.append(Spacer(1, 24))
w = letter[0] - 1.3 * inch
sig_data = [['Inspector Signature', '', 'Date']]
sig_tbl = Table(sig_data, colWidths=[w * 0.45, w * 0.1, w * 0.45])
sig_tbl.setStyle(TableStyle([
('LINEABOVE', (0, 0), (0, 0), 0.75, C_DARK),
('LINEABOVE', (2, 0), (2, 0), 0.75, C_DARK),
('FONTNAME', (0, 0), (-1, -1), 'Helvetica'),
('FONTSIZE', (0, 0), (-1, -1), 8),
('TEXTCOLOR', (0, 0), (-1, -1), C_SLATE),
('TOPPADDING', (0, 0), (-1, -1), 4),
('BOTTOMPADDING',(0, 0), (-1, -1), 0),
]))
story.append(sig_tbl)
doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb)
return buf.getvalue()
# ══════════════════════════════════════════════════════════════════════════════
# ISSUE PDF
# ══════════════════════════════════════════════════════════════════════════════
def generate_issue_pdf(issue, static_folder: str) -> bytes:
"""Return a PDF byte-string for a single Issue.
Parameters
----------
issue : Issue model instance (relationships pre-loaded by caller)
static_folder : absolute path to app/static (for resolving photo paths)
"""
buf = io.BytesIO()
generated_at = datetime.now().strftime('%B %d, %Y %I:%M %p ET')
report_title = f'Issue Report — Issue #{issue.id}'
doc = SimpleDocTemplate(
buf,
pagesize=letter,
leftMargin=0.65 * inch,
rightMargin=0.65 * inch,
topMargin=1.25 * inch,
bottomMargin=0.75 * inch,
title=report_title,
author='Janitorial QC System',
)
def _page_cb(canvas, doc):
_on_page(canvas, doc, report_title, generated_at)
pw = letter[0] - 1.3 * inch # usable page width
# ── Severity / status banner ──────────────────────────────────────────────
sev = issue.severity or 'low'
sev_color = SEVERITY_COLORS.get(sev, C_SLATE)
stat_txt = (issue.status or '').replace('_', ' ').title()
banner_data = [[
Paragraph(f'{sev.upper()}',
ParagraphStyle('b1', fontName='Helvetica-Bold', fontSize=10,
textColor=C_WHITE, alignment=TA_CENTER)),
Paragraph(f'Issue #{issue.id}',
ParagraphStyle('b2', fontName='Helvetica-Bold', fontSize=16,
textColor=C_WHITE, alignment=TA_CENTER)),
Paragraph(f'{stat_txt}',
ParagraphStyle('b3', fontName='Helvetica-Bold', fontSize=10,
textColor=C_WHITE, alignment=TA_CENTER)),
]]
banner = Table(banner_data, colWidths=[pw * 0.20, pw * 0.55, pw * 0.25])
banner.setStyle(TableStyle([
('BACKGROUND', (0, 0), (0, 0), sev_color),
('BACKGROUND', (1, 0), (1, 0), C_DARK),
('BACKGROUND', (2, 0), (2, 0), C_SLATE),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('TOPPADDING', (0, 0), (-1, -1), 10),
('BOTTOMPADDING', (0, 0), (-1, -1), 10),
('LEFTPADDING', (0, 0), (-1, -1), 8),
('RIGHTPADDING', (0, 0), (-1, -1), 8),
]))
# ── Details grid ─────────────────────────────────────────────────────────
def lbl(t):
return Paragraph(t, STYLES['MetaLabel'])
def val(t):
return Paragraph(str(t) if t else '—', STYLES['MetaValue'])
facility_name = issue.resolved_facility.name if issue.resolved_facility else '—'
contract_name = (issue.resolved_facility.project.name
if issue.resolved_facility and issue.resolved_facility.project else '—')
area_name = issue.area.name if issue.area else '—'
reporter_name = issue.reporter.display_name if issue.reporter else '—'
assigned_name = issue.assigned_user.display_name if issue.assigned_user else '— Unassigned —'
reported_str = issue.reported_at.strftime('%b %d, %Y %I:%M %p') if issue.reported_at else '—'
resolved_str = issue.resolved_at.strftime('%b %d, %Y %I:%M %p') if issue.resolved_at else '—'
cw = pw / 4
detail_rows = [
[lbl('CONTRACT'), val(contract_name), lbl('FACILITY'), val(facility_name)],
[lbl('AREA'), val(area_name), lbl('REPORTED BY'), val(reporter_name)],
[lbl('ASSIGNED TO'), val(assigned_name), lbl('REPORTED'), val(reported_str)],
[lbl('STATUS'), val(stat_txt), lbl('RESOLVED'), val(resolved_str)],
]
if issue.inspection_id:
detail_rows.append([lbl('INSPECTION'), val(f'#{issue.inspection_id}'),
lbl(''), val('')])
detail_tbl = Table(detail_rows, colWidths=[cw] * 4)
detail_tbl.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, -1), C_LIGHT),
('BOX', (0, 0), (-1, -1), 0.5, C_BORDER),
('INNERGRID', (0, 0), (-1, -1), 0.5, C_BORDER),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('LEFTPADDING', (0, 0), (-1, -1), 8),
('RIGHTPADDING', (0, 0), (-1, -1), 8),
('TOPPADDING', (0, 0), (-1, -1), 5),
('BOTTOMPADDING', (0, 0), (-1, -1), 5),
]))
# ── Section header helper ─────────────────────────────────────────────────
def _section(title, color=C_BORDER):
return [
Spacer(1, 10),
Paragraph(title, STYLES['SectionHead']),
HRFlowable(width='100%', thickness=1, color=color, spaceAfter=6),
]
# ── Photo grid helper ─────────────────────────────────────────────────────
def _photo_grid(paths, max_w=2.4 * inch, max_h=2.0 * inch, cols=3):
valid = []
for p in paths:
if not p:
continue
abs_p = os.path.join(static_folder, p)
if os.path.exists(abs_p):
valid.append(abs_p)
if not valid:
return [Paragraph('(photos not found on disk)', STYLES['FieldValue'])]
cells = []
for abs_p in valid:
try:
compressed = _compress_image(abs_p)
src = compressed if compressed is not None else abs_p
cells.append(RLImage(src, width=max_w, height=max_h, kind='proportional'))
except Exception:
cells.append(Paragraph('(unreadable)', STYLES['FieldValue']))
while len(cells) % cols != 0:
cells.append(Paragraph('', STYLES['FieldValue']))
rows = [cells[i:i + cols] for i in range(0, len(cells), cols)]
tbl = Table(rows, colWidths=[max_w + 6] * cols)
tbl.setStyle(TableStyle([
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
('TOPPADDING', (0, 0), (-1, -1), 4),
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
('LEFTPADDING', (0, 0), (-1, -1), 4),
('RIGHTPADDING', (0, 0), (-1, -1), 4),
]))
return [tbl]
# ── Assemble story ────────────────────────────────────────────────────────
story = []
story.append(banner)
story.append(Spacer(1, 10))
story.append(detail_tbl)
# Description
story.extend(_section('Description'))
story.append(Paragraph(
issue.description or '—',
ParagraphStyle('Desc', fontName='Helvetica', fontSize=9,
textColor=C_DARK, leading=14,
backColor=colors.HexColor('#f8fafc'),
borderPad=8, spaceAfter=6),
))
# Photo Evidence
evidence_paths = []
if issue.photo_path:
evidence_paths.append(issue.photo_path)
evidence_paths.extend(issue.mobile_photo_paths or [])
if evidence_paths:
story.extend(_section('Photo Evidence'))
story.extend(_photo_grid(evidence_paths))
# Resolution Details
if issue.result_notes or issue.result_photos:
story.extend(_section('Resolution Details', C_GREEN))
if issue.result_notes:
story.append(Paragraph(
issue.result_notes,
ParagraphStyle('Res', fontName='Helvetica', fontSize=9,
textColor=C_DARK, leading=14,
backColor=colors.HexColor('#f0fdf4'),
borderPad=8, spaceAfter=6),
))
if issue.result_photos:
story.extend(_photo_grid(issue.result_photos))
# Verification
if issue.verified_at:
story.extend(_section('Verification', C_GREEN))
verifier_name = issue.verifier.display_name if issue.verifier else '—'
verified_str = issue.verified_at.strftime('%b %d, %Y %I:%M %p')
v_rows = [
[lbl('VERIFIED BY'), val(verifier_name), lbl('VERIFIED ON'), val(verified_str)],
]
if issue.verification_note:
v_rows.append([lbl('NOTE'),
Paragraph(issue.verification_note, STYLES['MetaValue']),
lbl(''), val('')])
v_tbl = Table(v_rows, colWidths=[cw] * 4)
v_tbl.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, -1), colors.HexColor('#f0fdf4')),
('BOX', (0, 0), (-1, -1), 0.5, C_BORDER),
('INNERGRID', (0, 0), (-1, -1), 0.5, C_BORDER),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('LEFTPADDING', (0, 0), (-1, -1), 8),
('RIGHTPADDING', (0, 0), (-1, -1), 8),
('TOPPADDING', (0, 0), (-1, -1), 5),
('BOTTOMPADDING', (0, 0), (-1, -1), 5),
]))
story.append(v_tbl)
doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb)
return buf.getvalue()
# ══════════════════════════════════════════════════════════════════════════════
# SCHEDULED REPORT PDF
# ══════════════════════════════════════════════════════════════════════════════
def generate_scheduled_report_pdf(report_name, frequency, start, end,
facility_name=None, data=None):
"""Generate a PDF summary for a scheduled report email attachment.
Parameters
----------
report_name : str — the ScheduledReport.name
frequency : str — 'daily' / 'weekly' / 'monthly'
start, end : datetime — the reporting window
facility_name : str | None — scoped facility name, or None for all
data : dict — the assembled report data from _build_report_data()
Returns
-------
bytes — the PDF content
"""
if data is None:
data = {}
buf = io.BytesIO()
doc = SimpleDocTemplate(
buf, pagesize=letter,
leftMargin=0.65 * inch, rightMargin=0.65 * inch,
topMargin=1.0 * inch, bottomMargin=0.65 * inch,
)
generated_at = datetime.now().strftime('%Y-%m-%d %H:%M')
title_text = f'{frequency.title()} Report — {report_name}'
def _page_cb(canvas, doc):
_on_page(canvas, doc, title_text, generated_at)
story = []
pw = letter[0] - 1.3 * inch # usable page width
# ── Sub-header ────────────────────────────────────────────────────────
period = f'{start.strftime("%b %d, %Y")} — {end.strftime("%b %d, %Y")}'
scope = f'Facility: {facility_name}' if facility_name else 'All Facilities'
story.append(Paragraph(f'{period} · {scope}', STYLES['ReportSub']))
story.append(Spacer(1, 12))
# ── KPI cards (summary / facility report types) ───────────────────────
total_insp = data.get('total_inspections', 0)
completed = data.get('completed', 0)
open_iss = data.get('open_issues', 0)
avg_score = data.get('avg_score')
kpi_data = [[
Paragraph('Inspections', STYLES['FieldLabel']),
Paragraph('Completed', STYLES['FieldLabel']),
Paragraph('Open Issues', STYLES['FieldLabel']),
Paragraph('Avg Score', STYLES['FieldLabel']),
], [
Paragraph(f'{total_insp}', STYLES['FieldValue']),
Paragraph(f'{completed}', STYLES['FieldValue']),
Paragraph(f'{open_iss}', STYLES['FieldValue']),
Paragraph(
f'{f"{avg_score:.1f}%" if avg_score else "—"}',
STYLES['FieldValue'],
),
]]
kpi_tbl = Table(kpi_data, colWidths=[pw * 0.25] * 4)
kpi_tbl.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, -1), C_LIGHT),
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('TOPPADDING', (0, 0), (-1, -1), 8),
('BOTTOMPADDING', (0, 0), (-1, -1), 8),
('BOX', (0, 0), (-1, -1), 0.5, C_BORDER),
('INNERGRID', (0, 0), (-1, -1), 0.25, C_BORDER),
('ROUNDEDCORNERS', [6, 6, 6, 6]),
]))
story.append(kpi_tbl)
story.append(Spacer(1, 16))
# ── Facility scores table ─────────────────────────────────────────────
fac_scores = data.get('facility_scores', [])
if fac_scores:
story.append(Paragraph('Facility Scores', STYLES['SectionHead']))
tbl_data = [['Facility', 'Inspections', 'Avg Score']]
for row in fac_scores:
sc = float(row.avg) if hasattr(row, 'avg') else float(row[1])
cnt = row.count if hasattr(row, 'count') else row[2]
nm = row.name if hasattr(row, 'name') else row[0]
sc_color = C_GREEN if sc >= 90 else C_YELLOW if sc >= 70 else C_RED
tbl_data.append([
Paragraph(str(nm), STYLES['FieldValue']),
Paragraph(str(cnt), STYLES['FieldValue']),
Paragraph(f'{sc:.1f}%', STYLES['FieldValue']),
])
fac_tbl = Table(tbl_data, colWidths=[pw * 0.50, pw * 0.25, pw * 0.25])
fac_tbl.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), C_LIGHT),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 8),
('ALIGN', (1, 0), (-1, -1), 'CENTER'),
('INNERGRID', (0, 0), (-1, -1), 0.25, C_BORDER),
('BOX', (0, 0), (-1, -1), 0.5, C_BORDER),
('TOPPADDING', (0, 0), (-1, -1), 4),
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
]))
story.append(fac_tbl)
story.append(Spacer(1, 14))
# ── Critical / High issues ────────────────────────────────────────────
crit_issues = data.get('critical_issues', [])
if crit_issues:
story.append(Paragraph('Open Critical / High Issues', STYLES['SectionHead']))
tbl_data = [['#', 'Severity', 'Facility / Area', 'Description', 'Reported']]
for iss in crit_issues:
sev_c = SEVERITY_COLORS.get(iss.severity, C_SLATE)
tbl_data.append([
Paragraph(f'#{iss.id}', STYLES['FieldValue']),
Paragraph(f'{iss.severity.title()}', STYLES['FieldValue']),
Paragraph(f'{iss.area.facility.name} / {iss.area.name}', STYLES['FieldValue']),
Paragraph(iss.description[:80] + ('…' if len(iss.description) > 80 else ''), STYLES['IssueDesc']),
Paragraph(iss.reported_at.strftime('%b %d'), STYLES['FieldValue']),
])
iss_tbl = Table(tbl_data, colWidths=[pw * 0.07, pw * 0.12, pw * 0.25, pw * 0.40, pw * 0.16])
iss_tbl.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#fef2f2')),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 7.5),
('INNERGRID', (0, 0), (-1, -1), 0.25, C_BORDER),
('BOX', (0, 0), (-1, -1), 0.5, C_BORDER),
('TOPPADDING', (0, 0), (-1, -1), 3),
('BOTTOMPADDING', (0, 0), (-1, -1), 3),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
]))
story.append(iss_tbl)
story.append(Spacer(1, 14))
# ── Open issues list (issues report type) ─────────────────────────────
all_issues = data.get('issues', [])
if all_issues and not crit_issues:
story.append(Paragraph(f'Open Issues ({len(all_issues)})', STYLES['SectionHead']))
tbl_data = [['#', 'Severity', 'Facility / Area', 'Status', 'Description']]
for iss in all_issues:
sev_c = SEVERITY_COLORS.get(iss.severity, C_SLATE)
tbl_data.append([
Paragraph(f'#{iss.id}', STYLES['FieldValue']),
Paragraph(f'{iss.severity.title()}', STYLES['FieldValue']),
Paragraph(f'{iss.area.facility.name} / {iss.area.name}', STYLES['FieldValue']),
Paragraph(iss.status.replace('_', ' ').title(), STYLES['FieldValue']),
Paragraph(iss.description[:70] + ('…' if len(iss.description) > 70 else ''), STYLES['IssueDesc']),
])
iss_tbl = Table(tbl_data, colWidths=[pw * 0.07, pw * 0.12, pw * 0.25, pw * 0.16, pw * 0.40])
iss_tbl.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), C_LIGHT),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 7.5),
('INNERGRID', (0, 0), (-1, -1), 0.25, C_BORDER),
('BOX', (0, 0), (-1, -1), 0.5, C_BORDER),
('TOPPADDING', (0, 0), (-1, -1), 3),
('BOTTOMPADDING', (0, 0), (-1, -1), 3),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
]))
story.append(iss_tbl)
# ── Footer note ───────────────────────────────────────────────────────
story.append(Spacer(1, 20))
story.append(HRFlowable(width='100%', thickness=0.5, color=C_BORDER))
story.append(Spacer(1, 6))
story.append(Paragraph(
'Janitorial QC System — automated scheduled report',
STYLES['FooterStyle'],
))
doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb)
return buf.getvalue()