Jul 16 - Update facilities QR codes - add filters & export PDF
This commit is contained in:
@@ -471,7 +471,7 @@ Management (`/scheduled-inspections/new|edit|delete`) is `@project_manager_requi
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `auth` | `/auth` | `/login`, `/logout`, `/profile`, `/users/*`, `/notification-matrix` |
|
| `auth` | `/auth` | `/login`, `/logout`, `/profile`, `/users/*`, `/notification-matrix` |
|
||||||
| `dashboard` | `/` | `GET /`, `/facility-trend` (AJAX) |
|
| `dashboard` | `/` | `GET /`, `/facility-trend` (AJAX) |
|
||||||
| `facilities` | `/facilities` | CRUD + area management + QR code: `/<id>/qr` printable page, `/<id>/qr.png` image, `POST /<id>/qr/regenerate` (invalidates old printed code), `/qr/print-all[?contract_id=]` bulk sheet. **Per-area QR (Phase 39):** `/areas/<id>/qr`, `/areas/<id>/qr.png`, `POST /areas/<id>/qr/regenerate` — mirror the facility QR routes; scope enforced by `_area_for_qr_or_403()` via the area's parent facility. **Customers may use all QR actions (including regenerate) for their own assigned facilities**; inspectors/PM/admin/director for any. Scope enforced by `_facility_for_qr_or_403()` (customers) / `get_customer_scope` (print-all). Regenerate is limited to admin/director + scoped customer (PM/inspector excluded). |
|
| `facilities` | `/facilities` | CRUD + area management + QR code: `/<id>/qr` printable page, `/<id>/qr.png` image, `POST /<id>/qr/regenerate` (invalidates old printed code), `/qr/print-all[?contract_id=]` bulk sheet. **Per-area QR (Phase 39):** `/areas/<id>/qr`, `/areas/<id>/qr.png`, `POST /areas/<id>/qr/regenerate` — mirror the facility QR routes; scope enforced by `_area_for_qr_or_403()` via the area's parent facility. **Customers may use all QR actions (including regenerate) for their own assigned facilities**; inspectors/PM/admin/director for any. Scope enforced by `_facility_for_qr_or_403()` (customers) / `get_customer_scope` (print-all). Regenerate is limited to admin/director + scoped customer (PM/inspector excluded). **QR print/export page:** `GET /qr/print-all` is a selectable sheet with filters `?contract_id=` / `?facility_id=` / `?include_areas=1` (contract narrows the facility dropdown; areas render each facility's per-area QR cards). Each card is a `<label>` wrapping a checkbox; **Print Selected** (JS toggles `body.print-selected-only` so `@media print` hides unticked cards) and **Export Selected to PDF** (`POST /qr/export-pdf`, repeated `facility_ids`/`area_ids`, scope re-checked per id via the `_*_for_qr_or_403()` helpers, streams `generate_qr_codes_pdf()` output; logs `ACTION_EXPORT`). Inspectors 403. QR PNG bytes for the PDF come from `_qr_png_bytes(url)`. |
|
||||||
| `public` | `/f` | **No login.** `GET /<token>` occupant facility summary + `POST /<token>/report` occupant issue report; `GET /area/<token>` per-area summary + `POST /area/<token>/report` (Phase 39, files with `area_id` set). All report POSTs rate-limited `5/hour`, honeypot-guarded. Resolves ACTIVE facility (area's parent must be active) by `public_token` or 404. |
|
| `public` | `/f` | **No login.** `GET /<token>` occupant facility summary + `POST /<token>/report` occupant issue report; `GET /area/<token>` per-area summary + `POST /area/<token>/report` (Phase 39, files with `area_id` set). All report POSTs rate-limited `5/hour`, honeypot-guarded. Resolves ACTIVE facility (area's parent must be active) by `public_token` or 404. |
|
||||||
| `projects` | `/projects` | CRUD + customer assignment management + notification-recipient add/remove (`/<id>/notify-recipients/add`, `/notify-recipients/<rid>/remove` — admin only) |
|
| `projects` | `/projects` | CRUD + customer assignment management + notification-recipient add/remove (`/<id>/notify-recipients/add`, `/notify-recipients/<rid>/remove` — admin only) |
|
||||||
| `customers` | `/customers` | list, invite, set-password, manage, import CSV |
|
| `customers` | `/customers` | list, invite, set-password, manage, import CSV |
|
||||||
@@ -536,6 +536,7 @@ ReportLab-based. 12-column grid must be preserved — never collapse in PDF view
|
|||||||
- `generate_issues_list_pdf(issues, filter_summary)` — landscape issues list PDF (from issues list export)
|
- `generate_issues_list_pdf(issues, filter_summary)` — landscape issues list PDF (from issues list export)
|
||||||
- `generate_inspections_list_pdf(inspections, filter_summary)` — landscape inspections list PDF
|
- `generate_inspections_list_pdf(inspections, filter_summary)` — landscape inspections list PDF
|
||||||
- `generate_facility_summary_pdf(facility, days, start, now, total_inspections, avg_score, area_scores, open_issues, resolved_count)` — customer-facing one-page facility summary PDF (Phase R4)
|
- `generate_facility_summary_pdf(facility, days, start, now, total_inspections, avg_score, area_scores, open_issues, resolved_count)` — customer-facing one-page facility summary PDF (Phase R4)
|
||||||
|
- `generate_qr_codes_pdf(items, filter_summary='')` — grid of selected facility/area QR codes. `items` = list of `{title, subtitle, caption, png(bytes)}`; 3-per-row portrait sheet. Backs `POST /facilities/qr/export-pdf`.
|
||||||
|
|
||||||
**`_build_styles()` registered style names:** `ReportTitle`, `ReportSub`, `SectionHead`, `FieldLabel`, `FieldValue`, `MetaLabel`, `MetaValue`, `IssueDesc`, `FooterStyle`, `SummaryTitle`, `ReportSubtitle`, `Meta`, `ScoreValue`, `ScoreLabel`, `SectionHeader`, `TableHeader`, `TableCell`
|
**`_build_styles()` registered style names:** `ReportTitle`, `ReportSub`, `SectionHead`, `FieldLabel`, `FieldValue`, `MetaLabel`, `MetaValue`, `IssueDesc`, `FooterStyle`, `SummaryTitle`, `ReportSubtitle`, `Meta`, `ScoreValue`, `ScoreLabel`, `SectionHeader`, `TableHeader`, `TableCell`
|
||||||
|
|
||||||
|
|||||||
+121
-14
@@ -6,7 +6,7 @@ from app.models.facility import Facility, Area
|
|||||||
from app.models.project import Project
|
from app.models.project import Project
|
||||||
from app.utils.forms import FacilityForm, AreaForm
|
from app.utils.forms import FacilityForm, AreaForm
|
||||||
from app.utils.decorators import supervisor_required, admin_required
|
from app.utils.decorators import supervisor_required, admin_required
|
||||||
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.scope import get_customer_scope, get_inspector_scope
|
from app.utils.scope import get_customer_scope, get_inspector_scope
|
||||||
|
|
||||||
bp = Blueprint('facilities', __name__, url_prefix='/facilities')
|
bp = Blueprint('facilities', __name__, url_prefix='/facilities')
|
||||||
@@ -186,45 +186,152 @@ def facility_qr_regenerate(facility_id):
|
|||||||
return redirect(url_for('facilities.facility_qr_page', facility_id=facility.id))
|
return redirect(url_for('facilities.facility_qr_page', facility_id=facility.id))
|
||||||
|
|
||||||
|
|
||||||
|
def _qr_png_bytes(url):
|
||||||
|
"""Return PNG bytes for a QR code encoding *url* (same params as qr.png)."""
|
||||||
|
import io
|
||||||
|
import qrcode
|
||||||
|
img = qrcode.make(url, box_size=10, border=2)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf, format='PNG')
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/qr/print-all')
|
@bp.route('/qr/print-all')
|
||||||
@login_required
|
@login_required
|
||||||
def facility_qr_print_all():
|
def facility_qr_print_all():
|
||||||
"""Printable sheet of QR codes for all facilities the user can see.
|
"""Printable / selectable sheet of QR codes the user can see.
|
||||||
|
|
||||||
Optional ?contract_id=<id> limits the sheet to one contract. Inspectors have
|
Query params (all optional):
|
||||||
no QR management (403); customers are scoped to their assigned facilities;
|
?contract_id=<id> — limit to one contract; narrows the facility dropdown
|
||||||
managers see all active facilities.
|
?facility_id=<id> — limit to a single facility
|
||||||
|
?include_areas=1 — also render each facility's per-area QR codes
|
||||||
|
|
||||||
|
Inspectors have no QR management (403); customers are scoped to their
|
||||||
|
assigned facilities; managers see all active facilities.
|
||||||
"""
|
"""
|
||||||
# QR management is not an inspector task.
|
# QR management is not an inspector task.
|
||||||
if current_user.role == 'inspector':
|
if current_user.role == 'inspector':
|
||||||
abort(403)
|
abort(403)
|
||||||
|
|
||||||
contract_id = request.args.get('contract_id', type=int)
|
contract_id = request.args.get('contract_id', type=int)
|
||||||
|
facility_id = request.args.get('facility_id', type=int)
|
||||||
|
include_areas = request.args.get('include_areas') in ('1', 'true', 'on')
|
||||||
|
|
||||||
|
# Facilities in the viewer's scope.
|
||||||
if current_user.role == 'customer':
|
if current_user.role == 'customer':
|
||||||
fids = get_customer_scope(current_user) or []
|
fids = get_customer_scope(current_user) or []
|
||||||
query = Facility.query.filter(Facility.id.in_(fids), Facility.active == True)
|
scoped = Facility.query.filter(Facility.id.in_(fids), Facility.active == True)
|
||||||
else:
|
else:
|
||||||
query = Facility.query.filter(Facility.active == True)
|
scoped = Facility.query.filter(Facility.active == True)
|
||||||
|
scoped_facilities = scoped.order_by(Facility.name).all()
|
||||||
|
|
||||||
if contract_id:
|
# Contract dropdown — only contracts present among the scoped facilities.
|
||||||
query = query.filter(Facility.project_id == contract_id)
|
contract_ids = {f.project_id for f in scoped_facilities if f.project_id}
|
||||||
|
contracts = (Project.query
|
||||||
|
.filter(Project.id.in_(contract_ids))
|
||||||
|
.order_by(Project.name).all()) if contract_ids else []
|
||||||
|
|
||||||
facilities = query.order_by(Facility.name).all()
|
# Facility dropdown — narrowed by the selected contract.
|
||||||
|
facility_options = [f for f in scoped_facilities
|
||||||
|
if not contract_id or f.project_id == contract_id]
|
||||||
|
|
||||||
# Ensure every facility on the sheet has a token so its qr.png renders.
|
# The rendered grid — apply the contract + facility filters.
|
||||||
|
grid_facilities = facility_options
|
||||||
|
if facility_id:
|
||||||
|
grid_facilities = [f for f in grid_facilities if f.id == facility_id]
|
||||||
|
|
||||||
|
# Ensure every rendered facility (and area, if requested) has a token so
|
||||||
|
# its qr.png renders; collect areas keyed by facility id.
|
||||||
changed = False
|
changed = False
|
||||||
for f in facilities:
|
areas_by_facility = {}
|
||||||
|
for f in grid_facilities:
|
||||||
if not f.public_token:
|
if not f.public_token:
|
||||||
f.ensure_public_token()
|
f.ensure_public_token()
|
||||||
changed = True
|
changed = True
|
||||||
|
if include_areas:
|
||||||
|
fa = f.areas.order_by(Area.name).all()
|
||||||
|
for a in fa:
|
||||||
|
if not a.public_token:
|
||||||
|
a.ensure_public_token()
|
||||||
|
changed = True
|
||||||
|
areas_by_facility[f.id] = fa
|
||||||
if changed:
|
if changed:
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
selected_contract = db.session.get(Project, contract_id) if contract_id else None
|
selected_contract = db.session.get(Project, contract_id) if contract_id else None
|
||||||
return render_template('facilities/qr_print_all.html',
|
return render_template('facilities/qr_print_all.html',
|
||||||
facilities=facilities,
|
facilities=grid_facilities,
|
||||||
selected_contract=selected_contract)
|
areas_by_facility=areas_by_facility,
|
||||||
|
include_areas=include_areas,
|
||||||
|
contracts=contracts,
|
||||||
|
facility_options=facility_options,
|
||||||
|
selected_contract=selected_contract,
|
||||||
|
selected_contract_id=contract_id,
|
||||||
|
selected_facility_id=facility_id)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/qr/export-pdf', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def facility_qr_export_pdf():
|
||||||
|
"""Export the selected facility + area QR codes to a single PDF.
|
||||||
|
|
||||||
|
Selection arrives as repeated `facility_ids` / `area_ids` form fields.
|
||||||
|
Scope is enforced per-id via the same helpers as the QR pages, so a
|
||||||
|
customer can never export a code outside their assigned facilities.
|
||||||
|
"""
|
||||||
|
if current_user.role == 'inspector':
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
facility_ids = request.form.getlist('facility_ids', type=int)
|
||||||
|
area_ids = request.form.getlist('area_ids', type=int)
|
||||||
|
|
||||||
|
if not facility_ids and not area_ids:
|
||||||
|
flash('Select at least one QR code to export.', 'warning')
|
||||||
|
return redirect(request.referrer or url_for('facilities.facility_qr_print_all'))
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for fid in facility_ids:
|
||||||
|
facility = _facility_for_qr_or_403(fid) # 403 if out of scope
|
||||||
|
url = _public_facility_url(facility)
|
||||||
|
items.append({
|
||||||
|
'title': facility.name,
|
||||||
|
'subtitle': facility.project.name if facility.project else None,
|
||||||
|
'caption': 'Facility · Report a problem & view recent quality',
|
||||||
|
'png': _qr_png_bytes(url),
|
||||||
|
'_sort': ((facility.name or '').lower(), 0, ''),
|
||||||
|
})
|
||||||
|
for aid in area_ids:
|
||||||
|
area = _area_for_qr_or_403(aid) # 403 if out of scope
|
||||||
|
url = _public_area_url(area)
|
||||||
|
fac_name = area.facility.name if area.facility else ''
|
||||||
|
items.append({
|
||||||
|
'title': area.name,
|
||||||
|
'subtitle': fac_name or None,
|
||||||
|
'caption': 'Area · Report a problem & view recent quality',
|
||||||
|
'png': _qr_png_bytes(url),
|
||||||
|
'_sort': (fac_name.lower(), 1, (area.name or '').lower()),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Persist any tokens minted by ensure_public_token() above.
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Group each facility with its own areas: facility card first, then areas.
|
||||||
|
items.sort(key=lambda x: x['_sort'])
|
||||||
|
|
||||||
|
from app.utils.pdf_export import generate_qr_codes_pdf
|
||||||
|
summary = f'{len(facility_ids)} facilit' + ('y' if len(facility_ids) == 1 else 'ies')
|
||||||
|
summary += f', {len(area_ids)} area' + ('' if len(area_ids) == 1 else 's')
|
||||||
|
pdf_bytes = generate_qr_codes_pdf(items, filter_summary=summary)
|
||||||
|
|
||||||
|
logger.info('FACILITIES | qr_export_pdf | user=%s | facilities=%s | areas=%s',
|
||||||
|
current_user.username, len(facility_ids), len(area_ids))
|
||||||
|
log_action(ACTION_EXPORT, 'Facility', 0, 'QR Codes',
|
||||||
|
f'exported {len(facility_ids)} facility + {len(area_ids)} area QR codes to PDF')
|
||||||
|
|
||||||
|
from flask import Response
|
||||||
|
return Response(pdf_bytes, mimetype='application/pdf', headers={
|
||||||
|
'Content-Disposition': 'attachment; filename="qr_codes.pdf"',
|
||||||
|
})
|
||||||
|
|
||||||
# ── Public Area QR code ───────────────────────────────────────────────────────
|
# ── Public Area QR code ───────────────────────────────────────────────────────
|
||||||
# Mirrors the facility QR routes above, but scoped to a single area. Customer
|
# Mirrors the facility QR routes above, but scoped to a single area. Customer
|
||||||
|
|||||||
@@ -4,42 +4,102 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<style>
|
<style>
|
||||||
.qr-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
|
.qr-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
|
||||||
.qr-item { break-inside: avoid; page-break-inside: avoid; text-align: center; }
|
.qr-item { position: relative; break-inside: avoid; page-break-inside: avoid;
|
||||||
|
text-align: center; cursor: pointer; }
|
||||||
.qr-item img { width: 220px; height: 220px; max-width: 100%; }
|
.qr-item img { width: 220px; height: 220px; max-width: 100%; }
|
||||||
|
.qr-item.qr-selected { outline: 3px solid #2563eb; outline-offset: -1px; }
|
||||||
|
.qr-check { position: absolute; top: 10px; left: 10px; }
|
||||||
|
.qr-check .form-check-input { width: 1.25rem; height: 1.25rem; }
|
||||||
|
.qr-kind { font-size: .68rem; letter-spacing: .08em; }
|
||||||
@media print {
|
@media print {
|
||||||
.no-print { display: none !important; }
|
.no-print { display: none !important; }
|
||||||
.navbar, nav, footer { display: none !important; }
|
.navbar, nav, footer { display: none !important; }
|
||||||
.qr-item { border: 1px dashed #bbb !important; }
|
.qr-item { border: 1px dashed #bbb !important; cursor: default; }
|
||||||
/* Two per row, comfortable for cutting/posting */
|
.qr-item.qr-selected { outline: none !important; }
|
||||||
.qr-grid { gap: 8px; }
|
.qr-grid { gap: 8px; }
|
||||||
|
/* When printing a selection, hide the unselected cards. */
|
||||||
|
body.print-selected-only .qr-item:not(.qr-selected) { display: none !important; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3 no-print">
|
<div class="d-flex justify-content-between align-items-center mb-3 no-print">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="h4 mb-0"><i class="bi bi-qr-code"></i> Facility QR Codes</h2>
|
<h2 class="h4 mb-0"><i class="bi bi-qr-code"></i> QR Codes</h2>
|
||||||
<div class="text-muted small">
|
<div class="text-muted small">
|
||||||
{% if selected_contract %}Contract: {{ selected_contract.name }} — {% endif %}
|
{% if selected_contract %}Contract: {{ selected_contract.name }} — {% endif %}
|
||||||
{{ facilities|length }} facilit{{ 'y' if facilities|length == 1 else 'ies' }}
|
{{ facilities|length }} facilit{{ 'y' if facilities|length == 1 else 'ies' }}
|
||||||
|
{% if include_areas %}(with areas){% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-2">
|
|
||||||
<a href="{{ url_for('facilities.list_facilities') }}" class="btn btn-outline-secondary btn-sm">
|
<a href="{{ url_for('facilities.list_facilities') }}" class="btn btn-outline-secondary btn-sm">
|
||||||
<i class="bi bi-arrow-left"></i> Back
|
<i class="bi bi-arrow-left"></i> Back
|
||||||
</a>
|
</a>
|
||||||
<button onclick="window.print()" class="btn btn-primary btn-sm">
|
|
||||||
<i class="bi bi-printer"></i> Print All
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if facilities %}
|
{# ── Filter bar (GET reload) ─────────────────────────────────────────────── #}
|
||||||
<div class="qr-grid">
|
<form method="get" id="filterForm" class="card card-body mb-3 no-print">
|
||||||
{% for f in facilities %}
|
<div class="row g-2 align-items-end">
|
||||||
<div class="qr-item card shadow-sm p-3">
|
<div class="col-md-4">
|
||||||
<div class="text-muted text-uppercase" style="font-size:.7rem;letter-spacing:.08em;">
|
<label class="form-label small fw-semibold mb-1">Contract</label>
|
||||||
Scan for Facility Status
|
<select name="contract_id" class="form-select form-select-sm"
|
||||||
|
onchange="document.getElementById('facilitySelect').value=''; this.form.submit();">
|
||||||
|
<option value="">All Contracts</option>
|
||||||
|
{% for c in contracts %}
|
||||||
|
<option value="{{ c.id }}" {{ 'selected' if selected_contract_id == c.id }}>{{ c.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small fw-semibold mb-1">Facility</label>
|
||||||
|
<select name="facility_id" id="facilitySelect" class="form-select form-select-sm">
|
||||||
|
<option value="">All Facilities</option>
|
||||||
|
{% for f in facility_options %}
|
||||||
|
<option value="{{ f.id }}" {{ 'selected' if selected_facility_id == f.id }}>{{ f.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" name="include_areas" value="1"
|
||||||
|
id="includeAreas" {{ 'checked' if include_areas }}>
|
||||||
|
<label class="form-check-label small" for="includeAreas">Include area QR codes</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm w-100">
|
||||||
|
<i class="bi bi-funnel"></i> Apply
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{# ── Selection toolbar + export form ─────────────────────────────────────── #}
|
||||||
|
<form method="post" action="{{ url_for('facilities.facility_qr_export_pdf') }}" id="qrForm">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-3 no-print flex-wrap">
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="selectAllQr(true)">Select All</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="selectAllQr(false)">Clear</button>
|
||||||
|
<span class="text-muted small" id="selCount">0 selected</span>
|
||||||
|
<div class="ms-auto d-flex gap-2">
|
||||||
|
<button type="button" class="btn btn-primary btn-sm" onclick="printSelected()">
|
||||||
|
<i class="bi bi-printer"></i> Print Selected
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-danger btn-sm">
|
||||||
|
<i class="bi bi-file-earmark-pdf"></i> Export Selected to PDF
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if facilities %}
|
||||||
|
<div class="qr-grid">
|
||||||
|
{% for f in facilities %}
|
||||||
|
{# Facility QR card #}
|
||||||
|
<label class="qr-item card shadow-sm p-3 mb-0">
|
||||||
|
<div class="qr-check no-print">
|
||||||
|
<input type="checkbox" class="form-check-input qr-cb" name="facility_ids" value="{{ f.id }}">
|
||||||
|
</div>
|
||||||
|
<div class="text-muted text-uppercase qr-kind">Facility</div>
|
||||||
<div class="fw-bold">{{ f.name }}</div>
|
<div class="fw-bold">{{ f.name }}</div>
|
||||||
{% if f.project %}
|
{% if f.project %}
|
||||||
<div class="text-muted small mb-1">{{ f.project.name }}</div>
|
<div class="text-muted small mb-1">{{ f.project.name }}</div>
|
||||||
@@ -49,14 +109,74 @@
|
|||||||
alt="QR code for {{ f.name }}" loading="lazy">
|
alt="QR code for {{ f.name }}" loading="lazy">
|
||||||
</div>
|
</div>
|
||||||
<div class="small">Report a problem & view recent quality</div>
|
<div class="small">Report a problem & view recent quality</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{% if include_areas %}
|
||||||
|
{% for a in areas_by_facility.get(f.id, []) %}
|
||||||
|
{# Area QR card #}
|
||||||
|
<label class="qr-item card shadow-sm p-3 mb-0">
|
||||||
|
<div class="qr-check no-print">
|
||||||
|
<input type="checkbox" class="form-check-input qr-cb" name="area_ids" value="{{ a.id }}">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="text-muted text-uppercase qr-kind">Area</div>
|
||||||
|
<div class="fw-bold">{{ a.name }}</div>
|
||||||
|
<div class="text-muted small mb-1">{{ f.name }}</div>
|
||||||
|
<div>
|
||||||
|
<img src="{{ url_for('facilities.area_qr_png', area_id=a.id) }}"
|
||||||
|
alt="QR code for {{ a.name }}" loading="lazy">
|
||||||
|
</div>
|
||||||
|
<div class="small">Report a problem & view recent quality</div>
|
||||||
|
</label>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
{% endif %}
|
||||||
{% else %}
|
{% endfor %}
|
||||||
<div class="alert alert-info">No active facilities to print QR codes for.</div>
|
</div>
|
||||||
{% endif %}
|
{% else %}
|
||||||
|
<div class="alert alert-info">No facilities match the selected filters.</div>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
|
||||||
<p class="text-muted small mt-3 no-print">
|
<p class="text-muted small mt-3 no-print">
|
||||||
Tip: print, cut along the dashed lines, and post each code at its facility.
|
Tip: tick the codes you want, then <strong>Print Selected</strong> or
|
||||||
|
<strong>Export Selected to PDF</strong>. With nothing ticked, Print Selected prints them all.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
function checkboxes() { return document.querySelectorAll('.qr-cb'); }
|
||||||
|
|
||||||
|
function updateCount() {
|
||||||
|
var n = document.querySelectorAll('.qr-cb:checked').length;
|
||||||
|
document.getElementById('selCount').textContent = n + ' selected';
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncCard(cb) {
|
||||||
|
var card = cb.closest('.qr-item');
|
||||||
|
if (card) { card.classList.toggle('qr-selected', cb.checked); }
|
||||||
|
}
|
||||||
|
|
||||||
|
window.selectAllQr = function (state) {
|
||||||
|
checkboxes().forEach(function (cb) { cb.checked = state; syncCard(cb); });
|
||||||
|
updateCount();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.printSelected = function () {
|
||||||
|
var anySelected = document.querySelectorAll('.qr-cb:checked').length > 0;
|
||||||
|
if (anySelected) { document.body.classList.add('print-selected-only'); }
|
||||||
|
window.print();
|
||||||
|
setTimeout(function () {
|
||||||
|
document.body.classList.remove('print-selected-only');
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toggling a checkbox inside its <label> card also fires on the label click.
|
||||||
|
checkboxes().forEach(function (cb) {
|
||||||
|
cb.addEventListener('change', function () { syncCard(cb); updateCount(); });
|
||||||
|
});
|
||||||
|
|
||||||
|
updateCount();
|
||||||
|
}());
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1590,3 +1590,93 @@ def generate_facility_summary_pdf(facility, days, start, now,
|
|||||||
doc.build(story)
|
doc.build(story)
|
||||||
return buf.getvalue()
|
return buf.getvalue()
|
||||||
return buf.getvalue()
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def generate_qr_codes_pdf(items, filter_summary: str = '') -> bytes:
|
||||||
|
"""Return a PDF byte-string laying out selected QR codes in a grid.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
items : list of dicts, each:
|
||||||
|
{
|
||||||
|
'title': str, # main label (facility or area name)
|
||||||
|
'subtitle': str | None, # e.g. contract name, or parent facility
|
||||||
|
'caption': str | None, # small line under the QR
|
||||||
|
'png': bytes, # QR code PNG image bytes
|
||||||
|
}
|
||||||
|
filter_summary : human-readable string describing the selection (optional)
|
||||||
|
"""
|
||||||
|
buf = io.BytesIO()
|
||||||
|
generated_at = datetime.now().strftime('%B %d, %Y %I:%M %p ET')
|
||||||
|
report_title = 'QR Codes'
|
||||||
|
|
||||||
|
doc = SimpleDocTemplate(
|
||||||
|
buf,
|
||||||
|
pagesize=letter,
|
||||||
|
leftMargin=0.65 * inch,
|
||||||
|
rightMargin=0.65 * inch,
|
||||||
|
topMargin=1.35 * 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)
|
||||||
|
|
||||||
|
story = []
|
||||||
|
if filter_summary:
|
||||||
|
story.append(Paragraph(f'Filters: {filter_summary}', STYLES['ReportSub']))
|
||||||
|
story.append(Paragraph(
|
||||||
|
f'Total codes: {len(items)}', STYLES['ReportSub']))
|
||||||
|
story.append(Spacer(1, 10))
|
||||||
|
|
||||||
|
if not items:
|
||||||
|
story.append(Paragraph('No QR codes selected.', STYLES['FieldValue']))
|
||||||
|
doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
COLS = 3
|
||||||
|
pw = letter[0] - 1.3 * inch # usable width
|
||||||
|
cell_w = pw / COLS
|
||||||
|
qr_size = 1.7 * inch
|
||||||
|
|
||||||
|
title_style = ParagraphStyle('QRT', fontName='Helvetica-Bold', fontSize=9,
|
||||||
|
alignment=TA_CENTER, leading=11, textColor=C_DARK)
|
||||||
|
sub_style = ParagraphStyle('QRS', fontName='Helvetica', fontSize=7.5,
|
||||||
|
alignment=TA_CENTER, leading=9, textColor=C_SLATE)
|
||||||
|
cap_style = ParagraphStyle('QRC', fontName='Helvetica', fontSize=6.5,
|
||||||
|
alignment=TA_CENTER, leading=8, textColor=C_SLATE)
|
||||||
|
|
||||||
|
def _cell(item):
|
||||||
|
flow = [Paragraph(item.get('title') or '', title_style)]
|
||||||
|
if item.get('subtitle'):
|
||||||
|
flow.append(Paragraph(item['subtitle'], sub_style))
|
||||||
|
flow.append(Spacer(1, 4))
|
||||||
|
flow.append(RLImage(io.BytesIO(item['png']), width=qr_size, height=qr_size))
|
||||||
|
if item.get('caption'):
|
||||||
|
flow.append(Spacer(1, 3))
|
||||||
|
flow.append(Paragraph(item['caption'], cap_style))
|
||||||
|
return flow
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for i in range(0, len(items), COLS):
|
||||||
|
chunk = items[i:i + COLS]
|
||||||
|
row = [_cell(it) for it in chunk]
|
||||||
|
while len(row) < COLS:
|
||||||
|
row.append('') # filler cell to keep the grid rectangular
|
||||||
|
rows.append(row)
|
||||||
|
|
||||||
|
tbl = Table(rows, colWidths=[cell_w] * COLS)
|
||||||
|
tbl.setStyle(TableStyle([
|
||||||
|
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
||||||
|
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
|
||||||
|
('TOPPADDING', (0, 0), (-1, -1), 10),
|
||||||
|
('BOTTOMPADDING', (0, 0), (-1, -1), 16),
|
||||||
|
('LEFTPADDING', (0, 0), (-1, -1), 6),
|
||||||
|
('RIGHTPADDING', (0, 0), (-1, -1), 6),
|
||||||
|
]))
|
||||||
|
story.append(tbl)
|
||||||
|
|
||||||
|
doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb)
|
||||||
|
return buf.getvalue()
|
||||||
Reference in New Issue
Block a user