bc437cce92
Fixes:
- Dashboard: featured most-recent activity card with map + stats
- Maps default to Street; preferCanvas + larger tile buffer for smoother pan/zoom
- Running cadence as colour-banded dots + 165 spm guide line
- Routes: inline row expansion, rename (PATCH /routes/{id}), podium + deltas, tiled map
- Records: remove reversed pace Y-axis
- Profile: remove resting HR; add goal weight
- Health: snapshot weight carry-forward; VO2 trend axis 30-70;
weight goal line + kg/st-lb toggle + axis max; sleep 8h/avg lines
- Garmin sync progress moved to global store with persistent floating bar
Features:
- Speed-coloured activity route (default) with Speed/Solid toggle
- GPS-geometry segments: draw on map, match across all activities,
1st/2nd/3rd leaderboard + podium badges (replaces old distance segments)
- Lap bests: best time per lap across a route + delta column
- Body Battery: highlight activity time windows
Schema: users.goal_weight_kg ALTER; new segments/segment_efforts tables.
Removes RouteSegment, the Segments page, and segment-bests endpoints.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
144 lines
5.0 KiB
Python
144 lines
5.0 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,
|
|
)
|
|
|
|
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))
|
|
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"}
|