""" 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 # Light table-header / banner fill — printer-friendly (low ink) replacement for the # former solid-dark headers. Distinct enough from the C_LIGHT zebra row shade. C_HEADER = colors.HexColor('#dbe4f0') 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 # ── Header band (light — printer-friendly) ── # Was a full-width solid-dark rectangle; replaced with a light tint + a thin # blue accent rule so it costs almost no ink to print. canvas.saveState() canvas.setFillColor(C_LIGHT) canvas.rect(0, h - 1.1 * inch, w, 1.1 * inch, stroke=0, fill=1) # Blue accent rule along the bottom edge of the band canvas.setStrokeColor(C_BLUE) canvas.setLineWidth(2) canvas.line(0, h - 1.1 * inch, w, h - 1.1 * inch) canvas.setFont('Helvetica-Bold', 13) canvas.setFillColor(C_DARK) canvas.drawString(margin, h - 0.55 * inch, title) canvas.setFont('Helvetica', 8) canvas.setFillColor(C_SLATE) 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(C_SLATE) 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'Overall Score', ParagraphStyle('x', fontName='Helvetica-Bold', fontSize=9, textColor=C_WHITE, alignment=TA_CENTER)), Paragraph(f'{score_txt}', ParagraphStyle('x2', fontName='Helvetica-Bold', fontSize=22, textColor=C_WHITE, alignment=TA_CENTER)), Paragraph(f'{grade}', ParagraphStyle('x3', fontName='Helvetica-Bold', fontSize=14, textColor=C_WHITE, alignment=TA_CENTER)), ]] w = letter[0] - 1.3 * inch tbl = Table(data, colWidths=[w * 0.35, w * 0.35, w * 0.30]) tbl.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (1, 0), sc), ('BACKGROUND', (2, 0), (2, 0), grade_color), ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), ('TOPPADDING', (0, 0), (-1, -1), 10), ('BOTTOMPADDING',(0, 0), (-1, -1), 10), ('LEFTPADDING', (0, 0), (-1, -1), 8), ('RIGHTPADDING', (0, 0), (-1, -1), 8), ])) return tbl # ── Form fields section ─────────────────────────────────────────────────────── def _star_string(score, max_stars=5): """Return a star string using only the filled ★ glyph. Hollow ☆ is not supported by Helvetica and renders as a box. Unselected stars are rendered as grey ★ via ReportLab XML markup. Returns a plain string for canvas use; use _star_markup() for Paragraph. """ filled = min(int(score), max_stars) return '★' * filled + '★' * (max_stars - filled) _SKIP_TYPES = {'button_submit', 'button_print', 'button_email'} _GRID_COLS = 12 # matches the web UI 12-column grid def _form_fields_section(form_fields, form_data, static_folder): """ Reconstruct the web UI grid layout in PDF form. Fields are grouped by their grid row number. Within each row the fields are placed side-by-side with column widths proportional to their colSpan. Section headings (type='section') are rendered as full-width banners between groups of rows, exactly as they appear in the web UI. """ story = [] story.append(Paragraph('Inspection Results', STYLES['SectionHead'])) story.append(HRFlowable(width='100%', thickness=1, color=C_BORDER, spaceAfter=6)) FULL_W = letter[0] - 1.3 * inch # usable page width UNIT = FULL_W / _GRID_COLS # width of one grid column unit # ── Group fields by row number, preserving original order ──────────────── from collections import defaultdict, OrderedDict rows_map = OrderedDict() # row_num -> [field, ...] sections = {} # row_num -> section label that precedes this row pending_section = None for field in form_fields: ftype = field.get('type', '') if ftype in ('button_submit', 'button_print', 'button_email'): continue if ftype == 'section': pending_section = field.get('label', '') continue row_num = field.get('row', 0) if row_num not in rows_map: rows_map[row_num] = [] if pending_section is not None: sections[row_num] = pending_section pending_section = None rows_map[row_num].append(field) # ── Pre-compute which rows have at least one visible field ─────────────── # A row is visible if it contains a text/textarea/label field, OR any other # field type that has a non-empty value. This is used to suppress section # banners whose every subordinate row is empty. _ALWAYS_SHOW_PRE = {'label'} def _row_has_content(fields_in_row): for f in fields_in_row: ft = f.get('type', '') fid = str(f.get('id', '')) v = form_data.get(fid, '') if ft in _ALWAYS_SHOW_PRE: return True if ft == 'rating' and str(v).isdigit() and int(v) > 0: return True if ft == 'pass_fail' and v: return True if ft == 'checkbox' and v in ('yes','true'): return True if ft == 'checkbox_group'and isinstance(v, list) and v: return True if ft == 'image' and v and os.path.exists(os.path.join(static_folder, v)): return True if ft == 'signature' and v and str(v).startswith('data:'): return True if ft == 'table' and isinstance(v, list) and v: return True if ft in ('number','date','email','radio','select') and v: return True if ft in ('text','textarea') and v: return True return False # Build a set of section-trigger row numbers that have visible content somewhere # in their subordinate rows (from their row_num up to the next section's row_num). section_row_nums = sorted(sections.keys()) all_row_nums = list(rows_map.keys()) def _section_has_visible_rows(sec_row_num): idx = section_row_nums.index(sec_row_num) next_sec = section_row_nums[idx + 1] if idx + 1 < len(section_row_nums) else None for rn in all_row_nums: if rn < sec_row_num: continue if next_sec is not None and rn >= next_sec: break if _row_has_content(rows_map[rn]): return True return False # ── Render row by row ───────────────────────────────────────────────────── for row_num, fields_in_row in rows_map.items(): # Emit section banner only if its subordinate rows have visible content if row_num in sections: if not _section_has_visible_rows(row_num): continue # skip the banner — all rows beneath it are empty story.append(Spacer(1, 6)) sec_label = sections[row_num] story.append(Paragraph( sec_label, ParagraphStyle('SecBanner', fontName='Helvetica-Bold', fontSize=10, textColor=C_DARK, spaceBefore=8, spaceAfter=3), )) story.append(HRFlowable(width='100%', thickness=0.75, color=C_BORDER, spaceAfter=4)) # Build a fixed 12-column table for this grid row. # Each of the 12 grid columns gets exactly UNIT width. # Fields occupy their designated columns via SPAN directives — # this is the ReportLab equivalent of CSS grid-column. # Unanswered fields are simply left as empty cells; no placeholder # bookkeeping is needed because the table always has all 12 columns. NCOLS = _GRID_COLS # 12 cells_12 = [Paragraph('', STYLES['FieldValue']) for _ in range(NCOLS)] spans = [] # SPAN TableStyle directives has_content = False for field in fields_in_row: ftype = field.get('type', '') col = max(1, int(field.get('col', 1))) # 1-indexed col_span = max(1, int(field.get('colSpan', 1))) ci = col - 1 # 0-indexed start ci_end = min(ci + col_span - 1, NCOLS - 1) # 0-indexed end cell_w = UNIT * col_span if col_span > 1: spans.append(('SPAN', (ci, 0), (ci_end, 0))) # ── Inline label (free-standing text element) ───────────────────── if ftype == 'label': fs_map = {'small': 8, 'normal': 9, 'large': 11, 'x-large': 13} fs = fs_map.get(field.get('font_size', 'normal'), 9) fw = 'Helvetica-Bold' if field.get('font_weight') == 'bold' else 'Helvetica' cells_12[ci] = Paragraph( field.get('text_content', ''), ParagraphStyle('li', fontName=fw, fontSize=fs, textColor=C_DARK, leading=fs + 3), ) has_content = True continue fid = str(field.get('id', '')) val = form_data.get(fid, '') lbl = field.get('label', '') # ── Skip fields with no meaningful value — leave cell empty ──────── def _skip(ft, v): if ft == 'rating': return not str(v).isdigit() or int(v) == 0 if ft == 'image': ip = os.path.join(static_folder, v) if v else '' return not v or not os.path.exists(ip) if ft == 'signature': return not v or not str(v).startswith('data:') if ft == 'checkbox': return v not in ('yes', 'true') if ft == 'pass_fail': return not v if ft in ('checkbox_group', 'table'): return not v or not isinstance(v, list) or len(v) == 0 return not v if _skip(ftype, val): continue # cell stays empty; SPAN ensures correct column width lbl_p = Paragraph(lbl, STYLES['FieldLabel']) # ── Value content ──────────────────────────────────────────────── if ftype == 'rating': score_int = int(val) filled_stars = '' + ('★' * score_int) + '' empty_stars = ('' + ('★' * (5 - score_int)) + '' if score_int < 5 else '') stars = filled_stars + empty_stars + f' {score_int}/5' val_p = Paragraph(stars, ParagraphStyle( 'rv', fontName='Helvetica', fontSize=9, leading=12)) elif ftype == 'image': img_path = os.path.join(static_folder, val) try: compressed = _compress_image(img_path) img_src = compressed if compressed is not None else img_path val_p = RLImage(img_src, width=min(cell_w - 12, 1.4 * inch), height=1.0 * inch, kind='proportional') except Exception: continue # leave cell empty elif ftype == 'signature': val_p = Paragraph('[Signature captured]', STYLES['FieldValue']) elif ftype == 'checkbox': val_p = Paragraph('Yes', STYLES['FieldValue']) elif ftype == 'pass_fail': is_pass = str(val).lower() in ('pass', 'yes', 'ok', 'good', 'acceptable', 'compliant') colour = C_GREEN if is_pass else C_RED val_p = Paragraph( f'{str(val)}', STYLES['FieldValue'], ) elif ftype == 'checkbox_group': val_p = Paragraph(', '.join(val), STYLES['FieldValue']) elif ftype == 'table': headers = field.get('col_headers', list(val[0].keys()) if val else []) tbl_data = ([headers] + [[r.get(h, '') for h in headers] for r in val]) val_p = Table(tbl_data) val_p.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (-1, 0), C_LIGHT), ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, -1), 7), ('INNERGRID', (0, 0), (-1, -1), 0.25, C_BORDER), ('BOX', (0, 0), (-1, -1), 0.5, C_BORDER), ('TOPPADDING', (0, 0), (-1, -1), 2), ('BOTTOMPADDING', (0, 0), (-1, -1), 2), ])) else: disp = str(val) if val else '' val_p = Paragraph(disp, STYLES['FieldValue']) # ── Wrap into label-over-value form box ────────────────────────── box = Table([[lbl_p], [val_p]], colWidths=[cell_w - 4]) box.setStyle(TableStyle([ ('VALIGN', (0, 0), (-1, -1), 'TOP'), ('LEFTPADDING', (0, 0), (-1, -1), 5), ('RIGHTPADDING', (0, 0), (-1, -1), 5), ('TOPPADDING', (0, 0), (0, 0), 2), ('BOTTOMPADDING',(0, 0), (0, 0), 1), ('TOPPADDING', (0, 1), (0, 1), 3), ('BOTTOMPADDING',(0, 1), (0, 1), 4), ('BOX', (0, 1), (0, 1), 0.5, C_BORDER), ('BACKGROUND', (0, 1), (0, 1), colors.HexColor('#f8fafc')), ])) cells_12[ci] = box has_content = True # Skip the entire row if no field had meaningful content if not has_content: continue row_tbl = Table([cells_12], colWidths=[UNIT] * NCOLS, hAlign='LEFT') row_tbl.setStyle(TableStyle( [ ('VALIGN', (0, 0), (-1, -1), 'TOP'), ('LEFTPADDING', (0, 0), (-1, -1), 2), ('RIGHTPADDING', (0, 0), (-1, -1), 2), ('TOPPADDING', (0, 0), (-1, -1), 2), ('BOTTOMPADDING',(0, 0), (-1, -1), 2), ] + spans )) story.append(row_tbl) return story # ── Issues section ──────────────────────────────────────────────────────────── def _issues_section(issues): if not issues: return [] story = [ Spacer(1, 10), Paragraph('Flagged Issues', STYLES['SectionHead']), HRFlowable(width='100%', thickness=1, color=C_RED, spaceAfter=6), ] headers = ['#', 'Severity', 'Area', 'Description', 'Status'] col_w = [0.3 * inch, 0.7 * inch, 1.1 * inch, 3.5 * inch, 0.9 * inch] rows = [headers] for i, issue in enumerate(issues, 1): desc = issue.description[:120] + ('…' if len(issue.description) > 120 else '') rows.append([ str(i), issue.severity.title(), issue.area.name if issue.area else (issue.resolved_facility.name if issue.resolved_facility else '—'), desc, issue.status.replace('_', ' ').title(), ]) tbl = Table(rows, colWidths=col_w, repeatRows=1) sev_styles = [] for r, issue in enumerate(issues, 1): sc = SEVERITY_COLORS.get(issue.severity, C_SLATE) sev_styles.append(('TEXTCOLOR', (1, r), (1, r), sc)) sev_styles.append(('FONTNAME', (1, r), (1, r), 'Helvetica-Bold')) tbl.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (-1, 0), C_HEADER), ('TEXTCOLOR', (0, 0), (-1, 0), C_DARK), ('LINEBELOW', (0, 0), (-1, 0), 0.75, C_BLUE), ('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'{sev.upper()}', ParagraphStyle('b1', fontName='Helvetica-Bold', fontSize=10, textColor=C_WHITE, alignment=TA_CENTER)), Paragraph(f'Issue #{issue.id}', ParagraphStyle('b2', fontName='Helvetica-Bold', fontSize=16, textColor=C_DARK, alignment=TA_CENTER)), Paragraph(f'{stat_txt}', ParagraphStyle('b3', fontName='Helvetica-Bold', fontSize=10, textColor=C_DARK, 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_LIGHT), ('BACKGROUND', (2, 0), (2, 0), C_HEADER), ('LINEBELOW', (1, 0), (2, 0), 0.75, C_BLUE), ('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('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() # ══════════════════════════════════════════════════════════════════════════════ # 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_DARK, 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'{text}', 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{area_str[:24]}', 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_HEADER), ('LINEBELOW', (0, 0), (-1, 0), 0.75, C_BLUE), ('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_DARK, 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'{text}', 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_HEADER), ('LINEBELOW', (0, 0), (-1, 0), 0.75, C_BLUE), ('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('' '{}'.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('{}'.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('' '{}%'.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('' '{}'.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()