06/12 Add export PDF function for Inspection filter result
This commit is contained in:
+122
-1
@@ -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')
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
Reference in New Issue
Block a user