Jul 16 - Update facilities QR codes - add filters & export PDF

This commit is contained in:
2026-07-16 09:39:05 -04:00
parent 593c630e1b
commit faf4796b4a
4 changed files with 366 additions and 48 deletions
+2 -1
View File
@@ -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
View File
@@ -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
+140 -20
View File
@@ -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 &amp; view recent quality</div> <div class="small">Report a problem &amp; 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 &amp; 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 %}
+90
View File
@@ -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()