06/11 Add export PDF button to export issue
This commit is contained in:
@@ -703,6 +703,219 @@ def generate_inspection_pdf(inspection, form_fields, form_data, issues,
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# ISSUE PDF
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def generate_issue_pdf(issue, static_folder: str) -> bytes:
|
||||
"""Return a PDF byte-string for a single Issue.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
issue : Issue model instance (relationships pre-loaded by caller)
|
||||
static_folder : absolute path to app/static (for resolving photo paths)
|
||||
"""
|
||||
buf = io.BytesIO()
|
||||
generated_at = datetime.now().strftime('%B %d, %Y %I:%M %p ET')
|
||||
report_title = f'Issue Report — Issue #{issue.id}'
|
||||
|
||||
doc = SimpleDocTemplate(
|
||||
buf,
|
||||
pagesize=letter,
|
||||
leftMargin=0.65 * inch,
|
||||
rightMargin=0.65 * inch,
|
||||
topMargin=1.25 * inch,
|
||||
bottomMargin=0.75 * inch,
|
||||
title=report_title,
|
||||
author='Janitorial QC System',
|
||||
)
|
||||
|
||||
def _page_cb(canvas, doc):
|
||||
_on_page(canvas, doc, report_title, generated_at)
|
||||
|
||||
pw = letter[0] - 1.3 * inch # usable page width
|
||||
|
||||
# ── Severity / status banner ──────────────────────────────────────────────
|
||||
sev = issue.severity or 'low'
|
||||
sev_color = SEVERITY_COLORS.get(sev, C_SLATE)
|
||||
stat_txt = (issue.status or '').replace('_', ' ').title()
|
||||
|
||||
banner_data = [[
|
||||
Paragraph(f'<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)
|
||||
|
||||
doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# SCHEDULED REPORT PDF
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user