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`
|
`PhotoWidgetConfig`
|
||||||
mirrors `app/photo_queue.py`'s attribute names exactly, so that module's
|
mirrors `app/photo_queue.py`'s attribute names exactly, so that module's
|
||||||
advance/back/queue logic ports across widget instances unchanged.
|
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
|
`TaskWidgetConfig` used to be a handful of `tasks_*` columns bolted onto
|
||||||
`CalendarWidgetConfig` (a week-view-only, single-list task list); split
|
`CalendarWidgetConfig` (a week-view-only, single-list task list); split
|
||||||
into its own widget type (migration 17) so a task list can be placed
|
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"))
|
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 = [
|
MIGRATIONS = [
|
||||||
(1, _migration_1),
|
(1, _migration_1),
|
||||||
(2, _migration_2),
|
(2, _migration_2),
|
||||||
@@ -732,6 +752,7 @@ MIGRATIONS = [
|
|||||||
(24, _migration_24),
|
(24, _migration_24),
|
||||||
(25, _migration_25),
|
(25, _migration_25),
|
||||||
(26, _migration_26),
|
(26, _migration_26),
|
||||||
|
(27, _migration_27),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -483,6 +483,7 @@ class PhotoWidgetConfig(Base):
|
|||||||
queue_cursor: Mapped[int] = mapped_column(Integer, default=0)
|
queue_cursor: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
history: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
|
history: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
|
||||||
excluded_asset_ids: 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):
|
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
|
/api/queue), so without this an open browser tab polling overnight
|
||||||
would silently advance the current photo on raw elapsed time alone,
|
would silently advance the current photo on raw elapsed time alone,
|
||||||
even though the device itself is correctly asleep through the
|
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}
|
valid_ids = {a["id"] for a in assets}
|
||||||
needs_pick = not cfg.current_asset_id or cfg.current_asset_id not in valid_ids
|
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
|
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:
|
if not stale:
|
||||||
return False
|
return False
|
||||||
advance_forced(cfg, assets, frame)
|
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
|
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,
|
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,
|
"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(
|
def require_widget_view(
|
||||||
@@ -141,12 +142,21 @@ def api_widgets_list(request: Request, frame: Frame = Depends(require_frame_view
|
|||||||
widgets = db.scalars(
|
widgets = db.scalars(
|
||||||
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
|
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
|
||||||
).all()
|
).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 {
|
return {
|
||||||
"orientation": frame.orientation,
|
"orientation": frame.orientation,
|
||||||
"grid": {"cols": cols, "rows": rows},
|
"grid": {"cols": cols, "rows": rows},
|
||||||
"widget_types": list(WIDGET_TYPES.keys()),
|
"widget_types": list(WIDGET_TYPES.keys()),
|
||||||
"min_footprint": grid.MIN_FOOTPRINT,
|
"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": {
|
"control": {
|
||||||
"controller": (frame.controlled_by.display_name or frame.controlled_by.username) if frame.controlled_by else None,
|
"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,
|
"you": frame.controlled_by_user_id == user.id,
|
||||||
@@ -486,6 +496,7 @@ def api_widget_queue(
|
|||||||
photo_queue.sync_queue_length(locked_pcfg, assets)
|
photo_queue.sync_queue_length(locked_pcfg, assets)
|
||||||
current_asset_id = locked_pcfg.current_asset_id
|
current_asset_id = locked_pcfg.current_asset_id
|
||||||
queue = list(locked_pcfg.queue)
|
queue = list(locked_pcfg.queue)
|
||||||
|
locked = locked_pcfg.locked
|
||||||
controller_id = locked_frame.controlled_by_user_id
|
controller_id = locked_frame.controlled_by_user_id
|
||||||
controller = (
|
controller = (
|
||||||
(locked_frame.controlled_by.display_name or locked_frame.controlled_by.username)
|
(locked_frame.controlled_by.display_name or locked_frame.controlled_by.username)
|
||||||
@@ -498,6 +509,7 @@ def api_widget_queue(
|
|||||||
return {
|
return {
|
||||||
"current": entry(current_asset_id) if current_asset_id else None,
|
"current": entry(current_asset_id) if current_asset_id else None,
|
||||||
"upcoming": [entry(asset_id) for asset_id in queue],
|
"upcoming": [entry(asset_id) for asset_id in queue],
|
||||||
|
"locked": locked,
|
||||||
"control": {"controller": controller, "you": controller_id == user.id},
|
"control": {"controller": controller, "you": controller_id == user.id},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -567,6 +579,26 @@ def api_widget_queue_remove(
|
|||||||
return {"status": "removed"}
|
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}")
|
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/thumbnail/{asset_id}")
|
||||||
def api_widget_thumbnail(
|
def api_widget_thumbnail(
|
||||||
asset_id: str, frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
|
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;
|
label.textContent = WIDGET_LABELS[widget.widget_type] || widget.widget_type;
|
||||||
box.appendChild(label);
|
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');
|
const settingsBtn = document.createElement('button');
|
||||||
settingsBtn.type = 'button';
|
settingsBtn.type = 'button';
|
||||||
settingsBtn.className = 'widget-box-settings';
|
settingsBtn.className = 'widget-box-settings';
|
||||||
@@ -343,6 +351,7 @@ document.getElementById('widget-dialog').addEventListener('close', () => {
|
|||||||
openDialogWidgetType = null;
|
openDialogWidgetType = null;
|
||||||
window.FRAME_API = window.FRAME_BASE_API;
|
window.FRAME_API = window.FRAME_BASE_API;
|
||||||
document.getElementById('widget-dialog-body').innerHTML = '';
|
document.getElementById('widget-dialog-body').innerHTML = '';
|
||||||
|
loadWidgets(); // picks up anything the dialog changed that the canvas shows (e.g. the lock badge)
|
||||||
});
|
});
|
||||||
|
|
||||||
loadWidgets();
|
loadWidgets();
|
||||||
|
|||||||
@@ -477,6 +477,20 @@ button.secondary:hover { background: var(--surface-alt); }
|
|||||||
.widget-box-remove { right: 4px; }
|
.widget-box-remove { right: 4px; }
|
||||||
.widget-box-settings { right: 28px; }
|
.widget-box-settings { right: 28px; }
|
||||||
.widget-box-remove:hover, .widget-box-settings:hover { background: var(--overlay-hover); }
|
.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 {
|
.widget-box-resize-handle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
|
|||||||
@@ -8,6 +8,33 @@
|
|||||||
// FRAME_API + a global loadQueue()" contract queue.js has always had.
|
// FRAME_API + a global loadQueue()" contract queue.js has always had.
|
||||||
|
|
||||||
let photosPollTimer = null;
|
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() {
|
async function loadQueue() {
|
||||||
if (dragState) {
|
if (dragState) {
|
||||||
@@ -21,9 +48,14 @@ async function loadQueue() {
|
|||||||
currentEl.innerHTML =
|
currentEl.innerHTML =
|
||||||
'<p class="sub">Not available yet -- the owner needs to connect Immich (Settings) and pick an album.</p>';
|
'<p class="sub">Not available yet -- the owner needs to connect Immich (Settings) and pick an album.</p>';
|
||||||
renderUpcoming([]);
|
renderUpcoming([]);
|
||||||
|
photoHasCurrent = false;
|
||||||
|
renderLockButton();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
|
photoLocked = !!data.locked;
|
||||||
|
photoHasCurrent = !!data.current;
|
||||||
|
renderLockButton();
|
||||||
currentEl.innerHTML = '';
|
currentEl.innerHTML = '';
|
||||||
if (data.current) {
|
if (data.current) {
|
||||||
const wrap = document.createElement('div');
|
const wrap = document.createElement('div');
|
||||||
@@ -109,6 +141,8 @@ function initPhotosDialog() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById('lock-photo-btn').addEventListener('click', toggleLock);
|
||||||
|
|
||||||
loadQueue();
|
loadQueue();
|
||||||
// Slow poll: picks up real changes (new photo displayed, queue edited
|
// Slow poll: picks up real changes (new photo displayed, queue edited
|
||||||
// from elsewhere) without a manual refresh. Skipped mid-drag.
|
// from elsewhere) without a manual refresh. Skipped mid-drag.
|
||||||
|
|||||||
@@ -46,6 +46,10 @@
|
|||||||
<section class="card" style="margin-top: 20px;">
|
<section class="card" style="margin-top: 20px;">
|
||||||
<h2 class="card-title">Now displaying</h2>
|
<h2 class="card-title">Now displaying</h2>
|
||||||
<div id="current-thumb"><p class="sub">Loading...</p></div>
|
<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>
|
||||||
|
|
||||||
<section class="card" style="margin-top: 20px;">
|
<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:
|
def _advance(db: Session, frame: Frame, widget: Widget) -> None:
|
||||||
cfg = db.get(PhotoWidgetConfig, widget.id)
|
cfg = db.get(PhotoWidgetConfig, widget.id)
|
||||||
if not cfg.album_id:
|
if not cfg.album_id or cfg.locked:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
client = immich_client_for(frame)
|
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:
|
def _back(db: Session, frame: Frame, widget: Widget) -> None:
|
||||||
cfg = db.get(PhotoWidgetConfig, widget.id)
|
cfg = db.get(PhotoWidgetConfig, widget.id)
|
||||||
if not cfg.album_id:
|
if not cfg.album_id or cfg.locked:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
client = immich_client_for(frame)
|
client = immich_client_for(frame)
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ def test_expected_columns_exist_on_current_schema():
|
|||||||
assert "mode" in battery_widget_columns
|
assert "mode" in battery_widget_columns
|
||||||
widget_columns = {c["name"] for c in inspector.get_columns("widgets")}
|
widget_columns = {c["name"] for c in inspector.get_columns("widgets")}
|
||||||
assert {"border_style", "border_thickness", "border_color_index"} <= widget_columns # migration 26
|
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) ---
|
# --- 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.status_code == 200, resp.text
|
||||||
assert resp.json() == {
|
assert resp.json() == {
|
||||||
"id": widget_id, "widget_type": "photos", "x": 0, "y": 0, "w": 8, "h": 5, "sort_order": 0,
|
"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)
|
widget = db_session.get(Widget, widget_id)
|
||||||
assert (widget.border_style, widget.border_thickness, widget.border_color_index) == ("dashed", 5, 3)
|
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 == ""
|
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():
|
def test_advance_uses_the_frame_stats_counter_not_the_widget_config():
|
||||||
"""advance_forced's frame/stats split (see app/photo_queue.py) means
|
"""advance_forced's frame/stats split (see app/photo_queue.py) means
|
||||||
stats_photos_displayed should land on the Frame row, never on
|
stats_photos_displayed should land on the Frame row, never on
|
||||||
|
|||||||
Reference in New Issue
Block a user