2026-06-16 09:17:27 +02:00
|
|
|
"""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
|
2026-06-12 01:43:07 +02:00
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
2026-06-19 14:03:11 +02:00
|
|
|
from sqlalchemy import delete, func, select
|
2026-06-12 01:43:07 +02:00
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
|
|
|
|
|
|
from app import email as email_mod
|
2026-06-16 09:17:27 +02:00
|
|
|
from app.auth import current_user, get_or_create_demo_user
|
2026-06-12 01:43:07 +02:00
|
|
|
from app.db import get_db
|
2026-06-16 09:17:27 +02:00
|
|
|
from app.models import (
|
|
|
|
|
DemoWhitelist,
|
|
|
|
|
Invite,
|
|
|
|
|
Playlist,
|
|
|
|
|
PlaylistItem,
|
|
|
|
|
User,
|
|
|
|
|
Video,
|
|
|
|
|
VideoState,
|
|
|
|
|
)
|
2026-06-12 01:43:07 +02:00
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
|
|
|
|
|
2026-06-16 09:17:27 +02:00
|
|
|
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
|
|
|
|
|
2026-06-12 01:43:07 +02:00
|
|
|
|
|
|
|
|
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]
|
|
|
|
|
|
|
|
|
|
|
2026-06-19 14:03:11 +02:00
|
|
|
def _activate_user_by_email(db: Session, email: str) -> None:
|
|
|
|
|
"""Approving access activates a matching (e.g. pending password-registration) account so
|
|
|
|
|
it can sign in. No-op if no such user exists yet (a Google user is created at first login)."""
|
|
|
|
|
user = db.execute(select(User).where(User.email == email.lower())).scalar_one_or_none()
|
|
|
|
|
if user is not None and not user.is_active:
|
|
|
|
|
user.is_active = True
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
2026-06-12 01:43:07 +02:00
|
|
|
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)
|
2026-06-19 14:03:11 +02:00
|
|
|
_activate_user_by_email(db, inv.email)
|
2026-06-12 01:43:07 +02:00
|
|
|
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()
|
2026-06-19 14:03:11 +02:00
|
|
|
_activate_user_by_email(db, email)
|
2026-06-12 01:43:07 +02:00
|
|
|
return _serialize(inv)
|
2026-06-16 09:17:27 +02:00
|
|
|
|
|
|
|
|
|
2026-06-19 14:03:11 +02:00
|
|
|
# --- Users & roles --------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def _serialize_user(u: User) -> dict:
|
|
|
|
|
return {
|
|
|
|
|
"id": u.id,
|
|
|
|
|
"email": u.email,
|
|
|
|
|
"display_name": u.display_name,
|
|
|
|
|
"role": u.role,
|
|
|
|
|
"is_active": u.is_active,
|
|
|
|
|
"email_verified": u.email_verified,
|
|
|
|
|
"is_demo": u.is_demo,
|
|
|
|
|
"has_password": u.password_hash is not None,
|
|
|
|
|
"has_google": u.google_sub is not None,
|
|
|
|
|
"created_at": u.created_at.isoformat() if u.created_at else None,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/users")
|
|
|
|
|
def list_users(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> list[dict]:
|
|
|
|
|
rows = db.execute(select(User).order_by(User.created_at.desc())).scalars().all()
|
|
|
|
|
return [_serialize_user(u) for u in rows]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.patch("/users/{user_id}/role")
|
|
|
|
|
def set_user_role(
|
|
|
|
|
user_id: int,
|
|
|
|
|
payload: dict,
|
|
|
|
|
admin: User = Depends(admin_user),
|
|
|
|
|
db: Session = Depends(get_db),
|
|
|
|
|
) -> dict:
|
|
|
|
|
"""Promote/demote a user. Guards: can't change your own role, can't touch the demo
|
|
|
|
|
account, and can't remove the last remaining admin."""
|
|
|
|
|
role = payload.get("role")
|
|
|
|
|
if role not in ("user", "admin"):
|
|
|
|
|
raise HTTPException(status_code=400, detail="role must be 'user' or 'admin'")
|
|
|
|
|
target = db.get(User, user_id)
|
|
|
|
|
if target is None:
|
|
|
|
|
raise HTTPException(status_code=404, detail="No such user")
|
|
|
|
|
if target.is_demo:
|
|
|
|
|
raise HTTPException(status_code=400, detail="The demo account's role can't be changed.")
|
|
|
|
|
if target.id == admin.id:
|
|
|
|
|
raise HTTPException(status_code=400, detail="You can't change your own role.")
|
|
|
|
|
if target.role == "admin" and role == "user":
|
|
|
|
|
admin_count = db.scalar(select(func.count()).select_from(User).where(User.role == "admin"))
|
|
|
|
|
if (admin_count or 0) <= 1:
|
|
|
|
|
raise HTTPException(status_code=400, detail="Can't remove the last admin.")
|
|
|
|
|
target.role = role
|
|
|
|
|
db.commit()
|
|
|
|
|
return _serialize_user(target)
|
|
|
|
|
|
|
|
|
|
|
2026-06-16 09:17:27 +02:00
|
|
|
# --- 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}
|