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.DATA_DIR = str(data_dir)
|
||||||
cfg.DB_PATH = str(data_dir / "imptune.db")
|
cfg.DB_PATH = str(data_dir / "imptune.db")
|
||||||
cfg.DRIVERS_DIR = str(data_dir / "drivers")
|
cfg.DRIVERS_DIR = str(data_dir / "drivers")
|
||||||
|
cfg.ICONS_DIR = str(data_dir / "icons")
|
||||||
|
|
||||||
yield data_dir
|
yield data_dir
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user