feat(05-02): icon upload endpoint with validation
- 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
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
"""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,
|
||||
)
|
||||
@@ -24,6 +24,7 @@ def tmp_data_dir(tmp_path, monkeypatch):
|
||||
cfg.DATA_DIR = str(data_dir)
|
||||
cfg.DB_PATH = str(data_dir / "imptune.db")
|
||||
cfg.DRIVERS_DIR = str(data_dir / "drivers")
|
||||
cfg.ICONS_DIR = str(data_dir / "icons")
|
||||
|
||||
yield data_dir
|
||||
|
||||
|
||||
Reference in New Issue
Block a user