siftlode/backend/app/routes/admin.py
npeter83 747c8d20ce 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.
2026-06-16 09:17:27 +02:00

226 lines
7.1 KiB
Python

"""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}