Add a static image widget (PNG/JPEG/GIF/BMP/WEBP/TIFF/PDF upload)
A new widget type showing a single user-uploaded image with no live upstream to poll -- decoded once at upload time (PDF's first page via pypdfium2, BSD-3-Clause/Apache-2.0, no copyleft exposure) into plain RGB PNG bytes, then composed per a crop/stretch/letterbox display mode like the photos widget.
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
"""app.image_upload.decode_upload -- pure decoding logic, no HTTP, no DB.
|
||||
See routers/api_widgets.py's api_widget_static_upload for the endpoint
|
||||
that calls this, and tests/test_widget_config_and_queue_endpoints.py for
|
||||
HTTP-level coverage of that endpoint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from PIL import Image
|
||||
|
||||
from app import image_upload
|
||||
|
||||
|
||||
def _png_bytes(size=(20, 10), color=(255, 0, 0)) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", size, color).save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _pdf_bytes(size=(40, 30), color=(0, 255, 0)) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", size, color).save(buf, format="PDF")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _gif_bytes(size=(15, 15)) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", size, (0, 0, 255)).save(buf, format="GIF")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_decodes_a_png():
|
||||
img = image_upload.decode_upload(_png_bytes(size=(20, 10)))
|
||||
assert img.size == (20, 10)
|
||||
assert img.mode == "RGB"
|
||||
|
||||
|
||||
def test_decodes_a_jpeg():
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (12, 8), (1, 2, 3)).save(buf, format="JPEG")
|
||||
img = image_upload.decode_upload(buf.getvalue())
|
||||
assert img.size == (12, 8)
|
||||
assert img.mode == "RGB"
|
||||
|
||||
|
||||
def test_decodes_a_gif():
|
||||
img = image_upload.decode_upload(_gif_bytes(size=(15, 15)))
|
||||
assert img.size == (15, 15)
|
||||
assert img.mode == "RGB"
|
||||
|
||||
|
||||
def test_decodes_a_pdfs_first_page():
|
||||
img = image_upload.decode_upload(_pdf_bytes(size=(40, 30)))
|
||||
# Rendered at PDF_RENDER_SCALE (2.0), not the page's native point size.
|
||||
assert img.size == (80, 60)
|
||||
assert img.mode == "RGB"
|
||||
|
||||
|
||||
def test_rejects_garbage_bytes():
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
image_upload.decode_upload(b"this is not an image or a pdf")
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
|
||||
def test_rejects_a_pdf_with_no_pages():
|
||||
# A syntactically-valid empty PDF (no /Page objects) -- pypdfium2
|
||||
# opens it fine but reports zero pages.
|
||||
empty_pdf = (
|
||||
b"%PDF-1.4\n"
|
||||
b"1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n"
|
||||
b"2 0 obj<</Type/Pages/Kids[]/Count 0>>endobj\n"
|
||||
b"xref\n0 1\n0000000000 65535 f \n"
|
||||
b"trailer<</Size 3/Root 1 0 R>>\n"
|
||||
b"startxref\n0\n%%EOF"
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
image_upload.decode_upload(empty_pdf)
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
|
||||
def test_rejects_a_file_over_the_size_limit(monkeypatch):
|
||||
monkeypatch.setattr(image_upload, "MAX_UPLOAD_BYTES", 10)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
image_upload.decode_upload(_png_bytes())
|
||||
assert exc_info.value.status_code == 400
|
||||
@@ -75,6 +75,7 @@ def test_expected_columns_exist_on_current_schema():
|
||||
assert "whiteboard_cached_image" in frame_columns # migration 14
|
||||
assert "calendar_week_start_offset" in frame_columns
|
||||
assert "name" in task_widget_columns # migration 19
|
||||
assert "static_widget_configs" in inspector.get_table_names() # migration 20
|
||||
|
||||
|
||||
# --- widget system backfill (migration 16 + _ensure_widgets_backfilled) ---
|
||||
|
||||
@@ -9,7 +9,18 @@ while the (unmodified, copied-over) dialog JS posts form-urlencoded data
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.models import CalendarWidgetConfig, Frame, PhotoWidgetConfig, TaskWidgetConfig, Widget
|
||||
import io
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from app.models import (
|
||||
CalendarWidgetConfig,
|
||||
Frame,
|
||||
PhotoWidgetConfig,
|
||||
StaticWidgetConfig,
|
||||
TaskWidgetConfig,
|
||||
Widget,
|
||||
)
|
||||
|
||||
from .conftest import csrf_headers
|
||||
|
||||
@@ -44,6 +55,24 @@ def _add_tasks_widget(db_session) -> Widget:
|
||||
return widget
|
||||
|
||||
|
||||
def _add_static_widget(db_session) -> Widget:
|
||||
import time
|
||||
|
||||
widget = Widget(frame_id=1, widget_type="static", x=0, y=0, w=2, h=2,
|
||||
sort_order=1, created_at=time.time())
|
||||
db_session.add(widget)
|
||||
db_session.flush()
|
||||
db_session.add(StaticWidgetConfig(widget_id=widget.id))
|
||||
db_session.commit()
|
||||
return widget
|
||||
|
||||
|
||||
def _png_bytes(size=(20, 10), color=(10, 20, 30)) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", size, color).save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _mock_immich(monkeypatch):
|
||||
monkeypatch.setattr("app.routers.api_widgets.immich_client_for", lambda frame: object())
|
||||
monkeypatch.setattr("app.routers.api_widgets.list_assets", lambda client, album_id: _ASSETS)
|
||||
@@ -123,6 +152,38 @@ def test_config_save_truncates_an_overlong_tasks_name(client, db_session):
|
||||
assert len(cfg.name) == 40
|
||||
|
||||
|
||||
def test_config_save_updates_a_static_widget(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget = _add_static_widget(db_session)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/frames/1/widgets/{widget.id}/config",
|
||||
data={"display_mode": "letterbox"},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
cfg = db_session.get(StaticWidgetConfig, widget.id)
|
||||
assert cfg.display_mode == "letterbox"
|
||||
|
||||
|
||||
def test_config_save_rejects_an_unrecognized_static_display_mode(client, db_session):
|
||||
"""Falls back to the default rather than erroring -- same "truncate/
|
||||
clamp, don't reject" posture as the other config-save fields."""
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget = _add_static_widget(db_session)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/frames/1/widgets/{widget.id}/config",
|
||||
data={"display_mode": "crop_faces"}, # valid for photos, not offered for static
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
cfg = db_session.get(StaticWidgetConfig, widget.id)
|
||||
assert cfg.display_mode == "crop_fill"
|
||||
|
||||
|
||||
def test_config_save_only_partially_updates_provided_fields(client, db_session):
|
||||
"""Fields not present in the POST are left untouched -- the whole
|
||||
point of the partial-update convention (each dialog's own form only
|
||||
@@ -200,3 +261,72 @@ def test_queue_400s_when_widget_is_not_photos(client, db_session):
|
||||
widget = _add_calendar_widget(db_session)
|
||||
resp = client.get(f"/api/frames/1/widgets/{widget.id}/queue")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# --- static image: upload/preview ------------------------------------------
|
||||
|
||||
def test_static_upload_decodes_and_stores_the_image(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget = _add_static_widget(db_session)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/frames/1/widgets/{widget.id}/static-upload",
|
||||
files={"file": ("photo.png", _png_bytes(size=(20, 10)), "image/png")},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["filename"] == "photo.png"
|
||||
|
||||
cfg = db_session.get(StaticWidgetConfig, widget.id)
|
||||
assert cfg.image is not None
|
||||
assert Image.open(io.BytesIO(cfg.image)).size == (20, 10)
|
||||
assert cfg.original_filename == "photo.png"
|
||||
assert cfg.uploaded_at > 0
|
||||
|
||||
|
||||
def test_static_upload_rejects_a_non_image_non_pdf_file(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget = _add_static_widget(db_session)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/frames/1/widgets/{widget.id}/static-upload",
|
||||
files={"file": ("notes.txt", b"just some text", "text/plain")},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
cfg = db_session.get(StaticWidgetConfig, widget.id)
|
||||
assert cfg.image is None
|
||||
|
||||
|
||||
def test_static_upload_400s_when_widget_is_not_static(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget = _add_calendar_widget(db_session)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/frames/1/widgets/{widget.id}/static-upload",
|
||||
files={"file": ("photo.png", _png_bytes(), "image/png")},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_preview_static_400s_before_anything_is_uploaded(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget = _add_static_widget(db_session)
|
||||
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/static")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_preview_static_renders_after_upload(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget = _add_static_widget(db_session)
|
||||
client.post(
|
||||
f"/api/frames/1/widgets/{widget.id}/static-upload",
|
||||
files={"file": ("photo.png", _png_bytes(), "image/png")},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
|
||||
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/static")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.headers["content-type"] == "image/png"
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""app.widgets.static_image -- unit-level, no HTTP: constructs Widget/
|
||||
StaticWidgetConfig rows directly. The decode-at-upload-time path (see
|
||||
app/image_upload.py) is covered separately in test_image_upload.py and
|
||||
the HTTP-level upload endpoint in
|
||||
test_widget_config_and_queue_endpoints.py -- these tests only exercise
|
||||
render(), which always starts from already-decoded PNG bytes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import time
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from app import widgets
|
||||
from app.models import Frame, StaticWidgetConfig, Widget
|
||||
|
||||
|
||||
def _png_bytes(size=(40, 30), color=(200, 50, 50)) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", size, color).save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _make_widget(db_session, **cfg_kwargs) -> tuple[Frame, Widget]:
|
||||
frame = db_session.get(Frame, 1)
|
||||
widget = Widget(frame_id=frame.id, widget_type="static", x=0, y=0, w=2, h=2,
|
||||
sort_order=0, created_at=time.time())
|
||||
db_session.add(widget)
|
||||
db_session.flush()
|
||||
db_session.add(StaticWidgetConfig(widget_id=widget.id, **cfg_kwargs))
|
||||
db_session.commit()
|
||||
return frame, widget
|
||||
|
||||
|
||||
def test_render_shows_a_placeholder_when_no_image_uploaded(db_session):
|
||||
frame, widget = _make_widget(db_session)
|
||||
img = widgets.static_image.render(db_session, frame, widget, 300, 200)
|
||||
assert img.size == (300, 200)
|
||||
assert img.mode == "RGB"
|
||||
|
||||
|
||||
def test_render_composes_the_uploaded_image(db_session):
|
||||
frame, widget = _make_widget(db_session, image=_png_bytes())
|
||||
img = widgets.static_image.render(db_session, frame, widget, 300, 200)
|
||||
assert img.size == (300, 200)
|
||||
assert img.mode == "RGB"
|
||||
|
||||
|
||||
def test_render_respects_display_mode_stretch_fill(db_session):
|
||||
frame, widget = _make_widget(db_session, image=_png_bytes(), display_mode="stretch_fill")
|
||||
img = widgets.static_image.render(db_session, frame, widget, 300, 200)
|
||||
assert img.size == (300, 200)
|
||||
|
||||
|
||||
def test_render_respects_display_mode_letterbox(db_session):
|
||||
frame, widget = _make_widget(db_session, image=_png_bytes(size=(100, 10)), display_mode="letterbox")
|
||||
img = widgets.static_image.render(db_session, frame, widget, 300, 200)
|
||||
assert img.size == (300, 200)
|
||||
|
||||
|
||||
def test_render_at_minimum_grid_footprint(db_session):
|
||||
"""grid.MIN_FOOTPRINT["static"] is (1, 1) cells -- on an 8x5 grid
|
||||
against a full 800x480 panel that's a 100x96 box, the smallest a
|
||||
static widget can actually be placed at."""
|
||||
frame, widget = _make_widget(db_session, image=_png_bytes())
|
||||
img = widgets.static_image.render(db_session, frame, widget, 100, 96)
|
||||
assert img.size == (100, 96)
|
||||
|
||||
|
||||
def test_no_button_actions():
|
||||
"""A fixed uploaded image -- nothing to advance/back/check."""
|
||||
assert widgets.static_image.ACTIONS == {}
|
||||
assert widgets.static_image.ACTION_LABELS == {}
|
||||
Reference in New Issue
Block a user