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.
This commit is contained in:
parent
5936436d26
commit
e0c63c26d4
1 changed files with 130 additions and 4 deletions
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue