06/12 Add export PDF function for Issues filter result
This commit is contained in:
+101
-2
@@ -2,7 +2,7 @@ import os
|
|||||||
import logging
|
import logging
|
||||||
from app.utils.time_utils import now_eastern
|
from app.utils.time_utils import now_eastern
|
||||||
from flask import (Blueprint, render_template, redirect, url_for,
|
from flask import (Blueprint, render_template, redirect, url_for,
|
||||||
flash, request, current_app, jsonify, abort)
|
flash, request, current_app, jsonify, abort, Response)
|
||||||
from flask_login import login_required, current_user
|
from flask_login import login_required, current_user
|
||||||
from app import db
|
from app import db
|
||||||
from app.models.issue import Issue, IssueComment, IssueFollower
|
from app.models.issue import Issue, IssueComment, IssueFollower
|
||||||
@@ -16,7 +16,8 @@ from app.models.notification import (
|
|||||||
from app.utils.forms import IssueForm, IssueUpdateForm
|
from app.utils.forms import IssueForm, IssueUpdateForm
|
||||||
from app.utils.decorators import supervisor_required
|
from app.utils.decorators import supervisor_required
|
||||||
from app.utils.notifications import notify, notify_customers_for_facility, notify_by_matrix
|
from app.utils.notifications import notify, notify_customers_for_facility, notify_by_matrix
|
||||||
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
|
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_EXPORT
|
||||||
|
from app.utils.pdf_export import generate_issues_list_pdf
|
||||||
from app.utils.scope import get_customer_scope, get_inspector_scope
|
from app.utils.scope import get_customer_scope, get_inspector_scope
|
||||||
from app.utils.sla import sla_status
|
from app.utils.sla import sla_status
|
||||||
from sqlalchemy.orm import joinedload, contains_eager
|
from sqlalchemy.orm import joinedload, contains_eager
|
||||||
@@ -67,6 +68,104 @@ class _SLAFilteredPage:
|
|||||||
return iter([1])
|
return iter([1])
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/export-list-pdf')
|
||||||
|
@login_required
|
||||||
|
def export_list_pdf():
|
||||||
|
"""Generate and stream a PDF of the currently filtered issue list."""
|
||||||
|
q = (
|
||||||
|
Issue.query
|
||||||
|
.outerjoin(Area, Issue.area_id == Area.id)
|
||||||
|
.options(
|
||||||
|
contains_eager(Issue.area),
|
||||||
|
joinedload(Issue.facility),
|
||||||
|
joinedload(Issue.assigned_user),
|
||||||
|
)
|
||||||
|
.order_by(Issue.reported_at.desc())
|
||||||
|
)
|
||||||
|
|
||||||
|
if current_user.role == 'inspector':
|
||||||
|
fids = get_inspector_scope(current_user)
|
||||||
|
if not fids:
|
||||||
|
q = q.filter(False)
|
||||||
|
else:
|
||||||
|
q = q.filter(db.or_(
|
||||||
|
Issue.facility_id.in_(fids),
|
||||||
|
db.and_(Issue.area_id.isnot(None), Area.facility_id.in_(fids)),
|
||||||
|
))
|
||||||
|
elif current_user.role == 'customer':
|
||||||
|
customer_facility_ids = get_customer_scope(current_user)
|
||||||
|
if not customer_facility_ids:
|
||||||
|
q = q.filter(False)
|
||||||
|
else:
|
||||||
|
q = q.filter(db.or_(
|
||||||
|
Issue.facility_id.in_(customer_facility_ids),
|
||||||
|
db.and_(Issue.area_id.isnot(None), Area.facility_id.in_(customer_facility_ids)),
|
||||||
|
))
|
||||||
|
|
||||||
|
issue_id_filter = request.args.get('issue_id', '').strip()
|
||||||
|
severity_filter = request.args.get('severity', '')
|
||||||
|
status_filter = request.args.get('status', '')
|
||||||
|
sla_filter = request.args.get('sla', '')
|
||||||
|
facility_filter = request.args.get('facility_id', '')
|
||||||
|
contract_filter = request.args.get('contract_id', '')
|
||||||
|
|
||||||
|
if issue_id_filter.isdigit():
|
||||||
|
q = q.filter(Issue.id == int(issue_id_filter))
|
||||||
|
if severity_filter:
|
||||||
|
q = q.filter(Issue.severity == severity_filter)
|
||||||
|
if status_filter:
|
||||||
|
q = q.filter(Issue.status == status_filter)
|
||||||
|
if contract_filter.isdigit():
|
||||||
|
_contract_fids = [
|
||||||
|
f.id for f in Facility.query.filter_by(project_id=int(contract_filter)).all()
|
||||||
|
]
|
||||||
|
q = q.filter(db.or_(
|
||||||
|
Issue.facility_id.in_(_contract_fids),
|
||||||
|
Area.facility_id.in_(_contract_fids),
|
||||||
|
)) if _contract_fids else q.filter(False)
|
||||||
|
if facility_filter:
|
||||||
|
fid = int(facility_filter)
|
||||||
|
q = q.filter(db.or_(Issue.facility_id == fid, Area.facility_id == fid))
|
||||||
|
|
||||||
|
all_issues = q.all()
|
||||||
|
if sla_filter:
|
||||||
|
all_issues = [i for i in all_issues if sla_status(i) == sla_filter]
|
||||||
|
|
||||||
|
# Human-readable filter summary for the PDF header
|
||||||
|
filter_parts = []
|
||||||
|
if issue_id_filter:
|
||||||
|
filter_parts.append(f'Issue #: {issue_id_filter}')
|
||||||
|
if severity_filter:
|
||||||
|
filter_parts.append(f'Severity: {severity_filter.title()}')
|
||||||
|
if status_filter:
|
||||||
|
filter_parts.append(f'Status: {status_filter.replace("_", " ").title()}')
|
||||||
|
if sla_filter:
|
||||||
|
filter_parts.append(f'SLA: {sla_filter.replace("_", " ").title()}')
|
||||||
|
if contract_filter.isdigit():
|
||||||
|
from app.models.project import Project
|
||||||
|
p = db.session.get(Project, int(contract_filter))
|
||||||
|
if p:
|
||||||
|
filter_parts.append(f'Contract: {p.name}')
|
||||||
|
if facility_filter:
|
||||||
|
f = db.session.get(Facility, int(facility_filter))
|
||||||
|
if f:
|
||||||
|
filter_parts.append(f'Facility: {f.name}')
|
||||||
|
|
||||||
|
filter_summary = ' | '.join(filter_parts) if filter_parts else 'All issues'
|
||||||
|
|
||||||
|
pdf_bytes = generate_issues_list_pdf(all_issues, filter_summary)
|
||||||
|
filename = f'issues_list_{now_eastern().strftime("%Y%m%d_%H%M")}.pdf'
|
||||||
|
|
||||||
|
log_action(ACTION_EXPORT, 'Issue', None, 'Issues List',
|
||||||
|
f'format=pdf; filters={filter_summary}; count={len(all_issues)}')
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
pdf_bytes,
|
||||||
|
mimetype='application/pdf',
|
||||||
|
headers={'Content-Disposition': f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/')
|
@bp.route('/')
|
||||||
@login_required
|
@login_required
|
||||||
def index():
|
def index():
|
||||||
|
|||||||
@@ -63,9 +63,13 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto">
|
<div class="col-auto d-flex align-items-end gap-2 flex-wrap">
|
||||||
<button type="submit" class="btn btn-sm btn-outline-primary">Filter</button>
|
<button type="submit" class="btn btn-sm btn-outline-primary">Filter</button>
|
||||||
<a href="{{ url_for('issues.index') }}" class="btn btn-sm btn-outline-secondary">Clear</a>
|
<a href="{{ url_for('issues.index') }}" class="btn btn-sm btn-outline-secondary">Clear</a>
|
||||||
|
<a href="{{ url_for('issues.export_list_pdf', **request.args) }}"
|
||||||
|
class="btn btn-sm btn-outline-danger">
|
||||||
|
<i class="bi bi-file-earmark-pdf"></i> Export PDF
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -194,7 +198,7 @@
|
|||||||
{% if p %}
|
{% if p %}
|
||||||
<li class="page-item {{ 'active' if p == issues.page }}">
|
<li class="page-item {{ 'active' if p == issues.page }}">
|
||||||
<a class="page-link"
|
<a class="page-link"
|
||||||
href="{{ url_for('issues.index', page=p, severity=severity_filter, status=status_filter, sla=sla_filter, contract_id=contract_filter, facility_id=facility_filter) }}">{{ p }}</a>
|
href="{{ url_for('issues.index', page=p, issue_id=issue_id_filter, severity=severity_filter, status=status_filter, sla=sla_filter, contract_id=contract_filter, facility_id=facility_filter) }}">{{ p }}</a>
|
||||||
</li>
|
</li>
|
||||||
{% else %}<li class="page-item disabled"><span class="page-link">…</span></li>{% endif %}
|
{% else %}<li class="page-item disabled"><span class="page-link">…</span></li>{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -1096,6 +1096,161 @@ def generate_scheduled_report_pdf(report_name, frequency, start, end,
|
|||||||
return buf.getvalue()
|
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_WHITE, 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'<font color="{color.hexval()}">{text}</font>', 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<font size="6.5" color="{C_SLATE.hexval()}">{area_str[:24]}</font>', 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_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), 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
|
# INSPECTIONS LIST PDF
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
Reference in New Issue
Block a user