Add a static image widget (PNG/JPEG/GIF/BMP/WEBP/TIFF/PDF upload)
Build and push server image / test (push) Successful in 26s
Build and push server image / build-and-push (push) Successful in 2m1s
Build and push server image / deploy (push) Successful in 59s

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:
Thomas Faour
2026-07-25 08:39:09 +00:00
parent 4a2b1f3795
commit 35e80c6d1c
19 changed files with 618 additions and 8 deletions
+88
View File
@@ -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
+1
View File
@@ -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"
+74
View File
@@ -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 == {}