From e0c63c26d4b8fdc8340412f0cda4597c2d2a6d4a Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 16 Jun 2026 09:17:27 +0200 Subject: [PATCH] feat(demo): admin demo whitelist CRUD + state reset Admin endpoints to manage the demo email whitelist (DB-backed, no env) and a manual reset that wipes the demo account's per-user state and re-seeds a few sample playlists from the shared catalog. --- backend/app/routes/admin.py | 134 ++++++++++++++++++++++++++++++++++-- 1 file changed, 130 insertions(+), 4 deletions(-) diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py index c544f4b..545a1ce 100644 --- a/backend/app/routes/admin.py +++ b/backend/app/routes/admin.py @@ -1,17 +1,29 @@ -"""Admin-only onboarding: review access requests (Invites), approve/deny, manual add.""" +"""Admin-only onboarding: review access requests (Invites), approve/deny, manual add. +Also the demo-account admin surface: the demo email whitelist + a manual state reset.""" +import re from datetime import datetime, timezone from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException -from sqlalchemy import select +from sqlalchemy import delete, select from sqlalchemy.orm import Session from app import email as email_mod -from app.auth import current_user +from app.auth import current_user, get_or_create_demo_user from app.db import get_db -from app.models import Invite, User +from app.models import ( + DemoWhitelist, + Invite, + Playlist, + PlaylistItem, + User, + Video, + VideoState, +) router = APIRouter(prefix="/api/admin", tags=["admin"]) +_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") + def admin_user(user: User = Depends(current_user)) -> User: if user.role != "admin": @@ -98,3 +110,117 @@ def add_invite( inv.decided_by = admin.email db.commit() return _serialize(inv) + + +# --- Demo account: email whitelist + state reset ------------------------------------- + +def _serialize_demo(row: DemoWhitelist) -> dict: + return { + "id": row.id, + "email": row.email, + "note": row.note, + "added_by": row.added_by, + "created_at": row.created_at.isoformat() if row.created_at else None, + } + + +@router.get("/demo/whitelist") +def list_demo_whitelist( + _: User = Depends(admin_user), db: Session = Depends(get_db) +) -> list[dict]: + rows = ( + db.execute(select(DemoWhitelist).order_by(DemoWhitelist.created_at.desc())) + .scalars() + .all() + ) + return [_serialize_demo(r) for r in rows] + + +@router.post("/demo/whitelist") +def add_demo_whitelist( + payload: dict, + admin: User = Depends(admin_user), + db: Session = Depends(get_db), +) -> dict: + """Add an email that may enter the shared demo account from the login page (no Google + sign-in). Idempotent — re-adding an existing email just returns it.""" + email = (payload.get("email") or "").strip().lower() + if not _EMAIL_RE.match(email): + raise HTTPException(status_code=400, detail="Enter a valid email address.") + row = db.execute( + select(DemoWhitelist).where(DemoWhitelist.email == email) + ).scalar_one_or_none() + if row is None: + row = DemoWhitelist( + email=email, + note=(payload.get("note") or "").strip() or None, + added_by=admin.email, + ) + db.add(row) + db.commit() + return _serialize_demo(row) + + +@router.delete("/demo/whitelist/{entry_id}") +def remove_demo_whitelist( + entry_id: int, + _: User = Depends(admin_user), + db: Session = Depends(get_db), +) -> dict: + row = db.get(DemoWhitelist, entry_id) + if row is not None: + db.delete(row) + db.commit() + return {"deleted": entry_id} + + +def _seed_demo_playlists(db: Session, demo: User) -> int: + """Seed a few sample playlists from the shared catalog so a freshly-reset demo isn't + empty — gives visitors something to play with. Best-effort: skips silently if the + catalog is too small.""" + recent = ( + db.execute( + select(Video.id) + .where(Video.is_short.is_(False), Video.live_status == "none") + .order_by(Video.published_at.desc().nullslast()) + .limit(60) + ) + .scalars() + .all() + ) + if not recent: + return 0 + samples = [ + ("Fresh picks", recent[0:10]), + ("Quick watch", recent[10:18]), + ("Saved for later", recent[18:26]), + ] + created = 0 + for pos, (name, vids) in enumerate(samples): + if not vids: + continue + pl = Playlist(user_id=demo.id, name=name, position=pos) + db.add(pl) + db.flush() + for ipos, vid in enumerate(vids): + db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=ipos)) + created += 1 + return created + + +@router.post("/demo/reset") +def reset_demo( + _: User = Depends(admin_user), db: Session = Depends(get_db) +) -> dict: + """Wipe the shared demo account's per-user state (watch/save/hide states, playlists, + preferences) back to a clean baseline and re-seed a few sample playlists. There's no + automatic reset — this is the admin's manual 'clean up the sandbox' button.""" + demo = get_or_create_demo_user(db) + db.execute(delete(VideoState).where(VideoState.user_id == demo.id)) + # Playlist items cascade on playlist delete (ondelete="CASCADE"). + db.execute(delete(Playlist).where(Playlist.user_id == demo.id)) + demo.preferences = {"language": "en"} + db.commit() + seeded = _seed_demo_playlists(db, demo) + db.commit() + return {"reset": True, "playlists_seeded": seeded}