feat: driver rename, driver icons, web image + driver search

Driver rename and icons: `Driver.display_name` plus a `DriverIcon` table, both
global/shared like the `Driver` row they hang off, so a rename or an icon is
what every Owner sees. The rename/icon dialog keeps its forms as siblings
(nested forms are invalid HTML) and the icon routes return an `hx-swap-oob`
thumbnail refresh rather than re-rendering the table, which would tear the open
`<dialog>` out of the DOM.

Web image picker: `GET /web/images` renders a pickable grid for a printer or a
driver icon, with the search term prefilled from the entity name and editable.
Picking one downloads it server-side and normalizes it.

Driver download search: `GET /web/drivers` searches for a vendor-wide driver
(the term is rewritten into the vendor's real product name for 15 brands) or for
the exact model as typed. Links only — nothing is downloaded, and the fragment
says the results are unvetted.

Icon uploads no longer reject off-size or non-PNG files: `normalize_icon()`
letterboxes any decodable raster into a 256x256 PNG. An already-exact 256x256
PNG is returned byte-identical, because icon storage is content-addressed and
re-encoding would move the file on every save.

`fetch_image()` makes the request from the server, so `assert_fetchable()`
refuses any URL resolving to a private, loopback, or link-local address, and
re-runs on every redirect. ImpTune sits on the same LAN as the printers it
configures; an unguarded fetcher would be a port scanner for anyone who can
reach the UI.

DuckDuckGo is scraped, not called through an API — no key needed, but fragile,
so both search functions swallow parse failures and return [] instead of 500ing
a page. `WEB_SEARCH=false` disables every outbound request and hides the
controls, for air-gapped installs.

Also: one shared `Jinja2Templates` in `templating.py` instead of five per-router
instances, so a template global is declared once; `_add_missing_columns()` in
`database.py` adds new nullable columns to a pre-existing table, which
`create_tables(safe=True)` skips; `db_env` in test_db.py now closes its
connection on teardown, or the next test's ORM writes land in the previous
test's DB file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-05 10:44:47 +02:00
co-authored by Claude Opus 5
parent 3f9cd1f266
commit 2c06806814
34 changed files with 2516 additions and 162 deletions
+75 -81
View File
@@ -1,99 +1,96 @@
"""Icon upload API — POST /printers/{printer_id}/icon."""
"""Printer icon API — upload, fetch-from-web, and serve."""
from __future__ import annotations
import hashlib
import io
from pathlib import Path
from fastapi import APIRouter, Request, UploadFile
from fastapi import APIRouter, Form, Request, UploadFile
from fastapi.responses import FileResponse, HTMLResponse, Response
from PIL import Image
import imptune.config as cfg
from imptune.db.models import Icon, Printer
from imptune.services import websearch
from imptune.services.icons import (
MAX_ICON_BYTES,
ImageError,
IconTooLarge,
icon_path,
set_printer_icon,
)
router = APIRouter(prefix="/printers")
MAX_ICON_BYTES = 750 * 1024 # 750 KB
def _status_fragment(printer_id: int, sha256: str) -> HTMLResponse:
"""The `#icon-status` block, re-rendered after a successful save.
`?v=` busts the 5-minute private cache the GET route sets — without it the
preview keeps showing the icon that was just replaced.
"""
return HTMLResponse(
content=(
# Swapped-in fragments are normally English-only; this one is on the
# everyday path, so it goes through the i18n store with the English
# text as its fallback body.
'<p class="ok-note" x-data x-text="$store.i18n.t(\'icon_uploaded\')">'
"Icon uploaded successfully</p>"
f'<div class="icon-preview"><img src="/printers/{printer_id}/icon?v={sha256[:8]}"'
' width="56" height="56" alt="">'
'<span class="meta">256&times;256 PNG</span></div>'
),
status_code=200,
)
def _error(message: str, status_code: int = 422) -> HTMLResponse:
return HTMLResponse(content=f"<p class='error-note'>{message}</p>", status_code=status_code)
def _owned_printer(request: Request, printer_id: int) -> Printer | None:
return Printer.get_or_none(
(Printer.id == printer_id) & (Printer.owner == request.state.owner)
)
@router.post("/{printer_id}/icon", response_class=HTMLResponse)
def upload_icon(request: Request, printer_id: int, file: UploadFile) -> HTMLResponse:
"""Accept a printer icon PNG, validate it, store it, and update the Icon record.
"""Accept a printer icon, normalize it to 256x256 PNG, and store it.
Validation rules:
- Format: PNG only
- Dimensions: exactly 256x256 pixels
- Size: at most 750 KB
Replaces any previously uploaded icon for this printer.
Returns an HTMX-friendly HTML fragment.
Any raster Pillow can decode is accepted and letterboxed into the box —
only an unreadable file or one over 750 KB is rejected. Replaces the
printer's previous icon.
"""
# Check printer exists and belongs to this owner
printer = Printer.get_or_none(
(Printer.id == printer_id) & (Printer.owner == request.state.owner)
)
if printer is None:
return HTMLResponse(
content="<p>Printer not found.</p>",
status_code=404,
)
if _owned_printer(request, printer_id) is None:
return HTMLResponse(content="<p>Printer not found.</p>", status_code=404)
# Read file (read one byte extra to detect oversized files)
data = file.file.read(MAX_ICON_BYTES + 1)
if len(data) > MAX_ICON_BYTES:
return HTMLResponse(
content="<p>Icon exceeds 750 KB limit.</p>",
status_code=422,
)
# Validate with Pillow
try:
img = Image.open(io.BytesIO(data))
except Exception:
return HTMLResponse(
content="<p>Icon must be PNG format.</p>",
status_code=422,
)
icon = set_printer_icon(printer_id, data, file.filename or "icon.png")
except IconTooLarge as exc:
return _error(str(exc))
except ImageError:
return _error("That file is not a readable image.")
if img.format != "PNG":
return HTMLResponse(
content="<p>Icon must be PNG format.</p>",
status_code=422,
)
return _status_fragment(printer_id, icon.sha256)
if img.size != (256, 256):
return HTMLResponse(
content=f"<p>Icon must be 256x256 pixels, got {img.size}.</p>",
status_code=422,
)
# Store SHA256-addressed on disk. cfg.ICONS_DIR is the same path the
# .intunewin export reads from — deriving it a second time from DATA_DIR
# would silently diverge if the layout ever changes.
sha256 = hashlib.sha256(data).hexdigest()
icons_dir = Path(cfg.ICONS_DIR)
icons_dir.mkdir(parents=True, exist_ok=True)
icon_path = icons_dir / sha256
icon_path.write_bytes(data)
@router.post("/{printer_id}/icon/from-web", response_class=HTMLResponse)
def upload_icon_from_web(
request: Request, printer_id: int, url: str = Form(...)
) -> HTMLResponse:
"""Download a search-result image server-side and use it as the icon."""
if _owned_printer(request, printer_id) is None:
return HTMLResponse(content="<p>Printer not found.</p>", status_code=404)
# Replace existing Icon record for this printer
Icon.delete().where(Icon.printer == printer_id).execute()
Icon.create(
printer=printer_id,
sha256=sha256,
original_filename=file.filename or "icon.png",
size_bytes=len(data),
)
try:
data = websearch.fetch_image(url)
except websearch.WebSearchError as exc:
return _error(str(exc), status_code=400)
return HTMLResponse(
content=(
'<p class="ok-note">Icon uploaded successfully</p>'
f'<div class="icon-preview"><img src="/printers/{printer_id}/icon?v={sha256[:8]}"'
f' width="56" height="56" alt=""><span class="meta">{img.size[0]}&times;{img.size[1]} PNG</span></div>'
),
status_code=200,
)
try:
icon = set_printer_icon(printer_id, data, url.rsplit("/", 1)[-1][:120] or "web.png")
except IconTooLarge as exc:
return _error(str(exc))
except ImageError:
return _error("That URL did not return a readable image.")
return _status_fragment(printer_id, icon.sha256)
@router.get("/{printer_id}/icon")
@@ -102,22 +99,19 @@ def get_icon(request: Request, printer_id: int) -> Response:
Owner-scoped: a printer belonging to another owner reads as missing.
"""
printer = Printer.get_or_none(
(Printer.id == printer_id) & (Printer.owner == request.state.owner)
)
if printer is None:
if _owned_printer(request, printer_id) is None:
return Response(status_code=404)
icon = Icon.get_or_none(Icon.printer == printer_id)
if icon is None:
return Response(status_code=404)
icon_path = Path(cfg.ICONS_DIR) / icon.sha256
if not icon_path.exists():
path = icon_path(icon.sha256)
if not path.exists():
return Response(status_code=404)
return FileResponse(
icon_path,
path,
media_type="image/png",
headers={"Cache-Control": "private, max-age=300"},
)