"""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 delete, select from sqlalchemy.orm import Session from app import email as email_mod from app.auth import current_user, get_or_create_demo_user from app.db import get_db 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": raise HTTPException(status_code=403, detail="Admin only") return user def _serialize(inv: Invite) -> dict: return { "id": inv.id, "email": inv.email, "status": inv.status, "note": inv.note, "requested_at": inv.requested_at.isoformat() if inv.requested_at else None, "decided_at": inv.decided_at.isoformat() if inv.decided_at else None, "decided_by": inv.decided_by, } @router.get("/invites") def list_invites( status: str | None = None, _: User = Depends(admin_user), db: Session = Depends(get_db), ) -> list[dict]: stmt = select(Invite) if status: stmt = stmt.where(Invite.status == status) # Pending first, then most-recently requested. rows = db.execute(stmt.order_by(Invite.requested_at.desc())).scalars().all() order = {"pending": 0, "approved": 1, "denied": 2} rows.sort(key=lambda i: order.get(i.status, 9)) return [_serialize(i) for i in rows] def _decide(db: Session, invite_id: int, admin: User, approved: bool) -> Invite: inv = db.get(Invite, invite_id) if inv is None: raise HTTPException(status_code=404, detail="No such invite") inv.status = "approved" if approved else "denied" inv.decided_at = datetime.now(timezone.utc) inv.decided_by = admin.email db.commit() return inv @router.post("/invites/{invite_id}/approve") def approve_invite( invite_id: int, background: BackgroundTasks, admin: User = Depends(admin_user), db: Session = Depends(get_db), ) -> dict: inv = _decide(db, invite_id, admin, approved=True) background.add_task(email_mod.send_access_approved, inv.email) return _serialize(inv) @router.post("/invites/{invite_id}/deny") def deny_invite( invite_id: int, admin: User = Depends(admin_user), db: Session = Depends(get_db), ) -> dict: return _serialize(_decide(db, invite_id, admin, approved=False)) @router.post("/invites") def add_invite( payload: dict, admin: User = Depends(admin_user), db: Session = Depends(get_db), ) -> dict: """Manually whitelist an email (approved straight away). Convenience for the admin.""" email = (payload.get("email") or "").strip().lower() if "@" not in email: raise HTTPException(status_code=400, detail="Enter a valid email address.") inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_none() if inv is None: inv = Invite(email=email) db.add(inv) inv.status = "approved" inv.decided_at = datetime.now(timezone.utc) 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}