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 datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||||
from sqlalchemy import select
|
from sqlalchemy import delete, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import email as email_mod
|
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.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"])
|
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||||
|
|
||||||
|
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||||
|
|
||||||
|
|
||||||
def admin_user(user: User = Depends(current_user)) -> User:
|
def admin_user(user: User = Depends(current_user)) -> User:
|
||||||
if user.role != "admin":
|
if user.role != "admin":
|
||||||
|
|
@ -98,3 +110,117 @@ def add_invite(
|
||||||
inv.decided_by = admin.email
|
inv.decided_by = admin.email
|
||||||
db.commit()
|
db.commit()
|
||||||
return _serialize(inv)
|
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