- Create imptune/api/icons.py with POST /printers/{printer_id}/icon
- Validate PNG format, 256x256 dimensions, 750KB max size
- Store icons SHA256-addressed under cfg.DATA_DIR/icons/
- Replace existing Icon record on re-upload
- Register icons.router in main.py with ICONS_DIR makedirs
- Patch cfg.ICONS_DIR in conftest.py for tests
- All 6 icon upload tests pass
89 lines
2.5 KiB
Python
89 lines
2.5 KiB
Python
"""Icon upload API — POST /printers/{printer_id}/icon."""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import io
|
|
from pathlib import Path
|
|
|
|
from fastapi import APIRouter, UploadFile
|
|
from fastapi.responses import HTMLResponse
|
|
from PIL import Image
|
|
|
|
import imptune.config as cfg
|
|
from imptune.db.models import Icon, Printer
|
|
|
|
router = APIRouter(prefix="/printers")
|
|
|
|
MAX_ICON_BYTES = 750 * 1024 # 750 KB
|
|
|
|
|
|
@router.post("/{printer_id}/icon", response_class=HTMLResponse)
|
|
def upload_icon(printer_id: int, file: UploadFile) -> HTMLResponse:
|
|
"""Accept a printer icon PNG, validate it, store it, and update the Icon record.
|
|
|
|
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.
|
|
"""
|
|
# Check printer exists
|
|
printer = Printer.get_or_none(Printer.id == printer_id)
|
|
if printer 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,
|
|
)
|
|
|
|
if img.format != "PNG":
|
|
return HTMLResponse(
|
|
content="<p>Icon must be PNG format.</p>",
|
|
status_code=422,
|
|
)
|
|
|
|
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
|
|
sha256 = hashlib.sha256(data).hexdigest()
|
|
icons_dir = Path(cfg.DATA_DIR) / "icons"
|
|
icons_dir.mkdir(parents=True, exist_ok=True)
|
|
icon_path = icons_dir / sha256
|
|
icon_path.write_bytes(data)
|
|
|
|
# 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),
|
|
)
|
|
|
|
return HTMLResponse(
|
|
content="<p>Icon uploaded successfully</p>",
|
|
status_code=200,
|
|
)
|