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:
+75
-81
@@ -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×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]}×{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"},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user