Files
MileVault/backend/app/api/users.py
T
owain 64caaad4e7
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 19s
fix: cleanup StravaConfig on user delete (FK violation); equalize login timing (user enum); scope upload task-status to owner; SQL-side data-point downsampling; drop unused clsx/react-leaflet deps
2026-06-25 12:21:25 +01:00

150 lines
5.4 KiB
Python

"""
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,
Segment, SegmentEffort, PersonalRecord, HealthMetric, WeightLog,
GarminConnectConfig, StravaConfig,
)
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)
segment_ids = select(Segment.id).where(Segment.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(SegmentEffort).where(SegmentEffort.segment_id.in_(segment_ids)))
await db.execute(delete(Segment).where(Segment.user_id == user_id))
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))
# StravaConfig holds Fernet-encrypted OAuth tokens and has a NOT-NULL FK to
# users with no DB-level cascade; the Core deletes above bypass the ORM
# relationship cascade, so it must be removed explicitly or the final
# DELETE on users raises a ForeignKey violation (and leaves tokens orphaned).
await db.execute(delete(StravaConfig).where(StravaConfig.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"}