Add per-photo-widget lock (freezes current photo until unlocked)
A "Lock this photo" button in the photos widget's dialog toggles PhotoWidgetConfig.locked, which suppresses both the timer-elapsed auto-advance and the advance/back button actions until unlocked. The Layout tab canvas shows a lock badge on any locked photo widget's box.
This commit is contained in:
@@ -54,6 +54,13 @@ a button press does.
|
||||
`PhotoWidgetConfig`
|
||||
mirrors `app/photo_queue.py`'s attribute names exactly, so that module's
|
||||
advance/back/queue logic ports across widget instances unchanged.
|
||||
`PhotoWidgetConfig.locked` (migration 27) freezes `current_asset_id`
|
||||
against both the timer-elapsed auto-advance
|
||||
(`photo_queue.get_current`) and the advance/back button actions
|
||||
(`app/widgets/photos.py`'s `ACTIONS`) until unlocked -- toggled via a
|
||||
"Lock this photo" button in the widget's own dialog
|
||||
(`POST .../widgets/{id}/lock`), shown as a lock badge on the widget's
|
||||
box on the Layout tab canvas.
|
||||
`TaskWidgetConfig` used to be a handful of `tasks_*` columns bolted onto
|
||||
`CalendarWidgetConfig` (a week-view-only, single-list task list); split
|
||||
into its own widget type (migration 17) so a task list can be placed
|
||||
|
||||
@@ -705,6 +705,26 @@ def _migration_26(conn) -> None:
|
||||
conn.execute(text("ALTER TABLE widgets ADD COLUMN border_color_index INTEGER NOT NULL DEFAULT 0"))
|
||||
|
||||
|
||||
def _migration_27(conn) -> None:
|
||||
"""Per-photo-widget lock (models.PhotoWidgetConfig.locked) -- freezes
|
||||
current_asset_id against both the timer-elapsed auto-advance
|
||||
(photo_queue.get_current) and the advance/back button actions
|
||||
(app/widgets/photos.py's ACTIONS) until unlocked. Defaults to
|
||||
unlocked so existing widgets keep rotating exactly as before.
|
||||
|
||||
Guarded per-column, same reasoning as migration 26's own comment:
|
||||
photo_widget_configs isn't touched by test_migrations.py's simulated
|
||||
pre-widget-system replays (unlike calendar/task/widgets tables those
|
||||
tests DROP and recreate in an old shape), so it keeps the fresh-
|
||||
install create_all() copy -- which already has this column -- when
|
||||
those tests replay migrations 17+ from schema_version 16. Without
|
||||
the guard, replaying this migration there re-adds a column that's
|
||||
already there and SQLite raises "duplicate column name"."""
|
||||
existing = {c["name"] for c in inspect(conn).get_columns("photo_widget_configs")}
|
||||
if "locked" not in existing:
|
||||
conn.execute(text("ALTER TABLE photo_widget_configs ADD COLUMN locked INTEGER NOT NULL DEFAULT 0"))
|
||||
|
||||
|
||||
MIGRATIONS = [
|
||||
(1, _migration_1),
|
||||
(2, _migration_2),
|
||||
@@ -732,6 +752,7 @@ MIGRATIONS = [
|
||||
(24, _migration_24),
|
||||
(25, _migration_25),
|
||||
(26, _migration_26),
|
||||
(27, _migration_27),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -483,6 +483,7 @@ class PhotoWidgetConfig(Base):
|
||||
queue_cursor: Mapped[int] = mapped_column(Integer, default=0)
|
||||
history: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
|
||||
excluded_asset_ids: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
|
||||
locked: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
|
||||
class CalendarWidgetConfig(Base):
|
||||
|
||||
@@ -211,11 +211,18 @@ def get_current(cfg: Frame, assets: list[dict], frame: Frame, in_quiet_hours: bo
|
||||
/api/queue), so without this an open browser tab polling overnight
|
||||
would silently advance the current photo on raw elapsed time alone,
|
||||
even though the device itself is correctly asleep through the
|
||||
window (see main.py's _effective_refresh_interval_s)."""
|
||||
window (see main.py's _effective_refresh_interval_s).
|
||||
|
||||
cfg.locked suppresses the elapsed-time trigger the same way
|
||||
in_quiet_hours does -- a locked widget still needs an initial pick
|
||||
if it somehow has none (an unconfigured widget just locked, or a
|
||||
changed album), but once it has a current photo the whole point of
|
||||
locking is that it stops moving on its own until explicitly
|
||||
unlocked."""
|
||||
valid_ids = {a["id"] for a in assets}
|
||||
needs_pick = not cfg.current_asset_id or cfg.current_asset_id not in valid_ids
|
||||
time_elapsed = (time.time() - cfg.current_asset_set_at) >= frame.refresh_interval_s
|
||||
stale = needs_pick or (time_elapsed and not in_quiet_hours)
|
||||
stale = needs_pick or (time_elapsed and not in_quiet_hours and not cfg.locked)
|
||||
if not stale:
|
||||
return False
|
||||
advance_forced(cfg, assets, frame)
|
||||
|
||||
@@ -86,10 +86,11 @@ CALENDAR_COLOR_INDEX_RANGE = range(2, 6) # Yellow/Red/Blue/Green -- see PALETTE
|
||||
MAX_TASKS_NAME_LEN = 40 # a sane on-panel-header length, see calendar_render._draw_tasks
|
||||
|
||||
|
||||
def _widget_dict(w: Widget) -> dict:
|
||||
def _widget_dict(w: Widget, locked: bool = False) -> dict:
|
||||
return {"id": w.id, "widget_type": w.widget_type, "x": w.x, "y": w.y, "w": w.w, "h": w.h,
|
||||
"sort_order": w.sort_order, "border_style": w.border_style,
|
||||
"border_thickness": w.border_thickness, "border_color_index": w.border_color_index}
|
||||
"border_thickness": w.border_thickness, "border_color_index": w.border_color_index,
|
||||
"locked": locked}
|
||||
|
||||
|
||||
def require_widget_view(
|
||||
@@ -141,12 +142,21 @@ def api_widgets_list(request: Request, frame: Frame = Depends(require_frame_view
|
||||
widgets = db.scalars(
|
||||
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
|
||||
).all()
|
||||
# Layout canvas needs to know which photo widgets are locked (to draw
|
||||
# the lock badge) -- a per-type config field, not on Widget itself,
|
||||
# so it's a separate lookup rather than something _widget_dict can
|
||||
# read straight off the row it's given.
|
||||
photo_widget_ids = [w.id for w in widgets if w.widget_type == "photos"]
|
||||
locked_by_widget_id = dict(db.execute(
|
||||
select(PhotoWidgetConfig.widget_id, PhotoWidgetConfig.locked)
|
||||
.where(PhotoWidgetConfig.widget_id.in_(photo_widget_ids))
|
||||
).all()) if photo_widget_ids else {}
|
||||
return {
|
||||
"orientation": frame.orientation,
|
||||
"grid": {"cols": cols, "rows": rows},
|
||||
"widget_types": list(WIDGET_TYPES.keys()),
|
||||
"min_footprint": grid.MIN_FOOTPRINT,
|
||||
"widgets": [_widget_dict(w) for w in widgets],
|
||||
"widgets": [_widget_dict(w, locked_by_widget_id.get(w.id, False)) for w in widgets],
|
||||
"control": {
|
||||
"controller": (frame.controlled_by.display_name or frame.controlled_by.username) if frame.controlled_by else None,
|
||||
"you": frame.controlled_by_user_id == user.id,
|
||||
@@ -486,6 +496,7 @@ def api_widget_queue(
|
||||
photo_queue.sync_queue_length(locked_pcfg, assets)
|
||||
current_asset_id = locked_pcfg.current_asset_id
|
||||
queue = list(locked_pcfg.queue)
|
||||
locked = locked_pcfg.locked
|
||||
controller_id = locked_frame.controlled_by_user_id
|
||||
controller = (
|
||||
(locked_frame.controlled_by.display_name or locked_frame.controlled_by.username)
|
||||
@@ -498,6 +509,7 @@ def api_widget_queue(
|
||||
return {
|
||||
"current": entry(current_asset_id) if current_asset_id else None,
|
||||
"upcoming": [entry(asset_id) for asset_id in queue],
|
||||
"locked": locked,
|
||||
"control": {"controller": controller, "you": controller_id == user.id},
|
||||
}
|
||||
|
||||
@@ -567,6 +579,26 @@ def api_widget_queue_remove(
|
||||
return {"status": "removed"}
|
||||
|
||||
|
||||
class QueueLockRequest(BaseModel):
|
||||
locked: bool
|
||||
|
||||
|
||||
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/lock")
|
||||
def api_widget_queue_lock(
|
||||
body: QueueLockRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Freezes/unfreezes current_asset_id (models.PhotoWidgetConfig.locked)
|
||||
-- while locked, neither the timer-elapsed auto-advance
|
||||
(photo_queue.get_current) nor the advance/back button actions
|
||||
(app/widgets/photos.py) change which photo is showing."""
|
||||
frame, widget = frame_widget
|
||||
_require_widget_type(widget, "photos")
|
||||
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
|
||||
cfg.locked = body.locked
|
||||
return {"status": "saved", "locked": body.locked}
|
||||
|
||||
|
||||
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/thumbnail/{asset_id}")
|
||||
def api_widget_thumbnail(
|
||||
asset_id: str, frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
|
||||
|
||||
@@ -210,6 +210,14 @@ function renderCanvas() {
|
||||
label.textContent = WIDGET_LABELS[widget.widget_type] || widget.widget_type;
|
||||
box.appendChild(label);
|
||||
|
||||
if (widget.locked) {
|
||||
const lockBadge = document.createElement('span');
|
||||
lockBadge.className = 'widget-box-lock-badge';
|
||||
lockBadge.textContent = '\u{1F512}'; // lock emoji -- open the gear icon to unlock
|
||||
lockBadge.title = 'Locked -- won\'t change until unlocked in this widget\'s settings';
|
||||
box.appendChild(lockBadge);
|
||||
}
|
||||
|
||||
const settingsBtn = document.createElement('button');
|
||||
settingsBtn.type = 'button';
|
||||
settingsBtn.className = 'widget-box-settings';
|
||||
@@ -343,6 +351,7 @@ document.getElementById('widget-dialog').addEventListener('close', () => {
|
||||
openDialogWidgetType = null;
|
||||
window.FRAME_API = window.FRAME_BASE_API;
|
||||
document.getElementById('widget-dialog-body').innerHTML = '';
|
||||
loadWidgets(); // picks up anything the dialog changed that the canvas shows (e.g. the lock badge)
|
||||
});
|
||||
|
||||
loadWidgets();
|
||||
|
||||
@@ -477,6 +477,20 @@ button.secondary:hover { background: var(--surface-alt); }
|
||||
.widget-box-remove { right: 4px; }
|
||||
.widget-box-settings { right: 28px; }
|
||||
.widget-box-remove:hover, .widget-box-settings:hover { background: var(--overlay-hover); }
|
||||
.widget-box-lock-badge {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
left: 4px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--overlay);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
line-height: 20px;
|
||||
text-align: center;
|
||||
pointer-events: none; /* passive indicator, not a control -- toggled from the widget's own dialog */
|
||||
}
|
||||
.widget-box-resize-handle {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
|
||||
@@ -8,6 +8,33 @@
|
||||
// FRAME_API + a global loadQueue()" contract queue.js has always had.
|
||||
|
||||
let photosPollTimer = null;
|
||||
let photoLocked = false;
|
||||
let photoHasCurrent = false;
|
||||
|
||||
function renderLockButton() {
|
||||
const btn = document.getElementById('lock-photo-btn');
|
||||
if (!btn) return;
|
||||
btn.textContent = photoLocked ? 'Unlock this photo' : 'Lock this photo';
|
||||
btn.classList.toggle('active', photoLocked);
|
||||
btn.disabled = !photoHasCurrent && !photoLocked; // nothing displayed yet to lock
|
||||
}
|
||||
|
||||
async function toggleLock() {
|
||||
const next = !photoLocked;
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/lock`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ locked: next }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
photoLocked = next;
|
||||
renderLockButton();
|
||||
showStatus(true, photoLocked ? 'Locked -- this photo will stay put.' : 'Unlocked.');
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadQueue() {
|
||||
if (dragState) {
|
||||
@@ -21,9 +48,14 @@ async function loadQueue() {
|
||||
currentEl.innerHTML =
|
||||
'<p class="sub">Not available yet -- the owner needs to connect Immich (Settings) and pick an album.</p>';
|
||||
renderUpcoming([]);
|
||||
photoHasCurrent = false;
|
||||
renderLockButton();
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
photoLocked = !!data.locked;
|
||||
photoHasCurrent = !!data.current;
|
||||
renderLockButton();
|
||||
currentEl.innerHTML = '';
|
||||
if (data.current) {
|
||||
const wrap = document.createElement('div');
|
||||
@@ -109,6 +141,8 @@ function initPhotosDialog() {
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('lock-photo-btn').addEventListener('click', toggleLock);
|
||||
|
||||
loadQueue();
|
||||
// Slow poll: picks up real changes (new photo displayed, queue edited
|
||||
// from elsewhere) without a manual refresh. Skipped mid-drag.
|
||||
|
||||
@@ -46,6 +46,10 @@
|
||||
<section class="card" style="margin-top: 20px;">
|
||||
<h2 class="card-title">Now displaying</h2>
|
||||
<div id="current-thumb"><p class="sub">Loading...</p></div>
|
||||
<button type="button" class="secondary" id="lock-photo-btn" style="margin-top: 8px;">Lock this photo</button>
|
||||
<p class="sub" style="margin-top: 4px;">While locked, this photo stays
|
||||
on screen -- the refresh timer and the next/back buttons won't
|
||||
change it until you unlock it.</p>
|
||||
</section>
|
||||
|
||||
<section class="card" style="margin-top: 20px;">
|
||||
|
||||
@@ -55,7 +55,7 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
|
||||
|
||||
def _advance(db: Session, frame: Frame, widget: Widget) -> None:
|
||||
cfg = db.get(PhotoWidgetConfig, widget.id)
|
||||
if not cfg.album_id:
|
||||
if not cfg.album_id or cfg.locked:
|
||||
return
|
||||
try:
|
||||
client = immich_client_for(frame)
|
||||
@@ -68,7 +68,7 @@ def _advance(db: Session, frame: Frame, widget: Widget) -> None:
|
||||
|
||||
def _back(db: Session, frame: Frame, widget: Widget) -> None:
|
||||
cfg = db.get(PhotoWidgetConfig, widget.id)
|
||||
if not cfg.album_id:
|
||||
if not cfg.album_id or cfg.locked:
|
||||
return
|
||||
try:
|
||||
client = immich_client_for(frame)
|
||||
|
||||
@@ -87,6 +87,8 @@ def test_expected_columns_exist_on_current_schema():
|
||||
assert "mode" in battery_widget_columns
|
||||
widget_columns = {c["name"] for c in inspector.get_columns("widgets")}
|
||||
assert {"border_style", "border_thickness", "border_color_index"} <= widget_columns # migration 26
|
||||
photo_widget_columns = {c["name"] for c in inspector.get_columns("photo_widget_configs")}
|
||||
assert "locked" in photo_widget_columns # migration 27
|
||||
|
||||
|
||||
# --- widget system backfill (migration 16 + _ensure_widgets_backfilled) ---
|
||||
|
||||
@@ -37,7 +37,7 @@ def test_set_border_persists(client, db_session):
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json() == {
|
||||
"id": widget_id, "widget_type": "photos", "x": 0, "y": 0, "w": 8, "h": 5, "sort_order": 0,
|
||||
"border_style": "dashed", "border_thickness": 5, "border_color_index": 3,
|
||||
"border_style": "dashed", "border_thickness": 5, "border_color_index": 3, "locked": False,
|
||||
}
|
||||
widget = db_session.get(Widget, widget_id)
|
||||
assert (widget.border_style, widget.border_thickness, widget.border_color_index) == ("dashed", 5, 3)
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""routers/api_widgets.py's POST .../lock endpoint (models.PhotoWidgetConfig.
|
||||
locked) -- same permission shape as test_widget_border.py's own endpoint,
|
||||
just for a photos-only field instead of a shared Widget-level one."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from app.models import Frame, PhotoWidgetConfig, StaticWidgetConfig, Widget
|
||||
|
||||
from .conftest import csrf_headers, link_user, login, make_user
|
||||
|
||||
|
||||
def _widget_id(db_session, widget_type="photos") -> int:
|
||||
return db_session.query(Widget).filter_by(frame_id=1, widget_type=widget_type).one().id
|
||||
|
||||
|
||||
def test_new_widget_defaults_to_unlocked(db_session):
|
||||
widget_id = _widget_id(db_session)
|
||||
assert db_session.get(PhotoWidgetConfig, widget_id).locked is False
|
||||
|
||||
|
||||
def test_set_lock_persists(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget_id = _widget_id(db_session)
|
||||
resp = client.post(f"/api/frames/1/widgets/{widget_id}/lock",
|
||||
json={"locked": True}, headers=csrf_headers(client))
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json() == {"status": "saved", "locked": True}
|
||||
assert db_session.get(PhotoWidgetConfig, widget_id).locked is True
|
||||
|
||||
resp = client.post(f"/api/frames/1/widgets/{widget_id}/lock",
|
||||
json={"locked": False}, headers=csrf_headers(client))
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert db_session.get(PhotoWidgetConfig, widget_id).locked is False
|
||||
|
||||
|
||||
def test_set_lock_404s_for_unknown_widget(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
resp = client.post("/api/frames/1/widgets/999999/lock",
|
||||
json={"locked": True}, headers=csrf_headers(client))
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_set_lock_rejects_non_photos_widget(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
# Built directly rather than through the placement-validated create
|
||||
# endpoint -- the default frame's photos widget already covers the
|
||||
# whole grid, and overlap isn't the thing under test here.
|
||||
static_widget = Widget(frame_id=1, widget_type="static", x=0, y=0, w=1, h=1,
|
||||
sort_order=99, created_at=time.time())
|
||||
db_session.add(static_widget)
|
||||
db_session.flush()
|
||||
db_session.add(StaticWidgetConfig(widget_id=static_widget.id))
|
||||
db_session.commit()
|
||||
|
||||
resp = client.post(f"/api/frames/1/widgets/{static_widget.id}/lock",
|
||||
json={"locked": True}, headers=csrf_headers(client))
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_set_lock_unrelated_user_404s(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
make_user(db_session, "mallory")
|
||||
widget_id = _widget_id(db_session)
|
||||
|
||||
client.cookies.clear()
|
||||
login(client, "mallory")
|
||||
resp = client.post(f"/api/frames/1/widgets/{widget_id}/lock",
|
||||
json={"locked": True}, headers=csrf_headers(client))
|
||||
assert resp.status_code == 404
|
||||
assert db_session.get(PhotoWidgetConfig, widget_id).locked is False
|
||||
|
||||
|
||||
def test_set_lock_linked_but_not_controlling_user_409s(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
bob = make_user(db_session, "bob")
|
||||
frame = db_session.get(Frame, 1)
|
||||
link_user(db_session, bob, frame)
|
||||
widget_id = _widget_id(db_session)
|
||||
|
||||
client.cookies.clear()
|
||||
login(client, "bob")
|
||||
resp = client.post(f"/api/frames/1/widgets/{widget_id}/lock",
|
||||
json={"locked": True}, headers=csrf_headers(client))
|
||||
assert resp.status_code == 409
|
||||
assert resp.json()["detail"]["error"] == "not_controller"
|
||||
|
||||
|
||||
def test_widgets_list_reports_locked_state(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget_id = _widget_id(db_session)
|
||||
resp = client.get("/api/frames/1/widgets")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert next(w for w in resp.json()["widgets"] if w["id"] == widget_id)["locked"] is False
|
||||
|
||||
client.post(f"/api/frames/1/widgets/{widget_id}/lock",
|
||||
json={"locked": True}, headers=csrf_headers(client))
|
||||
resp = client.get("/api/frames/1/widgets")
|
||||
assert next(w for w in resp.json()["widgets"] if w["id"] == widget_id)["locked"] is True
|
||||
@@ -109,6 +109,41 @@ def test_advance_action_is_a_no_op_when_unconfigured(db_session):
|
||||
assert cfg.current_asset_id == ""
|
||||
|
||||
|
||||
def test_render_does_not_advance_when_locked(db_session, monkeypatch):
|
||||
frame, widget = _make_widget(db_session)
|
||||
monkeypatch.setattr(widgets.photos, "immich_client_for", lambda frame: object())
|
||||
monkeypatch.setattr(widgets.photos, "list_assets", lambda client, album_id: _ASSETS)
|
||||
source = Image.new("RGB", (100, 80), (10, 20, 30))
|
||||
monkeypatch.setattr(widgets.photos, "fetch_source_and_faces", lambda client, mode, asset_id: (source, None))
|
||||
|
||||
with widget_locked(db_session, frame.id, widget.id) as (_, _, cfg):
|
||||
cfg.current_asset_id = "asset-1"
|
||||
cfg.current_asset_set_at = 0.0 # long ago -- refresh_interval_s has definitely elapsed
|
||||
cfg.locked = True
|
||||
|
||||
widgets.photos.render(db_session, frame, widget, 400, 300)
|
||||
|
||||
cfg = db_session.get(PhotoWidgetConfig, widget.id)
|
||||
assert cfg.current_asset_id == "asset-1"
|
||||
|
||||
|
||||
def test_advance_and_back_actions_are_no_ops_when_locked(db_session, monkeypatch):
|
||||
frame, widget = _make_widget(db_session)
|
||||
monkeypatch.setattr(widgets.photos, "immich_client_for", lambda frame: object())
|
||||
monkeypatch.setattr(widgets.photos, "list_assets", lambda client, album_id: _ASSETS)
|
||||
|
||||
with widget_locked(db_session, frame.id, widget.id) as (_, _, cfg):
|
||||
cfg.current_asset_id = "asset-1"
|
||||
cfg.locked = True
|
||||
|
||||
widgets.photos.ACTIONS["advance"](db_session, frame, widget)
|
||||
widgets.photos.ACTIONS["back"](db_session, frame, widget)
|
||||
|
||||
cfg = db_session.get(PhotoWidgetConfig, widget.id)
|
||||
assert cfg.current_asset_id == "asset-1"
|
||||
assert cfg.history == []
|
||||
|
||||
|
||||
def test_advance_uses_the_frame_stats_counter_not_the_widget_config():
|
||||
"""advance_forced's frame/stats split (see app/photo_queue.py) means
|
||||
stats_photos_displayed should land on the Frame row, never on
|
||||
|
||||
Reference in New Issue
Block a user