06/12 Add export PDF function for Inspection filter result

This commit is contained in:
2026-06-12 10:43:18 -04:00
parent 8f7f8dbaff
commit e5f29e547b
4 changed files with 292 additions and 6 deletions
+122 -1
View File
@@ -16,7 +16,7 @@ from app.models.issue import Issue
from app.models.user import User from app.models.user import User
from app.utils.forms import StartInspectionForm, IssueForm from app.utils.forms import StartInspectionForm, IssueForm
from app.utils.decorators import supervisor_required from app.utils.decorators import supervisor_required
from app.utils.pdf_export import generate_inspection_pdf from app.utils.pdf_export import generate_inspection_pdf, generate_inspections_list_pdf
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.models.notification import ( from app.models.notification import (
EVENT_INSPECTION_DONE, EVENT_ISSUE_ASSIGNED, EVENT_INSPECTION_DONE, EVENT_ISSUE_ASSIGNED,
@@ -978,6 +978,127 @@ def flag_issue(inspection_id):
form=form, inspection=inspection) form=form, inspection=inspection)
# ── Export filtered list to PDF ───────────────────────────────────────────────
@bp.route('/export-list-pdf')
@login_required
def export_list_pdf():
"""Generate and stream a PDF of the currently filtered inspection list."""
q = Inspection.query.options(
joinedload(Inspection.facility),
joinedload(Inspection.template),
joinedload(Inspection.inspector),
joinedload(Inspection.area),
).order_by(Inspection.inspection_date.desc())
if current_user.role == 'inspector':
fids = get_inspector_scope(current_user)
if not fids:
q = q.filter(False)
else:
q = q.filter(Inspection.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(Inspection.facility_id.in_(customer_facility_ids))
status_filter = request.args.get('status', '')
facility_filter = request.args.get('facility_id', '')
contract_filter = request.args.get('contract_id', '')
date_from_filter = request.args.get('date_from', '')
date_to_filter = request.args.get('date_to', '')
score_min_filter = request.args.get('score_min', '')
score_max_filter = request.args.get('score_max', '')
inspector_filter = request.args.get('inspector_id', '')
if status_filter == 'follow_up':
q = q.filter(
Inspection.follow_up_required == True,
Inspection.status == 'completed',
).filter(~Inspection.follow_ups.any())
elif status_filter == 'has_issues':
from sqlalchemy import exists as sa_exists
q = q.filter(sa_exists().where(Issue.inspection_id == Inspection.id))
elif status_filter:
q = q.filter(Inspection.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(Inspection.facility_id.in_(_contract_fids)) if _contract_fids else q.filter(False)
if facility_filter.isdigit():
q = q.filter(Inspection.facility_id == int(facility_filter))
if date_from_filter:
try:
q = q.filter(Inspection.inspection_date >= datetime.strptime(date_from_filter, '%Y-%m-%d'))
except ValueError:
pass
if date_to_filter:
try:
_dt = datetime.strptime(date_to_filter, '%Y-%m-%d').replace(hour=23, minute=59, second=59)
q = q.filter(Inspection.inspection_date <= _dt)
except ValueError:
pass
if score_min_filter:
try:
q = q.filter(Inspection.overall_score >= float(score_min_filter))
except ValueError:
pass
if score_max_filter:
try:
q = q.filter(Inspection.overall_score <= float(score_max_filter))
except ValueError:
pass
if inspector_filter.isdigit() and current_user.role != 'inspector':
q = q.filter(Inspection.inspector_id == int(inspector_filter))
inspections = q.all()
# Build a human-readable filter summary for the PDF header
filter_parts = []
if status_filter:
label = {'follow_up': 'Flagged Follow-up', 'has_issues': 'Has Issues'}.get(
status_filter, status_filter.replace('_', ' ').title()
)
filter_parts.append(f'Status: {label}')
if contract_filter.isdigit():
p = db.session.get(Project, int(contract_filter))
if p:
filter_parts.append(f'Contract: {p.name}')
if facility_filter.isdigit():
f = db.session.get(Facility, int(facility_filter))
if f:
filter_parts.append(f'Facility: {f.name}')
if date_from_filter:
filter_parts.append(f'From: {date_from_filter}')
if date_to_filter:
filter_parts.append(f'To: {date_to_filter}')
if score_min_filter:
filter_parts.append(f'Min score: {score_min_filter}%')
if score_max_filter:
filter_parts.append(f'Max score: {score_max_filter}%')
if inspector_filter.isdigit() and current_user.role != 'inspector':
u = db.session.get(User, int(inspector_filter))
if u:
filter_parts.append(f'Inspector: {u.display_name}')
filter_summary = ' | '.join(filter_parts) if filter_parts else 'All inspections'
pdf_bytes = generate_inspections_list_pdf(inspections, filter_summary)
filename = f'inspections_list_{now_eastern().strftime("%Y%m%d_%H%M")}.pdf'
log_action(ACTION_EXPORT, 'Inspection', None, 'Inspections List',
f'format=pdf; filters={filter_summary}; count={len(inspections)}')
return Response(
pdf_bytes,
mimetype='application/pdf',
headers={'Content-Disposition': f'attachment; filename="{filename}"'},
)
# ── Export to PDF ───────────────────────────────────────────────────────────── # ── Export to PDF ─────────────────────────────────────────────────────────────
@bp.route('/<int:inspection_id>/export-pdf') @bp.route('/<int:inspection_id>/export-pdf')
+21 -3
View File
@@ -79,9 +79,14 @@
min="0" max="100" placeholder="100" min="0" max="100" placeholder="100"
value="{{ score_max_filter }}"> value="{{ score_max_filter }}">
</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('inspections.index') }}" class="btn btn-sm btn-outline-secondary">Clear</a> <a href="{{ url_for('inspections.index') }}" class="btn btn-sm btn-outline-secondary">Clear</a>
<a id="exportPdfBtn"
href="{{ url_for('inspections.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>
</div> </div>
</form> </form>
@@ -136,9 +141,9 @@
</td> </td>
<td class="text-nowrap"> <td class="text-nowrap">
{% if ins.status == 'in_progress' or ins.status == 'flagged' %} {% if ins.status == 'in_progress' or ins.status == 'flagged' %}
<a href="{{ url_for('inspections.execute', inspection_id=ins.id) }}" class="btn btn-sm btn-outline-primary">Continue</a> <a href="{{ url_for('inspections.execute', inspection_id=ins.id) }}" class="btn btn-sm btn-outline-primary insp-list-link">Continue</a>
{% else %} {% else %}
<a href="{{ url_for('inspections.view', inspection_id=ins.id) }}" class="btn btn-sm btn-outline-secondary">View</a> <a href="{{ url_for('inspections.view', inspection_id=ins.id) }}" class="btn btn-sm btn-outline-secondary insp-list-link">View</a>
{% endif %} {% endif %}
{% if current_user.role in ['admin', 'director'] %} {% if current_user.role in ['admin', 'director'] %}
<button type="button" <button type="button"
@@ -212,6 +217,19 @@
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
<script>
(function () {
'use strict';
// Save current filtered URL so view/execute pages can restore it on Back
var links = document.querySelectorAll('.insp-list-link');
links.forEach(function (a) {
a.addEventListener('click', function () {
sessionStorage.setItem('insp_list_back_url', window.location.href);
});
});
}());
</script>
<script> <script>
(function () { (function () {
'use strict'; 'use strict';
+11 -1
View File
@@ -338,7 +338,7 @@
{# Action bar #} {# Action bar #}
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<a href="{{ url_for('inspections.index') }}" class="btn btn-sm btn-outline-secondary"> <a id="backToInspectionsBtn" href="{{ url_for('inspections.index') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Back to Inspections <i class="bi bi-arrow-left"></i> Back to Inspections
</a> </a>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
@@ -852,6 +852,16 @@
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
<script>
(function () {
var backUrl = sessionStorage.getItem('insp_list_back_url');
if (backUrl) {
var btn = document.getElementById('backToInspectionsBtn');
if (btn) btn.href = backUrl;
}
}());
</script>
<script> <script>
function openMedia(src, label) { function openMedia(src, label) {
document.getElementById('mediaImg').src = src; document.getElementById('mediaImg').src = src;
+138 -1
View File
@@ -23,7 +23,7 @@ try:
except ImportError: except ImportError:
_PIL_AVAILABLE = False _PIL_AVAILABLE = False
from reportlab.lib.pagesizes import letter from reportlab.lib.pagesizes import letter, landscape
from reportlab.lib import colors from reportlab.lib import colors
from reportlab.lib.units import inch from reportlab.lib.units import inch
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
@@ -1092,5 +1092,142 @@ def generate_scheduled_report_pdf(report_name, frequency, start, end,
STYLES['FooterStyle'], STYLES['FooterStyle'],
)) ))
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_WHITE, 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'<font color="{color.hexval()}">{text}</font>', 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_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) doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb)
return buf.getvalue() return buf.getvalue()