Skip to content

Commit 1092bf5

Browse files
authored
Merge pull request #29 from jantman/issues/14
Issue #14: QR code generation for equipment labels
2 parents 5be0f36 + a2eafa9 commit 1092bf5

25 files changed

Lines changed: 2121 additions & 122 deletions

File tree

‎.env.example‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44
# Flask secret key (change in production!)
55
SECRET_KEY=dev-secret-change-me
66

7+
# Base URL of this ESB instance — used as the prefix for QR code targets.
8+
# Must be the externally-reachable URL that members' phones can hit to open equipment pages.
9+
# Required to enable QR code generation. No trailing slash is required (it is stripped).
10+
ESB_BASE_URL=http://localhost:5000
11+
712
# Database connection URL
813
DATABASE_URL=mysql+pymysql://root:esb_dev_password@localhost/esb
914

‎.github/workflows/ci.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ jobs:
2626
with:
2727
python-version: "3.14"
2828
allow-prereleases: true
29+
- run: sudo apt-get update && sudo apt-get install -y libzbar0
2930
- run: pip install -r requirements-dev.txt
3031
- run: pytest tests/ -v
3132
env:
@@ -55,6 +56,7 @@ jobs:
5556
repair-record-detail.png
5657
kanban-board.png
5758
equipment-detail.png
59+
qr-generation.png
5860
user-management.png
5961
app-configuration.png
6062
)

‎.gitignore‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,6 @@ cython_debug/
190190

