1682 lines
72 KiB
Python
1682 lines
72 KiB
Python
"""
|
||
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, landscape
|
||
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)
|
||
# ── Summary PDF styles ────────────────────────────────────────────────
|
||
add('SummaryTitle', parent='Normal',
|
||
fontSize=18, textColor=C_DARK, fontName='Helvetica-Bold', spaceAfter=2)
|
||
add('ReportSubtitle', parent='Normal',
|
||
fontSize=11, textColor=C_SLATE, fontName='Helvetica', spaceAfter=2)
|
||
add('Meta', parent='Normal',
|
||
fontSize=8, textColor=C_SLATE, fontName='Helvetica', spaceAfter=1)
|
||
add('ScoreValue', parent='Normal',
|
||
fontSize=22, textColor=C_BLUE, fontName='Helvetica-Bold',
|
||
alignment=TA_CENTER, spaceAfter=0)
|
||
add('ScoreLabel', parent='Normal',
|
||
fontSize=7.5, textColor=C_SLATE, fontName='Helvetica',
|
||
alignment=TA_CENTER, spaceAfter=0)
|
||
add('SectionHeader', parent='Normal',
|
||
fontSize=10, textColor=C_DARK, fontName='Helvetica-Bold',
|
||
spaceBefore=8, spaceAfter=4)
|
||
add('TableHeader', parent='Normal',
|
||
fontSize=8.5, textColor=C_WHITE, fontName='Helvetica-Bold',
|
||
alignment=TA_CENTER, spaceAfter=0)
|
||
add('TableCell', parent='Normal',
|
||
fontSize=8.5, textColor=C_DARK, fontName='Helvetica', spaceAfter=0)
|
||
|
||
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."""
|
||
start_date = inspection.inspection_date.strftime('%B %d, %Y %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('START DATE'), val(start_date),
|
||
lbl('COMPLETED DATE'), val(completed)],
|
||
[lbl('FACILITY'), val(inspection.facility.name),
|
||
lbl('AREA'), val(area_txt),
|
||
lbl('STATUS / SCORE'), val(f'{status_txt} {score_val}')],
|
||
[lbl('TEMPLATE'), val(inspection.template.name),
|
||
lbl('FREQUENCY'), val(inspection.template.frequency.title()),
|
||
lbl(''), 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'<font color="white"><b>Overall Score</b></font>',
|
||
ParagraphStyle('x', fontName='Helvetica-Bold', fontSize=9,
|
||
textColor=C_WHITE, alignment=TA_CENTER)),
|
||
Paragraph(f'<font color="white"><b>{score_txt}</b></font>',
|
||
ParagraphStyle('x2', fontName='Helvetica-Bold', fontSize=22,
|
||
textColor=C_WHITE, alignment=TA_CENTER)),
|
||
Paragraph(f'<font color="white"><b>{grade}</b></font>',
|
||
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 = '<font color="#f59e0b">' + ('★' * score_int) + '</font>'
|
||
empty_stars = ('<font color="#d1d5db">' + ('★' * (5 - score_int)) + '</font>'
|
||
if score_int < 5 else '')
|
||
stars = filled_stars + empty_stars + f' <font color="#64748b">{score_int}/5</font>'
|
||
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'<font color="{colour.hexval()}">{str(val)}</font>',
|
||
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 _collect_media_keys(form_data):
|
||
"""Return the set of 'uploads/...' storage keys referenced in form_data
|
||
(image field values, including any nested in lists/dicts). Signature values
|
||
are inline 'data:' base64 and are naturally excluded."""
|
||
keys = set()
|
||
|
||
def _walk(v):
|
||
if isinstance(v, str):
|
||
if v.startswith('uploads/'):
|
||
keys.add(v)
|
||
elif isinstance(v, dict):
|
||
for x in v.values():
|
||
_walk(x)
|
||
elif isinstance(v, (list, tuple)):
|
||
for x in v:
|
||
_walk(x)
|
||
|
||
_walk(form_data or {})
|
||
return keys
|
||
|
||
|
||
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}'
|
||
|
||
# Make referenced photos available as local file paths for ReportLab/PIL.
|
||
# local backend: no-op (returns the real static folder); s3 backend:
|
||
# downloads the form's image keys to a temp dir. Cleaned up after build.
|
||
from app.utils import storage
|
||
static_folder, _cleanup_media = storage.materialize_to_dir(
|
||
_collect_media_keys(form_data)
|
||
)
|
||
|
||
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)
|
||
|
||
try:
|
||
doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb)
|
||
return buf.getvalue()
|
||
finally:
|
||
_cleanup_media()
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
# 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}'
|
||
|
||
# Make referenced photos available as local file paths for ReportLab/PIL.
|
||
# local backend: no-op; s3 backend: downloads the issue's photo keys to a
|
||
# temp dir. Cleaned up after build.
|
||
from app.utils import storage
|
||
_issue_keys = [issue.photo_path] + list(issue.mobile_photo_paths or []) \
|
||
+ list(issue.result_photos or [])
|
||
static_folder, _cleanup_media = storage.materialize_to_dir(_issue_keys)
|
||
|
||
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'<font color="white"><b>{sev.upper()}</b></font>',
|
||
ParagraphStyle('b1', fontName='Helvetica-Bold', fontSize=10,
|
||
textColor=C_WHITE, alignment=TA_CENTER)),
|
||
Paragraph(f'<font color="white"><b>Issue #{issue.id}</b></font>',
|
||
ParagraphStyle('b2', fontName='Helvetica-Bold', fontSize=16,
|
||
textColor=C_WHITE, alignment=TA_CENTER)),
|
||
Paragraph(f'<font color="white"><b>{stat_txt}</b></font>',
|
||
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)
|
||
|
||
try:
|
||
doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb)
|
||
return buf.getvalue()
|
||
finally:
|
||
_cleanup_media()
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
# 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('<b>Inspections</b>', STYLES['FieldLabel']),
|
||
Paragraph('<b>Completed</b>', STYLES['FieldLabel']),
|
||
Paragraph('<b>Open Issues</b>', STYLES['FieldLabel']),
|
||
Paragraph('<b>Avg Score</b>', STYLES['FieldLabel']),
|
||
], [
|
||
Paragraph(f'<font size="14"><b>{total_insp}</b></font>', STYLES['FieldValue']),
|
||
Paragraph(f'<font size="14" color="{C_GREEN.hexval()}"><b>{completed}</b></font>', STYLES['FieldValue']),
|
||
Paragraph(f'<font size="14" color="{C_YELLOW.hexval()}"><b>{open_iss}</b></font>', STYLES['FieldValue']),
|
||
Paragraph(
|
||
f'<font size="14" color="{C_BLUE.hexval()}"><b>{f"{avg_score:.1f}%" if avg_score else "—"}</b></font>',
|
||
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'<font color="{sc_color.hexval()}">{sc:.1f}%</font>', 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'<font color="{sev_c.hexval()}">{iss.severity.title()}</font>', 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'<font color="{sev_c.hexval()}">{iss.severity.title()}</font>', 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()
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
# ISSUES LIST PDF
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
|
||
def generate_issues_list_pdf(issues, filter_summary: str = '') -> bytes:
|
||
"""Return a PDF byte-string for a filtered list of issues.
|
||
|
||
Parameters
|
||
----------
|
||
issues : list of Issue model instances
|
||
filter_summary : human-readable string describing active filters (optional)
|
||
"""
|
||
buf = io.BytesIO()
|
||
generated_at = datetime.now().strftime('%B %d, %Y %I:%M %p ET')
|
||
report_title = 'Issues List'
|
||
|
||
page_size = landscape(letter)
|
||
doc = SimpleDocTemplate(
|
||
buf,
|
||
pagesize=page_size,
|
||
leftMargin=0.65 * inch,
|
||
rightMargin=0.65 * inch,
|
||
topMargin=1.1 * 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 = page_size[0] - 1.3 * inch # usable page width (~9.7 in)
|
||
|
||
story = []
|
||
|
||
if filter_summary:
|
||
story.append(Paragraph(f'Filters: {filter_summary}', STYLES['ReportSub']))
|
||
story.append(Paragraph(f'Total records: {len(issues)}', STYLES['ReportSub']))
|
||
story.append(Spacer(1, 10))
|
||
|
||
if not issues:
|
||
story.append(Paragraph('No issues match the selected filters.', STYLES['FieldValue']))
|
||
doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb)
|
||
return buf.getvalue()
|
||
|
||
# ── Column widths ─────────────────────────────────────────────────────────
|
||
# #, Reported, Severity, Contract, Facility / Area, Description, Status, SLA, Assigned
|
||
col_w = [
|
||
pw * 0.05, # #
|
||
pw * 0.10, # Reported
|
||
pw * 0.08, # Severity
|
||
pw * 0.13, # Contract
|
||
pw * 0.14, # Facility / Area
|
||
pw * 0.28, # Description
|
||
pw * 0.10, # Status
|
||
pw * 0.07, # SLA
|
||
pw * 0.05, # Assigned (truncated)
|
||
]
|
||
|
||
hdr_style = ParagraphStyle('ILH', fontName='Helvetica-Bold', fontSize=7.5,
|
||
textColor=C_WHITE, leading=9)
|
||
val_style = ParagraphStyle('ILV', fontName='Helvetica', fontSize=7.5,
|
||
textColor=C_DARK, leading=9)
|
||
|
||
def _h(text):
|
||
return Paragraph(text, hdr_style)
|
||
|
||
def _v(text, color=None):
|
||
if color:
|
||
return Paragraph(f'<font color="{color.hexval()}">{text}</font>', val_style)
|
||
return Paragraph(text, val_style)
|
||
|
||
tbl_data = [[
|
||
_h('#'), _h('Reported'), _h('Severity'), _h('Contract'),
|
||
_h('Facility / Area'), _h('Description'), _h('Status'), _h('SLA'), _h('Assigned'),
|
||
]]
|
||
|
||
for iss in issues:
|
||
reported_str = iss.reported_at.strftime('%Y-%m-%d %H:%M') if iss.reported_at else '—'
|
||
|
||
sev = iss.severity or 'low'
|
||
sev_color = SEVERITY_COLORS.get(sev, C_SLATE)
|
||
|
||
contract_str = '—'
|
||
if iss.resolved_facility and iss.resolved_facility.project:
|
||
contract_str = iss.resolved_facility.project.name
|
||
facility_str = iss.resolved_facility.name if iss.resolved_facility else '—'
|
||
area_str = iss.area.name if iss.area else '—'
|
||
fac_area_str = f'{facility_str}\n{area_str}'
|
||
|
||
desc_str = iss.description or ''
|
||
if len(desc_str) > 90:
|
||
desc_str = desc_str[:90] + '...'
|
||
|
||
status_raw = iss.status or ''
|
||
if status_raw == 'resolved':
|
||
status_str = 'Resolved'
|
||
status_color = C_GREEN
|
||
elif status_raw == 'pending_verification':
|
||
status_str = 'Pending Verif.'
|
||
status_color = colors.HexColor('#0ea5e9')
|
||
elif status_raw == 'in_progress':
|
||
status_str = 'In Progress'
|
||
status_color = C_YELLOW
|
||
else:
|
||
status_str = 'Open'
|
||
status_color = C_RED
|
||
|
||
# Compute SLA inline (avoids circular import — same logic as sla.sla_status)
|
||
from app.utils.sla import sla_status as _sla_status
|
||
sla = _sla_status(iss)
|
||
if sla == 'breached':
|
||
sla_str, sla_color = 'Breached', C_RED
|
||
elif sla == 'at_risk':
|
||
sla_str, sla_color = 'At Risk', C_YELLOW
|
||
elif sla == 'ok':
|
||
sla_str, sla_color = 'OK', C_GREEN
|
||
else:
|
||
sla_str, sla_color = '—', C_SLATE
|
||
|
||
assigned_str = '—'
|
||
if iss.assigned_user:
|
||
n = iss.assigned_user.display_name
|
||
assigned_str = n[:18] + ('...' if len(n) > 18 else '')
|
||
|
||
tbl_data.append([
|
||
_v(f'#{iss.id}'),
|
||
_v(reported_str),
|
||
_v(sev.title(), color=sev_color),
|
||
_v(contract_str[:28] + ('...' if len(contract_str) > 28 else '')),
|
||
Paragraph(f'{facility_str[:28]}\n<font size="6.5" color="{C_SLATE.hexval()}">{area_str[:24]}</font>', val_style),
|
||
_v(desc_str),
|
||
_v(status_str, color=status_color),
|
||
_v(sla_str, color=sla_color),
|
||
_v(assigned_str),
|
||
])
|
||
|
||
tbl = Table(tbl_data, colWidths=col_w, repeatRows=1)
|
||
tbl.setStyle(TableStyle([
|
||
('BACKGROUND', (0, 0), (-1, 0), C_DARK),
|
||
('ROWBACKGROUNDS', (0, 1), (-1, -1), [C_WHITE, C_LIGHT]),
|
||
('INNERGRID', (0, 0), (-1, -1), 0.25, C_BORDER),
|
||
('BOX', (0, 0), (-1, -1), 0.5, C_BORDER),
|
||
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
||
('TOPPADDING', (0, 0), (-1, -1), 4),
|
||
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
|
||
('LEFTPADDING', (0, 0), (-1, -1), 4),
|
||
('RIGHTPADDING', (0, 0), (-1, -1), 4),
|
||
]))
|
||
story.append(tbl)
|
||
|
||
doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb)
|
||
return buf.getvalue()
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
# INSPECTIONS LIST PDF
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
|
||
def generate_inspections_list_pdf(inspections, filter_summary: str = '') -> bytes:
|
||
"""Return a PDF byte-string for a filtered list of inspections.
|
||
|
||
Parameters
|
||
----------
|
||
inspections : list of Inspection model instances
|
||
filter_summary : human-readable string describing active filters (optional)
|
||
"""
|
||
buf = io.BytesIO()
|
||
generated_at = datetime.now().strftime('%B %d, %Y %I:%M %p ET')
|
||
report_title = 'Inspections List'
|
||
|
||
page_size = landscape(letter)
|
||
doc = SimpleDocTemplate(
|
||
buf,
|
||
pagesize=page_size,
|
||
leftMargin=0.65 * inch,
|
||
rightMargin=0.65 * inch,
|
||
topMargin=1.1 * 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 = page_size[0] - 1.3 * inch # usable page width (~9.7 in)
|
||
|
||
story = []
|
||
|
||
# ── Filter summary line ───────────────────────────────────────────────────
|
||
if filter_summary:
|
||
story.append(Paragraph(f'Filters: {filter_summary}', STYLES['ReportSub']))
|
||
story.append(Paragraph(f'Total records: {len(inspections)}', STYLES['ReportSub']))
|
||
story.append(Spacer(1, 10))
|
||
|
||
if not inspections:
|
||
story.append(Paragraph('No inspections match the selected filters.', STYLES['FieldValue']))
|
||
doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb)
|
||
return buf.getvalue()
|
||
|
||
# ── Column widths (total = pw) ────────────────────────────────────────────
|
||
# Date, Contract, Facility, Area, Template, Inspector, Score, Status
|
||
col_w = [
|
||
pw * 0.11, # Date
|
||
pw * 0.16, # Contract
|
||
pw * 0.17, # Facility
|
||
pw * 0.10, # Area
|
||
pw * 0.16, # Template
|
||
pw * 0.12, # Inspector
|
||
pw * 0.08, # Score
|
||
pw * 0.10, # Status
|
||
]
|
||
|
||
# ── Table header ──────────────────────────────────────────────────────────
|
||
hdr_style = ParagraphStyle('LH', fontName='Helvetica-Bold', fontSize=7.5,
|
||
textColor=C_WHITE, leading=9)
|
||
val_style = ParagraphStyle('LV', fontName='Helvetica', fontSize=7.5,
|
||
textColor=C_DARK, leading=9)
|
||
|
||
def _h(text):
|
||
return Paragraph(text, hdr_style)
|
||
|
||
def _v(text, color=None):
|
||
if color:
|
||
return Paragraph(f'<font color="{color.hexval()}">{text}</font>', val_style)
|
||
return Paragraph(text, val_style)
|
||
|
||
tbl_data = [[
|
||
_h('Date'), _h('Contract'), _h('Facility'), _h('Area'),
|
||
_h('Template'), _h('Inspector'), _h('Score'), _h('Status'),
|
||
]]
|
||
|
||
for ins in inspections:
|
||
date_str = ins.inspection_date.strftime('%Y-%m-%d %H:%M')
|
||
contract_str = (ins.facility.project.name
|
||
if ins.facility and ins.facility.project else '—')
|
||
facility_str = ins.facility.name if ins.facility else '—'
|
||
area_str = ins.area.name if ins.area else '—'
|
||
template_str = ins.template.name if ins.template else '—'
|
||
inspector_str = ins.inspector.display_name if ins.inspector else '—'
|
||
|
||
if ins.overall_score is not None:
|
||
sc = float(ins.overall_score)
|
||
score_str = f'{sc:.1f}%'
|
||
score_color = C_GREEN if sc >= 90 else C_YELLOW if sc >= 70 else C_RED
|
||
else:
|
||
score_str = '—'
|
||
score_color = C_SLATE
|
||
|
||
status_raw = ins.status or ''
|
||
if status_raw == 'completed':
|
||
status_str = 'Submitted'
|
||
status_color = C_GREEN
|
||
elif status_raw == 'flagged':
|
||
status_str = 'Flagged'
|
||
status_color = C_RED
|
||
else:
|
||
status_str = status_raw.replace('_', ' ').title()
|
||
status_color = C_SLATE
|
||
|
||
follow_up_suffix = ' (Follow-up)' if ins.follow_up_required else ''
|
||
|
||
tbl_data.append([
|
||
_v(date_str),
|
||
_v(contract_str[:30] + ('...' if len(contract_str) > 30 else '')),
|
||
_v(facility_str[:35] + ('...' if len(facility_str) > 35 else '')),
|
||
_v(area_str[:20] + ('...' if len(area_str) > 20 else '')),
|
||
_v(template_str[:30] + ('...' if len(template_str) > 30 else '')),
|
||
_v(inspector_str[:22] + ('...' if len(inspector_str) > 22 else '')),
|
||
_v(score_str, color=score_color),
|
||
_v(status_str + follow_up_suffix, color=status_color),
|
||
])
|
||
|
||
tbl = Table(tbl_data, colWidths=col_w, repeatRows=1)
|
||
tbl.setStyle(TableStyle([
|
||
('BACKGROUND', (0, 0), (-1, 0), C_DARK),
|
||
('ROWBACKGROUNDS', (0, 1), (-1, -1), [C_WHITE, C_LIGHT]),
|
||
('INNERGRID', (0, 0), (-1, -1), 0.25, C_BORDER),
|
||
('BOX', (0, 0), (-1, -1), 0.5, C_BORDER),
|
||
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
||
('TOPPADDING', (0, 0), (-1, -1), 4),
|
||
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
|
||
('LEFTPADDING', (0, 0), (-1, -1), 4),
|
||
('RIGHTPADDING', (0, 0), (-1, -1), 4),
|
||
]))
|
||
story.append(tbl)
|
||
|
||
doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb)
|
||
return buf.getvalue()
|
||
|
||
|
||
# ── Facility Customer Summary PDF ─────────────────────────────────────────────
|
||
|
||
def generate_facility_summary_pdf(facility, days, start, now,
|
||
total_inspections, avg_score,
|
||
area_scores, open_issues, resolved_count):
|
||
"""Customer-facing one-page PDF summary for a facility."""
|
||
styles = _build_styles()
|
||
buf = io.BytesIO()
|
||
doc = SimpleDocTemplate(
|
||
buf, pagesize=letter,
|
||
leftMargin=0.75 * inch, rightMargin=0.75 * inch,
|
||
topMargin=0.75 * inch, bottomMargin=0.75 * inch,
|
||
)
|
||
page_w = letter[0] - 1.5 * inch
|
||
story = []
|
||
|
||
story.append(Paragraph(facility.name, styles['SummaryTitle']))
|
||
story.append(Paragraph('Facility Performance Summary', styles['ReportSubtitle']))
|
||
story.append(Paragraph(
|
||
'Period: {} to {} ({} days)'.format(
|
||
start.strftime('%b %d, %Y'), now.strftime('%b %d, %Y'), days),
|
||
styles['Meta'],
|
||
))
|
||
if getattr(facility, 'address', None):
|
||
story.append(Paragraph(facility.address, styles['Meta']))
|
||
story.append(Spacer(1, 0.15 * inch))
|
||
story.append(HRFlowable(width='100%', thickness=1, color=C_BORDER))
|
||
story.append(Spacer(1, 0.15 * inch))
|
||
|
||
score_str = '{}%'.format(avg_score) if avg_score is not None else '—'
|
||
score_color = (C_GREEN if avg_score and avg_score >= 80
|
||
else C_YELLOW if avg_score and avg_score >= 60 else C_RED)
|
||
|
||
def _kpi(label, value, col=C_BLUE):
|
||
hex_str = '%06x' % (col.hexval() & 0xFFFFFF)
|
||
return [Paragraph('<font color="#{}">'
|
||
'<b>{}</b></font>'.format(hex_str, value),
|
||
styles['ScoreValue']),
|
||
Paragraph(label, styles['ScoreLabel'])]
|
||
|
||
kpi_tbl = Table([[
|
||
_kpi('Inspections Completed', str(total_inspections)),
|
||
_kpi('Avg Score', score_str, score_color),
|
||
_kpi('Open Issues', str(len(open_issues)),
|
||
C_RED if open_issues else C_GREEN),
|
||
_kpi('Resolved This Period', str(resolved_count), C_GREEN),
|
||
]], colWidths=[page_w / 4] * 4)
|
||
kpi_tbl.setStyle(TableStyle([
|
||
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
|
||
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
||
('INNERGRID', (0, 0), (-1, -1), 0.5, C_BORDER),
|
||
('BOX', (0, 0), (-1, -1), 0.5, C_BORDER),
|
||
('BACKGROUND', (0, 0), (-1, -1), C_LIGHT),
|
||
('TOPPADDING', (0, 0), (-1, -1), 8),
|
||
('BOTTOMPADDING', (0, 0), (-1, -1), 8),
|
||
]))
|
||
story.append(kpi_tbl)
|
||
story.append(Spacer(1, 0.2 * inch))
|
||
|
||
_P = lambda t: Paragraph(t, styles['TableCell'])
|
||
_H = lambda t: Paragraph('<b>{}</b>'.format(t), styles['TableHeader'])
|
||
|
||
if area_scores:
|
||
story.append(Paragraph('Score by Area', styles['SectionHeader']))
|
||
story.append(Spacer(1, 0.05 * inch))
|
||
tbl_data = [[_H('Area'), _H('Avg Score'), _H('Inspections')]]
|
||
for a in area_scores:
|
||
avg = round(float(a.avg), 1)
|
||
col = C_GREEN if avg >= 80 else C_YELLOW if avg >= 60 else C_RED
|
||
hex_str = '%06x' % (col.hexval() & 0xFFFFFF)
|
||
tbl_data.append([
|
||
_P(a.name),
|
||
Paragraph('<font color="#{}">'
|
||
'<b>{}%</b></font>'.format(hex_str, avg),
|
||
styles['TableCell']),
|
||
_P(str(a.count)),
|
||
])
|
||
area_tbl = Table(tbl_data,
|
||
colWidths=[page_w * 0.55, page_w * 0.25, page_w * 0.20],
|
||
repeatRows=1)
|
||
area_tbl.setStyle(TableStyle([
|
||
('BACKGROUND', (0, 0), (-1, 0), C_DARK),
|
||
('ROWBACKGROUNDS', (0, 1), (-1, -1), [C_WHITE, C_LIGHT]),
|
||
('INNERGRID', (0, 0), (-1, -1), 0.25, C_BORDER),
|
||
('BOX', (0, 0), (-1, -1), 0.5, C_BORDER),
|
||
('ALIGN', (1, 0), (-1, -1), 'CENTER'),
|
||
('TOPPADDING', (0, 0), (-1, -1), 4),
|
||
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
|
||
('LEFTPADDING', (0, 0), (-1, -1), 6),
|
||
('RIGHTPADDING', (0, 0), (-1, -1), 6),
|
||
]))
|
||
story.append(area_tbl)
|
||
story.append(Spacer(1, 0.2 * inch))
|
||
|
||
story.append(Paragraph('Open Issues', styles['SectionHeader']))
|
||
story.append(Spacer(1, 0.05 * inch))
|
||
if open_issues:
|
||
tbl_data = [[_H('Severity'), _H('Area'), _H('Description'), _H('Reported')]]
|
||
for issue in open_issues:
|
||
sev_col = SEVERITY_COLORS.get(issue.severity, C_SLATE)
|
||
hex_str = '%06x' % (sev_col.hexval() & 0xFFFFFF)
|
||
desc = issue.description or ''
|
||
tbl_data.append([
|
||
Paragraph('<font color="#{}">'
|
||
'<b>{}</b></font>'.format(hex_str, (issue.severity or '').title()),
|
||
styles['TableCell']),
|
||
_P(issue.area.name if issue.area else '—'),
|
||
_P(desc[:100] + ('...' if len(desc) > 100 else '')),
|
||
_P(issue.reported_at.strftime('%Y-%m-%d') if issue.reported_at else '—'),
|
||
])
|
||
iss_tbl = Table(tbl_data,
|
||
colWidths=[page_w * 0.14, page_w * 0.20,
|
||
page_w * 0.50, page_w * 0.16],
|
||
repeatRows=1)
|
||
iss_tbl.setStyle(TableStyle([
|
||
('BACKGROUND', (0, 0), (-1, 0), C_DARK),
|
||
('ROWBACKGROUNDS', (0, 1), (-1, -1), [C_WHITE, C_LIGHT]),
|
||
('INNERGRID', (0, 0), (-1, -1), 0.25, C_BORDER),
|
||
('BOX', (0, 0), (-1, -1), 0.5, C_BORDER),
|
||
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
||
('TOPPADDING', (0, 0), (-1, -1), 4),
|
||
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
|
||
('LEFTPADDING', (0, 0), (-1, -1), 6),
|
||
('RIGHTPADDING', (0, 0), (-1, -1), 6),
|
||
]))
|
||
story.append(iss_tbl)
|
||
else:
|
||
story.append(Paragraph('No open issues — all clear.', styles['Meta']))
|
||
|
||
story.append(Spacer(1, 0.2 * inch))
|
||
story.append(HRFlowable(width='100%', thickness=0.5, color=C_BORDER))
|
||
story.append(Spacer(1, 0.05 * inch))
|
||
story.append(Paragraph(
|
||
'Generated {} — Confidential'.format(now.strftime('%B %d, %Y %I:%M %p')),
|
||
styles['Meta'],
|
||
))
|
||
doc.build(story)
|
||
return buf.getvalue()
|
||
return buf.getvalue()
|
||
|
||
|
||
def generate_qr_codes_pdf(items, filter_summary: str = '') -> bytes:
|
||
"""Return a PDF byte-string laying out selected QR codes in a grid.
|
||
|
||
Parameters
|
||
----------
|
||
items : list of dicts, each:
|
||
{
|
||
'title': str, # main label (facility or area name)
|
||
'subtitle': str | None, # e.g. contract name, or parent facility
|
||
'caption': str | None, # small line under the QR
|
||
'png': bytes, # QR code PNG image bytes
|
||
}
|
||
filter_summary : human-readable string describing the selection (optional)
|
||
"""
|
||
buf = io.BytesIO()
|
||
generated_at = datetime.now().strftime('%B %d, %Y %I:%M %p ET')
|
||
report_title = 'QR Codes'
|
||
|
||
doc = SimpleDocTemplate(
|
||
buf,
|
||
pagesize=letter,
|
||
leftMargin=0.65 * inch,
|
||
rightMargin=0.65 * inch,
|
||
topMargin=1.35 * 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)
|
||
|
||
story = []
|
||
if filter_summary:
|
||
story.append(Paragraph(f'Filters: {filter_summary}', STYLES['ReportSub']))
|
||
story.append(Paragraph(
|
||
f'Total codes: {len(items)}', STYLES['ReportSub']))
|
||
story.append(Spacer(1, 10))
|
||
|
||
if not items:
|
||
story.append(Paragraph('No QR codes selected.', STYLES['FieldValue']))
|
||
doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb)
|
||
return buf.getvalue()
|
||
|
||
COLS = 3
|
||
pw = letter[0] - 1.3 * inch # usable width
|
||
cell_w = pw / COLS
|
||
qr_size = 1.7 * inch
|
||
|
||
title_style = ParagraphStyle('QRT', fontName='Helvetica-Bold', fontSize=9,
|
||
alignment=TA_CENTER, leading=11, textColor=C_DARK)
|
||
sub_style = ParagraphStyle('QRS', fontName='Helvetica', fontSize=7.5,
|
||
alignment=TA_CENTER, leading=9, textColor=C_SLATE)
|
||
cap_style = ParagraphStyle('QRC', fontName='Helvetica', fontSize=6.5,
|
||
alignment=TA_CENTER, leading=8, textColor=C_SLATE)
|
||
|
||
def _cell(item):
|
||
flow = [Paragraph(item.get('title') or '', title_style)]
|
||
if item.get('subtitle'):
|
||
flow.append(Paragraph(item['subtitle'], sub_style))
|
||
flow.append(Spacer(1, 4))
|
||
flow.append(RLImage(io.BytesIO(item['png']), width=qr_size, height=qr_size))
|
||
if item.get('caption'):
|
||
flow.append(Spacer(1, 3))
|
||
flow.append(Paragraph(item['caption'], cap_style))
|
||
return flow
|
||
|
||
rows = []
|
||
for i in range(0, len(items), COLS):
|
||
chunk = items[i:i + COLS]
|
||
row = [_cell(it) for it in chunk]
|
||
while len(row) < COLS:
|
||
row.append('') # filler cell to keep the grid rectangular
|
||
rows.append(row)
|
||
|
||
tbl = Table(rows, colWidths=[cell_w] * COLS)
|
||
tbl.setStyle(TableStyle([
|
||
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
||
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
|
||
('TOPPADDING', (0, 0), (-1, -1), 10),
|
||
('BOTTOMPADDING', (0, 0), (-1, -1), 16),
|
||
('LEFTPADDING', (0, 0), (-1, -1), 6),
|
||
('RIGHTPADDING', (0, 0), (-1, -1), 6),
|
||
]))
|
||
story.append(tbl)
|
||
|
||
doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb)
|
||
return buf.getvalue() |