06/11 Add export PDF button to export issue
This commit is contained in:
+36
-1
@@ -882,4 +882,39 @@ def quick_assign(issue_id):
|
||||
logger.info('ISSUE QUICK-ASSIGN | issue_id=%s | assigned_to=%s | by=%s',
|
||||
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
|
||||
@@ -374,6 +374,9 @@
|
||||
<a href="{{ url_for('issues.index') }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left"></i> Back to Issues
|
||||
</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'] %}
|
||||
<button type="button" class="btn btn-outline-danger btn-sm"
|
||||
data-bs-toggle="modal" data-bs-target="#deleteIssueModal">
|
||||
|
||||
@@ -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