Replaces the Photos/Calendar/Whiteboard tabs with a single Layout page
(now the frame's landing route) where each widget gets a gear icon
opening a dialog scoped to that specific widget's own settings. This
was the missing piece for genuinely independent same-type widgets --
"the Calendar tab" never made sense once a frame could hold more than
one calendar widget with different settings.
Data layer: FrameCalendar re-keyed from frame_id to widget_id, so each
calendar widget has its own independent included-calendars set. The
rekey runs as an unconditional post-startup step (like the existing
widget backfill), not a numbered migration -- it depends on calendar
widgets already existing, which themselves come from that same
backfill step, not from schema migration. Registering it as a numbered
migration would have run it first during a real upgrade, silently
dropping every row; caught by a new test that exercises the raw-SQL
upgrade path instead of the fresh-install create_all() shortcut every
other migration test takes.
API layer: every endpoint that used to assume "the frame's widget of
this type" (photo queue/thumbnail/preview, calendar select/color/
tasks/weather, whiteboard source/browse/preview) moved into
api_widgets.py under /api/frames/{id}/widgets/{widget_id}/..., with a
new require_widget_view/control dependency pair mirroring the existing
frame-level ones. Device status (battery/last-seen/firmware) got its
own frame-level /status endpoint, split out of the old photo-specific
/queue it used to piggyback on -- fixes the status bar going silently
blank on any frame without a photo widget.
UI layer: each widget type's existing settings markup/JS was ported
into a dialog partial + an explicit init/close function pair (the
content is now fetched and injected on demand, not loaded at page load
time). window.FRAME_API is repointed to the open dialog's widget-scoped
API base for its duration and restored on close; a separate
window.FRAME_BASE_API stays stable for the always-present header/
status-bar scripts.
Caught during manual browser testing: the consolidated config-save
endpoint initially expected a JSON body while the copied-over dialog JS
posts form-urlencoded data (the old convention) -- fixed to match, with
new HTTP-level test coverage that would have caught it immediately.
72 lines
3.0 KiB
Python
72 lines
3.0 KiB
Python
"""GET /api/frames/{id}/widgets/{widget_id}/preview/calendar -- the
|
|
calendar dialog's live render preview. No prior coverage existed for
|
|
this endpoint; added after a Phase 3 refactor (calendar_render.py's
|
|
size-tier rewrite, see the widget-system plan) left a stale
|
|
photo_inlay=None kwarg here that would have TypeError'd on the very next
|
|
request -- nothing in the existing suite actually called this endpoint
|
|
to catch it."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
|
|
from app.models import CalendarWidgetConfig, Frame, FrameCalendar, Widget
|
|
|
|
from .conftest import csrf_headers
|
|
|
|
|
|
def _configure_calendar_widget(db_session) -> Widget:
|
|
client_frame = db_session.get(Frame, 1)
|
|
widget = Widget(frame_id=client_frame.id, widget_type="calendar", x=0, y=0, w=8, h=5,
|
|
sort_order=1, created_at=time.time())
|
|
db_session.add(widget)
|
|
db_session.flush()
|
|
db_session.add(CalendarWidgetConfig(widget_id=widget.id, view="agenda"))
|
|
db_session.commit()
|
|
return widget
|
|
|
|
|
|
def _photo_widget_id(db_session) -> int:
|
|
return db_session.query(Widget).filter_by(frame_id=1, widget_type="photos").one().id
|
|
|
|
|
|
def test_preview_calendar_404s_for_a_widget_id_that_does_not_exist(client, db_session):
|
|
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
|
resp = client.get("/api/frames/1/widgets/999999/preview/calendar")
|
|
assert resp.status_code == 404
|
|
|
|
|
|
def test_preview_calendar_400s_when_widget_is_not_a_calendar(client, db_session):
|
|
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
|
photo_widget_id = _photo_widget_id(db_session)
|
|
resp = client.get(f"/api/frames/1/widgets/{photo_widget_id}/preview/calendar")
|
|
assert resp.status_code == 400
|
|
|
|
|
|
def test_preview_calendar_requires_an_included_calendar(client, db_session):
|
|
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
|
widget = _configure_calendar_widget(db_session)
|
|
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/calendar")
|
|
assert resp.status_code == 400
|
|
assert "calendar" in resp.json()["detail"].lower()
|
|
|
|
|
|
def test_preview_calendar_renders_a_png(client, db_session, monkeypatch):
|
|
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
|
widget = _configure_calendar_widget(db_session)
|
|
alice = db_session.get(Frame, 1).owner
|
|
alice.calendar_ics_url = "http://example.invalid/alice.ics"
|
|
db_session.add(FrameCalendar(widget_id=widget.id, user_id=alice.id, calendar_key="ics",
|
|
calendar_label="My calendar", included=True))
|
|
db_session.commit()
|
|
|
|
monkeypatch.setattr(
|
|
"app.routers.api_widgets.get_or_refresh_calendar_events_for_widget",
|
|
lambda db, frame, widget: ([], ""),
|
|
)
|
|
|
|
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/calendar", headers=csrf_headers(client))
|
|
assert resp.status_code == 200, resp.text
|
|
assert resp.headers["content-type"] == "image/png"
|
|
assert resp.content[:8] == b"\x89PNG\r\n\x1a\n"
|