191191
# ESB application
192192
uploads/
193-
esb/static/qrcodes/*.png
194193

195194
# Ruff stuff:
196195
.ruff_cache/

‎Dockerfile‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ WORKDIR /app
88
# Install system dependencies for MariaDB client
99
RUN apt-get update && apt-get install -y --no-install-recommends \
1010
gcc \
11+
libzbar0 \
1112
&& rm -rf /var/lib/apt/lists/*
1213

1314
# Install Python dependencies

‎_bmad-output/implementation-artifacts/tech-spec-qr-code-generation.md‎

Lines changed: 817 additions & 0 deletions
Large diffs are not rendered by default.

‎docs/administrators.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ Open `http://localhost:5000` in a browser (or the server's IP/hostname on port 5
7575
|----------|-------------|----------|---------|---------|
7676
| `SECRET_KEY` | Flask secret key for session signing. Must be random in production. | Yes | `dev-secret-change-me` | `a1b2c3d4e5f6...` (use `python3 -c "import secrets; print(secrets.token_hex(32))"`) |
7777
| `DATABASE_URL` | SQLAlchemy database connection URL. In Docker, the hostname is `db`. | Yes | `mysql+pymysql://root:esb_dev_password@localhost/esb` | `mysql+pymysql://root:yourpassword@db/esb` |
78+
| `ESB_BASE_URL` | Externally-reachable base URL of this ESB instance. Used as the prefix for QR code target URLs (the URL members' phones open when they scan a printed QR label). Must be set to enable QR code generation; otherwise the "Generate QR Code" button on each equipment detail page is disabled. Inside a container the request host is unreliable, so this must be set explicitly. Trailing slashes are stripped; must be an `http(s)://host[:port]` URL with no path, query, fragment, or credentials. | Yes | _(empty)_ | `http://esb.example.com:8080` |
7879
| `MARIADB_ROOT_PASSWORD` | Root password for the MariaDB container. Must match the password in `DATABASE_URL`. | Yes | `esb_dev_password` | `strong-random-password` |
7980
| `UPLOAD_PATH` | Directory for uploaded files (photos, documents). Relative to app root or absolute path. | No | `uploads` | `/app/uploads` |
8081
| `UPLOAD_MAX_SIZE_MB` | Maximum upload file size in megabytes. | No | `500` | `100` |

‎docs/staff.md‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,34 @@ The export includes one row per equipment item with these columns: `id`, `name`,
129129

130130
![Equipment Detail Page](images/equipment-detail.png)
131131

132+
## QR Code Labels
133+
134+
Each piece of equipment can have a printable QR code label attached to it. When a member scans the label with their phone, they land on the public equipment page, where they can see current status and report problems without needing to log in.
135+
136+
### Who can generate QR codes
137+
138+
Any logged-in user (Staff or Technician) can generate QR codes from the equipment detail page. The feature is only available when an administrator has set the `ESB_BASE_URL` environment variable — see the [Administrators Guide](administrators.md) for details. If the button is disabled with the tooltip "ESB_BASE_URL not configured", contact an administrator.
139+
140+
### Generating a QR code label
141+
142+
1. Open the equipment detail page for the item you want to label.
143+
2. Click **Generate QR Code**.
144+
3. Pick a size from the dropdown. Choices include square stickers (1", 1.5", 2", 3", 4"), Avery label sizes (5160 and 5163), and a US Letter full-page option.
145+
4. Optionally enable **Include equipment name above QR** to print the equipment's name at the top of the label.
146+
5. Optionally enable **Include URL below QR** to print the scan target URL at the bottom of the label.
147+
6. Watch the live preview update as you change options.
148+
7. Click **Download QR Code** to download a PNG file ready to print.
149+
150+
All output is rendered at 300 DPI so labels print sharply at any of the listed sizes.
151+
152+
> **Tip:** URL text below the QR is most useful at label/page sizes (Avery 5163, US Letter). On small stickers (≤ 2") the URL is usually truncated and adds little value — the live preview shows the result; uncheck **Include URL below QR** if it isn't legible.
153+
154+
![QR Code Generation](images/qr-generation.png)
155+
156+
When someone scans a printed QR label, they see the public equipment status page — see the example below for the member-side view.
157+
158+
![Scanned equipment page (mobile)](images/qr-equipment-page-mobile.png)
159+
132160
## Managing Areas
133161

134162
Navigate to **Admin > Areas** to manage the areas (rooms/zones) of the makerspace.

‎esb/config.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ class Config:
88
"""Base configuration."""
99

1010
SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-change-me')
11+
ESB_BASE_URL = os.environ.get('ESB_BASE_URL', '')
1112
SQLALCHEMY_DATABASE_URI = os.environ.get(
1213
'DATABASE_URL', 'mysql+pymysql://root:password@localhost/esb'
1314
)

‎esb/forms/equipment_forms.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from flask_wtf import FlaskForm
44
from flask_wtf.file import FileAllowed, FileField, FileRequired
55
from wtforms import (
6+
BooleanField,
67
DateField,
78
DecimalField,
89
SelectField,
@@ -13,6 +14,7 @@
1314
from wtforms.validators import URL, DataRequired, Length, Optional
1415

1516
from esb.models.document import DOCUMENT_CATEGORIES
17+
from esb.services.qr_service import QR_SIZE_PRESETS
1618

1719
FORM_DOCUMENT_CATEGORIES = [('', '-- Select Category --')] + DOCUMENT_CATEGORIES
1820

@@ -98,3 +100,17 @@ class ExternalLinkForm(FlaskForm):
98100
title = StringField('Title', validators=[DataRequired(), Length(max=200)])
99101
url = StringField('URL', validators=[DataRequired(), Length(max=2000), URL()])
100102
submit = SubmitField('Add Link')
103+
104+
105+
class QRGenerateForm(FlaskForm):
106+
"""Form for QR code generation options."""
107+
108+
size = SelectField(
109+
'Size',
110+
choices=[(p.key, p.label) for p in QR_SIZE_PRESETS],
111+
validators=[DataRequired()],
112+
default='sticker_2',
113+
)
114+
include_name = BooleanField('Include equipment name above QR', default=False)
115+
include_url = BooleanField('Include URL below QR', default=False)
116+
submit = SubmitField('Download QR Code')

‎esb/services/qr_service.py‎

Lines changed: 172 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,71 +1,193 @@
1-
"""QR code generation service for equipment pages."""
1+
"""QR code generation service for equipment pages.
22
3+
Renders QR codes as in-memory PNG bytes with optional equipment-name text
4+
above and URL text below. No disk caching — generated on demand per request.
5+
"""
6+
7+
import io
38
import os
9+
from dataclasses import dataclass
10+
from functools import lru_cache
411

512
import qrcode
613
from flask import current_app
14+
from PIL import Image, ImageDraw, ImageFont
715

8-
from esb.extensions import db
9-
from esb.models.equipment import Equipment
10-
16+
_DPI = 300
17+
_MIN_FONT_PT = 8
1118

12-
def generate_qr_code(equipment_id: int, base_url: str) -> str:
13-
"""Generate a QR code PNG for an equipment item.
1419

15-
Args:
16-
equipment_id: The equipment ID to generate QR for.
17-
base_url: The base URL of the application (e.g., 'http://192.168.1.50:5000').
20+
@lru_cache(maxsize=64)
21+
def _load_font(font_path: str, size_px: int) -> ImageFont.FreeTypeFont:
22+
"""Cache truetype font loads — preview hot path hits this per render."""
23+
return ImageFont.truetype(font_path, size_px)
1824

19-
Returns:
20-
Relative path to the generated QR code image (e.g., 'qrcodes/42.png').
21-
22-
Raises:
23-
ValueError: if equipment_id does not exist in the database.
24-
"""
25-
equipment = db.session.get(Equipment, equipment_id)
26-
if equipment is None:
27-
raise ValueError(f'Equipment with id {equipment_id} not found')
2825

29-
url = f"{base_url}/public/equipment/{equipment_id}"
30-
qr = qrcode.make(url)
26+
@dataclass(frozen=True)
27+
class QRSizePreset:
28+
key: str
29+
label: str
30+
width_in: float
31+
height_in: float
3132

32-
qr_dir = os.path.join(current_app.static_folder, 'qrcodes')
33-
os.makedirs(qr_dir, exist_ok=True)
3433

35-
filename = f"{equipment_id}.png"
36-
filepath = os.path.join(qr_dir, filename)
37-
qr.save(filepath)
34+
QR_SIZE_PRESETS: tuple[QRSizePreset, ...] = (
35+
QRSizePreset('sticker_1', '1"×1" sticker', 1.0, 1.0),
36+
QRSizePreset('sticker_1_5', '1.5"×1.5" sticker', 1.5, 1.5),
37+
QRSizePreset('sticker_2', '2"×2" sticker', 2.0, 2.0),
38+
QRSizePreset('sticker_3', '3"×3" sticker', 3.0, 3.0),
39+
QRSizePreset('sticker_4', '4"×4" sticker', 4.0, 4.0),
40+
QRSizePreset('avery_5160', 'Avery 5160 label (2.625"×1")', 2.625, 1.0),
41+
QRSizePreset('avery_5163', 'Avery 5163 label (4"×2")', 4.0, 2.0),
42+
QRSizePreset('letter', 'US Letter page (8.5"×11")', 8.5, 11.0),
43+
)
3844

39-
return f"qrcodes/{filename}"
45+
QR_PRESETS_BY_KEY: dict[str, QRSizePreset] = {p.key: p for p in QR_SIZE_PRESETS}
4046

4147

42-
def get_qr_code_path(equipment_id: int) -> str | None:
43-
"""Return the relative path to an equipment's QR code if it exists.
48+
def _px(inches: float) -> int:
49+
return int(inches * _DPI + 0.5)
4450

45-
Returns:
46-
Relative path string (e.g., 'qrcodes/42.png') or None if not found.
47-
"""
48-
qr_dir = os.path.join(current_app.static_folder, 'qrcodes')
49-
filepath = os.path.join(qr_dir, f"{equipment_id}.png")
50-
if os.path.isfile(filepath):
51-
return f"qrcodes/{equipment_id}.png"
52-
return None
5351

52+
def _pt_to_px(pt: int) -> int:
53+
return int(pt * _DPI / 72 + 0.5)
5454

55-
def generate_all_qr_codes(base_url: str) -> int:
56-
"""Generate QR codes for all non-archived equipment.
5755

58-
Args:
59-
base_url: The base URL of the application.
56+
def render_qr_png(
57+
equipment,
58+
preset: QRSizePreset,
59+
*,
60+
include_name: bool = False,
61+
include_url: bool = False,
62+
base_url: str,
63+
) -> bytes:
64+
"""Render a QR code PNG for an equipment item and return the bytes.
6065
61-
Returns:
62-
Number of QR codes generated.
66+
The service trusts `base_url` — it must be validated upstream via
67+
`esb.utils.text.get_normalized_base_url`.
6368
"""
64-
equipment_list = db.session.execute(
65-
db.select(Equipment).filter_by(is_archived=False)
66-
).scalars().all()
67-
68-
for equip in equipment_list:
69-
generate_qr_code(equip.id, base_url)
70-
71-
return len(equipment_list)
69+
canvas_w_px = _px(preset.width_in)
70+
canvas_h_px = _px(preset.height_in)
71+
reserved_top = int(canvas_h_px * 0.15) if include_name else 0
72+
reserved_bottom = int(canvas_h_px * 0.15) if include_url else 0
73+
margin = max(4, int(canvas_w_px * 0.02))
74+
avail = min(canvas_w_px, canvas_h_px - reserved_top - reserved_bottom) - margin
75+
76+
qr_url = f'{base_url}/public/equipment/{equipment.id}'
77+
qr = qrcode.QRCode(
78+
error_correction=qrcode.constants.ERROR_CORRECT_M,
79+
box_size=1,
80+
border=4,
81+
)
82+
qr.add_data(qr_url)
83+
qr.make(fit=True)
84+
# qr.get_matrix() already includes the border on each side, so this
85+
# equals modules_count + 2 * border — do NOT add border again.
86+
native = len(qr.get_matrix())
87+
if avail < native:
88+
raise ValueError(
89+
f'URL is too long for preset {preset.label!r} — '
90+
'choose a larger preset or shorten ESB_BASE_URL.'
91+
)
92+
module_px = avail // native
93+
qr_px = module_px * native
94+
95+
if module_px < 5:
96+
current_app.logger.warning(
97+
'QR for equipment %s at preset %s has %d px modules (<5); '
98+
'scannability may be marginal',
99+
equipment.id, preset.key, module_px,
100+
)
101+
102+
qr_img = (
103+
qr.make_image(fill_color='black', back_color='white')
104+
.convert('RGB')
105+
.resize((qr_px, qr_px), Image.NEAREST)
106+
)
107+
108+
canvas = Image.new('RGB', (canvas_w_px, canvas_h_px), 'white')
109+
paste_x = (canvas_w_px - qr_px) // 2
110+
paste_y = reserved_top + ((canvas_h_px - reserved_top - reserved_bottom) - qr_px) // 2
111+
canvas.paste(qr_img, (paste_x, paste_y))
112+
113+
max_text_width = int(qr_px * 1.2)
114+
if include_name:
115+
_draw_text_row(
116+
canvas, equipment.name,
117+
row_top=0, row_height=reserved_top, max_width_px=max_text_width,
118+
)
119+
if include_url:
120+
_draw_text_row(
121+
canvas, qr_url,
122+
row_top=canvas_h_px - reserved_bottom,
123+
row_height=reserved_bottom,
124+
max_width_px=max_text_width,
125+
)
126+
127+
buf = io.BytesIO()
128+
canvas.save(buf, format='PNG')
129+
return buf.getvalue()
130+
131+
132+
def _draw_text_row(canvas, text, *, row_top, row_height, max_width_px):
133+
"""Draw centered text in a horizontal row region of the canvas."""
134+
font_path = os.path.join(current_app.static_folder, 'fonts', 'DejaVuSans-Bold.ttf') # noqa: PTH118
135+
if not os.path.isfile(font_path):
136+
raise RuntimeError(
137+
f'QR label font missing at {font_path}. '
138+
'Ensure DejaVuSans-Bold.ttf is vendored in esb/static/fonts/.'
139+
)
140+
font, rendered = _fit_text(
141+
text,
142+
max_width_px=max_width_px,
143+
max_height_px=row_height,
144+
font_path=font_path,
145+
)
146+
if rendered == '':
147+
return
148+
draw = ImageDraw.Draw(canvas)
149+
(left, top, right, bottom) = draw.textbbox((0, 0), rendered, font=font)
150+
width = right - left
151+
height = bottom - top
152+
x = (canvas.width - width) // 2 - left
153+
y = row_top + (row_height - height) // 2 - top
154+
draw.text((x, y), rendered, fill='black', font=font)
155+
156+
157+
def _fit_text(text, *, max_width_px, max_height_px, font_path):
158+
"""Return (font, rendered_text) fitting within max_width_px at ≤ max_height_px.
159+
160+
Shrinks font toward the 8 pt floor; if still too wide at the floor,
161+
truncates with an ellipsis (binary-search); if even ellipsis alone
162+
doesn't fit, returns the min-size font and an empty string.
163+
"""
164+
min_px = _pt_to_px(_MIN_FONT_PT)
165+
size_px = max_height_px
166+
step = max(1, int(max_height_px * 0.05))
167+
scratch = ImageDraw.Draw(Image.new('RGB', (1, 1)))
168+
169+
def width_at(t, font):
170+
left, _, right, _ = scratch.textbbox((0, 0), t, font=font)
171+
return right - left
172+
173+
while size_px >= min_px:
174+
font = _load_font(font_path, size_px)
175+
if width_at(text, font) <= max_width_px:
176+
return font, text
177+
size_px -= step
178+
179+
font = _load_font(font_path, min_px)
180+
if width_at('…', font) > max_width_px:
181+
return font, ''
182+
if width_at(text + '…', font) <= max_width_px:
183+
return font, text + '…'
184+
185+
# Binary search: find the largest prefix length whose candidate fits.
186+
lo, hi = 0, len(text)
187+
while lo < hi:
188+
mid = (lo + hi + 1) // 2
189+
if width_at(text[:mid] + '…', font) <= max_width_px:
190+
lo = mid
191+
else:
192+
hi = mid - 1
193+
return font, text[:lo] + '…' if lo > 0 else '…'

0 commit comments

Comments
 (0)