Files
siftlode/scripts/e2e_seed.py
T
peter 0cff964ff2 refactor(logs): name every logger after its module, and hold it there
25 literal names covered 34 modules. plex/client.py, plex/stream.py,
plex/subtitles.py, plex/sync.py and two Plex route modules all called themselves
`siftlode.plex`; downloads/pipeline.py called itself `siftlode.worker`;
routes/channels.py called itself `siftlode.api`. The `logger` column is in every
stored row so E22.6's console can filter on it, and so an operator can pin a level
to one part of the system — neither works when six modules share a name.

`logs.get_logger(__name__)` derives it: app/plex/sync.py -> siftlode.plex.sync.
One name per module, and it cannot drift from the file it sits in.
tests/test_logger_names.py pins the derivation, that everything stays under the
one `siftlode` tree the handler and the tests attach to, and that no module
hand-rolls a name again unless the line says why (`# logger-name-ok: <why>`).
Two do: get_logger's own call, and `siftlode.http`, which is named for the traffic
it carries rather than the module it lives in — it replaced uvicorn's access log
and is what an operator pins when they want request lines and nothing else.

app/scheduler.py called its logger `logger`; every other module calls it `log`.

ruff gains LOG, G, T20, TRY400, TRY401 and S112. Four findings cleared with them:
worker.py logged `str(exc)[:120]` where the traceback was the whole question, and
three prints/f-strings that are deliberate now say so on the line. S110 and BLE001
stay off until S3 — 23 handlers are outstanding and each is a judgement about one
of them; enabling the rules first would just mean 23 blanket noqas.
2026-09-18 00:57:57 +02:00

189 lines
7.1 KiB
Python

"""Idempotent seed for the E2E characterization (golden-master) account.
Run INSIDE the api container — it needs the app's models + the argon2 hasher:
docker exec -i siftlode-api-1 python - < scripts/e2e_seed.py
Resets a dedicated, NON-demo test account to a known baseline every run, so the Playwright
golden-master net is deterministic and never touches a real account. Self-healing: a crashed
spec that left the account dirty is cleaned on the next run. Prints one JSON line describing
what it seeded (the wrapper / global-setup reads it).
Credentials come from env (E2E_EMAIL / E2E_PASSWORD) with local-only defaults; the account
lives only in the localdev DB.
"""
import json
import os
import sys
from datetime import datetime, timezone
sys.path.insert(0, "/app")
from sqlalchemy import delete, func, select # noqa: E402
from app.db import SessionLocal # noqa: E402
from app.models import ( # noqa: E402
AppState,
Channel,
Playlist,
PlaylistItem,
Subscription,
User,
Video,
VideoState,
)
from app.security import hash_password # noqa: E402
EMAIL = os.environ.get("E2E_EMAIL", "e2e@siftlode.local")
PASSWORD = os.environ.get("E2E_PASSWORD", "e2e-golden-master")
N_SUBS = 3
def _now() -> datetime:
return datetime.now(timezone.utc)
def main() -> None:
db = SessionLocal()
try:
# 0. Mark the instance configured so the first-run setup wizard is skipped. A prod→dev
# reseed (siftlode seed-dev-from-prod) TRUNCATEs app_state — it's excluded from the
# data allow-list, being local/operational state — which would otherwise drop the box
# back into "Setup needed" and block login/UAT. Since e2e_seed always leaves a loginable
# admin below, the instance is legitimately set up. Idempotent (mirrors state._row +
# state.mark_configured). The running api process caches `configured` one-way from False,
# so it re-reads the DB and promotes on the next request — no restart needed.
app_state = db.get(AppState, 1)
if app_state is None:
app_state = AppState(id=1, sync_paused=False)
db.add(app_state)
app_state.configured = True
app_state.setup_token_hash = None
db.commit()
# 1. Upsert the account and always reset it to a clean, loginable baseline
# (email_verified + is_active + not suspended + not demo + password set).
user = db.scalar(select(User).where(User.email == EMAIL))
if user is None:
user = User(email=EMAIL)
db.add(user)
user.password_hash = hash_password(PASSWORD)
user.email_verified = True
user.is_active = True
user.is_suspended = False
user.is_demo = False
user.google_sub = None
# Admin so the E2E can reach the admin-only pages (scheduler / config / users / audit) and
# thus the render ternary's admin branches — the primary real user (npeter83) is admin too.
user.role = "admin"
user.display_name = "E2E Tester"
user.preferences = {"language": "en"}
db.commit()
# 2. Subscriptions: the top-N organic channels by catalog size — deterministic, and
# >1 so a channel filter visibly narrows the feed. Reset each run.
top = db.execute(
select(Video.channel_id, func.count().label("n"))
.join(Channel, Channel.id == Video.channel_id)
.where(Channel.from_search.is_(False), Channel.from_explore.is_(False))
.group_by(Video.channel_id)
.order_by(func.count().desc())
.limit(N_SUBS)
).all()
channel_ids = [row[0] for row in top]
db.execute(delete(Subscription).where(Subscription.user_id == user.id))
for cid in channel_ids:
db.add(Subscription(user_id=user.id, channel_id=cid, subscribed_at=_now()))
db.commit()
# 3. Deterministic watch state (reset each run):
# - one in-progress video (drives the resume bar + "in progress" filter) from the
# first channel — status stays "new" so it still counts as unwatched;
# - one WATCHED video from the second channel, so the "watched" show-filter is a
# real, non-empty narrowing of the feed (and the "all" vs "unwatched" split exists).
db.execute(delete(VideoState).where(VideoState.user_id == user.id))
def _newest(channel_id: str, exclude: set[str]) -> Video | None:
conds = [
Video.channel_id == channel_id,
Video.duration_seconds.is_not(None),
Video.live_status == "none",
]
if exclude:
conds.append(Video.id.not_in(exclude))
return db.scalar(
select(Video).where(*conds).order_by(Video.published_at.desc()).limit(1)
)
used: set[str] = set()
in_progress = None
watched = None
if channel_ids:
vid = _newest(channel_ids[0], used)
if vid is not None:
db.add(
VideoState(
user_id=user.id,
video_id=vid.id,
status="new",
position_seconds=42,
progress_updated_at=_now(),
)
)
in_progress = vid.id
used.add(vid.id)
if len(channel_ids) > 1:
wv = _newest(channel_ids[1], used)
if wv is not None:
db.add(
VideoState(
user_id=user.id,
video_id=wv.id,
status="watched",
watched_at=_now(),
)
)
watched = wv.id
used.add(wv.id)
# 4. One "saved" video (Watch-later membership drives the saved marker) from the last
# subscribed channel. Reset the built-in playlist's items each run.
wl = db.scalar(
select(Playlist).where(Playlist.user_id == user.id, Playlist.kind == "watch_later")
)
if wl is None:
wl = Playlist(user_id=user.id, name="Watch later", kind="watch_later")
db.add(wl)
db.commit()
db.execute(delete(PlaylistItem).where(PlaylistItem.playlist_id == wl.id))
saved = None
if channel_ids:
svid = _newest(channel_ids[-1], used)
if svid is not None:
db.add(PlaylistItem(playlist_id=wl.id, video_id=svid.id, position=0))
saved = svid.id
db.commit()
# noqa T201: this script's stdout IS its interface — the e2e runner parses this JSON.
print( # noqa: T201
json.dumps(
{
"ok": True,
"user_id": user.id,
"email": EMAIL,
"channels": channel_ids,
"in_progress": in_progress,
"watched": watched,
"saved": saved,
}
)
)
finally:
db.close()
if __name__ == "__main__":
main()