diff --git a/app/routes/issues.py b/app/routes/issues.py index 61f8141..ea98427 100644 --- a/app/routes/issues.py +++ b/app/routes/issues.py @@ -2,7 +2,7 @@ import os import logging from app.utils.time_utils import now_eastern 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 app import db 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.decorators import supervisor_required 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.sla import sla_status from sqlalchemy.orm import joinedload, contains_eager @@ -67,6 +68,104 @@ class _SLAFilteredPage: 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('/') @login_required def index(): diff --git a/app/templates/issues/list.html b/app/templates/issues/list.html index 83635a7..4f5fc38 100644 --- a/app/templates/issues/list.html +++ b/app/templates/issues/list.html @@ -63,9 +63,13 @@ {% endfor %} -
+
Clear + + Export PDF +
@@ -194,7 +198,7 @@ {% if p %}
  • {{ p }} + 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 }}
  • {% else %}
  • {% endif %} {% endfor %} diff --git a/app/utils/pdf_export.py b/app/utils/pdf_export.py index e095a40..850440a 100644 --- a/app/utils/pdf_export.py +++ b/app/utils/pdf_export.py @@ -1096,6 +1096,161 @@ def generate_scheduled_report_pdf(report_name, frequency, start, end, 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'{text}', 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{area_str[:24]}', 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 # ══════════════════════════════════════════════════════════════════════════════