""" Admin-only user management: list provisioned users, promote/demote admin, and delete a user together with all of their data. New users are normally provisioned just-in-time on first PocketID login (see app/api/auth.py). This router is the in-app surface for managing them. """ import shutil from pathlib import Path from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, delete, func from pydantic import BaseModel from typing import Optional from app.core.database import get_db from app.core.security import get_current_user from app.core.config import settings from app.models.user import ( User, Activity, ActivityDataPoint, ActivityLap, NamedRoute, RouteSegment, PersonalRecord, HealthMetric, WeightLog, GarminConnectConfig, ) router = APIRouter() def _require_admin(current_user: User): if not current_user.is_admin: raise HTTPException(403, "Admin only") async def _admin_count(db: AsyncSession) -> int: result = await db.execute(select(func.count()).select_from(User).where(User.is_admin == True)) return result.scalar_one() class UserOut(BaseModel): id: int username: str email: Optional[str] is_admin: bool has_passkey: bool activity_count: int created_at: Optional[str] class AdminUpdate(BaseModel): is_admin: bool @router.get("/") async def list_users( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): _require_admin(current_user) # activity counts per user in one grouped query counts = dict( (await db.execute( select(Activity.user_id, func.count(Activity.id)).group_by(Activity.user_id) )).all() ) result = await db.execute(select(User).order_by(User.id)) users = result.scalars().all() return [ UserOut( id=u.id, username=u.username, email=u.email, is_admin=u.is_admin, has_passkey=u.pocketid_sub is not None, activity_count=counts.get(u.id, 0), created_at=u.created_at.isoformat() if u.created_at else None, ) for u in users ] @router.patch("/{user_id}") async def set_admin( user_id: int, body: AdminUpdate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): _require_admin(current_user) if user_id == current_user.id: raise HTTPException(400, "You cannot change your own admin status") result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if not user: raise HTTPException(404, "User not found") # Demoting the last remaining admin would lock everyone out. if user.is_admin and not body.is_admin and await _admin_count(db) <= 1: raise HTTPException(400, "Cannot demote the last admin") user.is_admin = body.is_admin await db.commit() return {"status": "ok", "is_admin": user.is_admin} @router.delete("/{user_id}") async def delete_user( user_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): _require_admin(current_user) if user_id == current_user.id: raise HTTPException(400, "You cannot delete your own account") result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if not user: raise HTTPException(404, "User not found") if user.is_admin and await _admin_count(db) <= 1: raise HTTPException(400, "Cannot delete the last admin") # Ordered deletes: PersonalRecord and the activity/route child tables have no # cascade path from User, so remove them before the parents to avoid FK errors. activity_ids = select(Activity.id).where(Activity.user_id == user_id) route_ids = select(NamedRoute.id).where(NamedRoute.user_id == user_id) await db.execute(delete(PersonalRecord).where(PersonalRecord.user_id == user_id)) await db.execute(delete(ActivityLap).where(ActivityLap.activity_id.in_(activity_ids))) await db.execute(delete(ActivityDataPoint).where(ActivityDataPoint.activity_id.in_(activity_ids))) await db.execute(delete(RouteSegment).where(RouteSegment.route_id.in_(route_ids))) await db.execute(delete(Activity).where(Activity.user_id == user_id)) await db.execute(delete(NamedRoute).where(NamedRoute.user_id == user_id)) await db.execute(delete(HealthMetric).where(HealthMetric.user_id == user_id)) await db.execute(delete(WeightLog).where(WeightLog.user_id == user_id)) await db.execute(delete(GarminConnectConfig).where(GarminConnectConfig.user_id == user_id)) await db.execute(delete(User).where(User.id == user_id)) await db.commit() # Remove the user's uploaded files from disk (best-effort). shutil.rmtree(Path(settings.file_store_path) / str(user_id), ignore_errors=True) return {"status": "ok"}