""" 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: img = PILImage.open(src_path) # 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 one Table row with cells sized by colSpan. # Fields with no value are skipped unless they are text/textarea/label — # those always appear so free-text notes are never suppressed. # current_col tracks horizontal position (1-based) so gaps between fields # are filled with invisible placeholder cells that preserve column alignment. cells = [] col_widths = [] has_content = False current_col = 1 # Returns True if the field should be hidden from the PDF. def _skip(ftype, val): if ftype == 'rating': return not str(val).isdigit() or int(val) == 0 if ftype == 'image': img_path = os.path.join(static_folder, val) if val else '' return not val or not os.path.exists(img_path) if ftype == 'signature': return not val or not str(val).startswith('data:') if ftype == 'checkbox': # stored as 'true'/'false' by _collect_form_responses return val not in ('yes', 'true') if ftype == 'pass_fail': return not val if ftype == 'checkbox_group': return not val or not isinstance(val, list) or len(val) == 0 if ftype == 'table': return not val or not isinstance(val, list) or len(val) == 0 # All remaining input types (text, textarea, number, date, # email, radio, select, and any future types) return not val for field in fields_in_row: ftype = field.get('type', '') col = field.get('col', current_col) col_span = field.get('colSpan', 1) cell_w = UNIT * col_span # Fill horizontal gap before this field with an invisible spacer if col > current_col: gap_w = UNIT * (col - current_col) cells.append(Paragraph('', STYLES['FieldValue'])) col_widths.append(gap_w) current_col = col + col_span # ── 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' p = Paragraph( field.get('text_content', ''), ParagraphStyle('li', fontName=fw, fontSize=fs, textColor=C_DARK, leading=fs + 3), ) cells.append(p) col_widths.append(cell_w) has_content = True continue fid = str(field.get('id', '')) val = form_data.get(fid, '') lbl = field.get('label', '') # ── Skip fields with no meaningful value ────────────────────────── if _skip(ftype, val): # Keep a placeholder so subsequent fields stay in column position cells.append(Paragraph('', STYLES['FieldValue'])) col_widths.append(cell_w) continue lbl_p = Paragraph(lbl, STYLES['FieldLabel']) # ── Value content ──────────────────────────────────────────────── if ftype == 'rating': score_int = int(val) # Use ★ for both filled and unfilled — ☆ is unsupported in # Helvetica and renders as a solid box. Grey colour for unfilled. 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': # File existence already verified in the skip gate above img_path = os.path.join(static_folder, val) try: # Attempt to compress the image before embedding. # _compress_image() resizes to _IMG_MAX_PX and re-encodes # as JPEG at _IMG_JPEG_QUALITY — typically 85–95% smaller # than the original phone photo. 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: # Could not load image — insert placeholder to preserve column position cells.append(Paragraph('', STYLES['FieldValue'])) col_widths.append(cell_w) continue 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: # text, textarea, number, date, email, radio, select 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.append(box) col_widths.append(cell_w) has_content = True # Skip the entire row if no field had meaningful content if not has_content: continue row_tbl = Table([cells], colWidths=col_widths, 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), ])) 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() # ══════════════════════════════════════════════════════════════════════════════ # 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()