06/11 Add export PDF button to export issue

This commit is contained in:
2026-06-11 08:35:11 -04:00
parent c6052e1844
commit 24064409a8
3 changed files with 252 additions and 1 deletions
+35
View File
@@ -883,3 +883,38 @@ def quick_assign(issue_id):
issue.id, label, current_user.username) issue.id, label, current_user.username)
return jsonify({'ok': True, 'label': label}) return jsonify({'ok': True, 'label': label})
# ── Export PDF ────────────────────────────────────────────────────────────────
@bp.route('/<int:issue_id>/export-pdf')
@login_required
def export_pdf(issue_id):
from flask import make_response
from app.utils.pdf_export import generate_issue_pdf
from app.utils.audit import ACTION_EXPORT
issue = db.session.get(Issue, issue_id)
if issue is None:
abort(404)
if current_user.role == 'inspector':
fids = get_inspector_scope(current_user)
facility = issue.resolved_facility
if not fids or not facility or facility.id not in fids:
abort(403)
if current_user.role == 'customer':
cids = get_customer_scope(current_user) or []
facility = issue.resolved_facility
if not facility or facility.id not in cids:
abort(403)
pdf_bytes = generate_issue_pdf(issue, current_app.static_folder)
log_action(ACTION_EXPORT, 'Issue', issue.id,
f'Issue #{issue.id}',
f'PDF export by {current_user.username}')
resp = make_response(pdf_bytes)
resp.headers['Content-Type'] = 'application/pdf'
resp.headers['Content-Disposition'] = f'attachment; filename="issue_{issue.id}.pdf"'
return resp
+3
View File
@@ -374,6 +374,9 @@
<a href="{{ url_for('issues.index') }}" class="btn btn-outline-secondary btn-sm"> <a href="{{ url_for('issues.index') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left"></i> Back to Issues <i class="bi bi-arrow-left"></i> Back to Issues
</a> </a>
<a href="{{ url_for('issues.export_pdf', issue_id=issue.id) }}" class="btn btn-outline-primary btn-sm">
<i class="bi bi-file-earmark-pdf"></i> Export PDF
</a>
{% if current_user.role in ['admin', 'director'] %} {% if current_user.role in ['admin', 'director'] %}
<button type="button" class="btn btn-outline-danger btn-sm" <button type="button" class="btn btn-outline-danger btn-sm"
data-bs-toggle="modal" data-bs-target="#deleteIssueModal"> data-bs-toggle="modal" data-bs-target="#deleteIssueModal">
+213
View File
@@ -703,6 +703,219 @@ def generate_inspection_pdf(inspection, form_fields, form_data, issues,
return buf.getvalue() 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 # SCHEDULED REPORT PDF
# ══════════════════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════════════════