Task lists used to be a week-view-only sub-feature bolted onto calendar widgets (CalendarWidgetConfig.tasks_*), so a task list could only exist tied to a calendar's view and only inside its footprint. Tasks are now a standalone widget type (TaskWidgetConfig, app/widgets/tasks.py) that can be placed and sized independently, same as photos/calendar/ whiteboard -- no separate "enabled" flag either, since being on the grid at all is the on/off switch, matching every other widget type. Migration 17 creates task_widget_configs, extracts any existing calendar widget's configured task source into a new sibling tasks widget (auto-placed in open grid space, source dropped+logged if truly none is left), then drops calendar_widget_configs' now-dead tasks_* columns in the same migration -- this project's usual same-migration- drop convention. Also handles the rarer case of a database jumping straight from before the widget system existed to after this split in one boot, via the legacy Frame.calendar_tasks_* columns. Verified live in the browser at desktop and mobile widths: adding a Tasks widget, its own dialog (task-list source picker + preview), and confirming the calendar widget's dialog no longer mentions tasks at all. Full test suite (180 tests, including new coverage for the widget render/actions, the migration's data-extraction path, and the permission-boundary shape for tasks-source) passes.
119 lines
4.5 KiB
Python
119 lines
4.5 KiB
Python
"""Engine, sessions, and the per-frame lock that replaces the old
|
|
whole-config.json RLock.
|
|
|
|
Single uvicorn worker (see Dockerfile) -- handlers are sync and run in
|
|
the threadpool, so this is ordinary multi-threading in one process: the
|
|
same regime the old config.locked() RLock handled, now scoped per frame.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import threading
|
|
from contextlib import contextmanager
|
|
from typing import Iterator
|
|
|
|
from sqlalchemy import create_engine, event
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|
|
|
from .models import WIDGET_CONFIG_MODELS, Frame, Widget
|
|
|
|
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:////data/espresso.db")
|
|
|
|
_is_sqlite = DATABASE_URL.startswith("sqlite")
|
|
|
|
engine = create_engine(
|
|
DATABASE_URL,
|
|
connect_args={"check_same_thread": False} if _is_sqlite else {},
|
|
)
|
|
|
|
if _is_sqlite:
|
|
|
|
@event.listens_for(engine, "connect")
|
|
def _sqlite_pragmas(dbapi_connection, _record):
|
|
cursor = dbapi_connection.cursor()
|
|
cursor.execute("PRAGMA journal_mode=WAL")
|
|
cursor.execute("PRAGMA foreign_keys=ON")
|
|
cursor.execute("PRAGMA busy_timeout=5000")
|
|
cursor.close()
|
|
|
|
|
|
# expire_on_commit=False so a Frame resolved by the require_device
|
|
# dependency (which commits its last_seen touch) stays usable in the
|
|
# route handler without a re-select per attribute. Freshness inside
|
|
# mutation spans is handled explicitly by frame_locked()'s refresh.
|
|
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
|
|
|
|
|
def get_db() -> Iterator[Session]:
|
|
"""FastAPI dependency: one session per request (FastAPI caches the
|
|
dependency, so require_device and the route handler share it)."""
|
|
db = SessionLocal()
|
|
try:
|
|
yield db
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
# One lock per frame id, created on demand. Guarded by a module lock so
|
|
# two threads can't race to create different Lock objects for the same
|
|
# frame (which would defeat the whole point).
|
|
_frame_locks: dict[int, threading.Lock] = {}
|
|
_frame_locks_guard = threading.Lock()
|
|
|
|
|
|
def _get_lock(frame_id: int) -> threading.Lock:
|
|
with _frame_locks_guard:
|
|
lock = _frame_locks.get(frame_id)
|
|
if lock is None:
|
|
lock = threading.Lock()
|
|
_frame_locks[frame_id] = lock
|
|
return lock
|
|
|
|
|
|
@contextmanager
|
|
def frame_locked(db: Session, frame_id: int) -> Iterator[Frame]:
|
|
"""Serializes a whole read-modify-write span on one frame -- the
|
|
direct successor of the old config.locked(). The refresh() inside the
|
|
lock is what makes it correct: without it the session could hold
|
|
attribute state read *before* another thread's committed write, and
|
|
saving would silently clobber it (the same lost-update race the old
|
|
pattern's 're-read inside the lock' comment guarded against)."""
|
|
with _get_lock(frame_id):
|
|
frame = db.get(Frame, frame_id)
|
|
if frame is None:
|
|
raise LookupError(f"Frame {frame_id} does not exist")
|
|
db.refresh(frame)
|
|
yield frame
|
|
db.commit()
|
|
|
|
|
|
@contextmanager
|
|
def widget_locked(db: Session, frame_id: int, widget_id: int) -> Iterator[tuple[Frame, Widget, object]]:
|
|
"""Same lock/refresh/commit dance as frame_locked, additionally
|
|
resolving and refreshing the widget's own per-type config row
|
|
(PhotoWidgetConfig/CalendarWidgetConfig/WhiteboardWidgetConfig/
|
|
TaskWidgetConfig, see models.WIDGET_CONFIG_MODELS). Deliberately
|
|
still locks at *frame* granularity -- the exact same per-frame
|
|
threading.Lock frame_locked
|
|
uses, not a separate per-widget lock -- simplest, avoids a new class
|
|
of multi-lock deadlock bugs, and this project's actual concurrency
|
|
needs are tiny (a handful of users per household frame).
|
|
|
|
threading.Lock is not reentrant: a caller executing several widget
|
|
actions in one pass (e.g. a button press assigned multiple
|
|
(widget, action) pairs, see routers/device.py) MUST call this once
|
|
per action, sequentially, never nested inside an outer
|
|
frame_locked/widget_locked span for the same frame -- nesting would
|
|
deadlock instantly, not just misbehave."""
|
|
with frame_locked(db, frame_id) as frame:
|
|
widget = db.get(Widget, widget_id)
|
|
if widget is None or widget.frame_id != frame_id:
|
|
raise LookupError(f"Widget {widget_id} does not belong to frame {frame_id}")
|
|
config_model = WIDGET_CONFIG_MODELS[widget.widget_type]
|
|
config = db.get(config_model, widget_id)
|
|
if config is None:
|
|
raise LookupError(f"Widget {widget_id} has no {widget.widget_type} config row")
|
|
db.refresh(config)
|
|
yield frame, widget, config
|