""" Admin router — user management and app configuration. All endpoints require admin role. """ import os import logging from typing import Optional, List from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel from sqlalchemy import select, func as sa_func from sqlalchemy.ext.asyncio import AsyncSession from app.auth import hash_password from app.database import get_db from app.dependencies import require_admin from app.models.user import User from app.models.photos import Photo from app.models.folders import SourceRoot from app.config import settings logger = logging.getLogger(__name__) router = APIRouter() # --------------------------------------------------------------------------- # Schemas # --------------------------------------------------------------------------- class CreateUserRequest(BaseModel): username: str password: str role: str = "user" # 'admin' | 'user' class UpdateUserRequest(BaseModel): role: Optional[str] = None is_active: Optional[bool] = None new_password: Optional[str] = None class UserDetailResponse(BaseModel): id: str username: str email: Optional[str] role: str is_active: bool media_path: str created_at: Optional[str] photo_count: int = 0 class UserListResponse(BaseModel): users: List[UserDetailResponse] total: int # --------------------------------------------------------------------------- # User CRUD # --------------------------------------------------------------------------- @router.get("/users", response_model=UserListResponse) async def list_users( admin: User = Depends(require_admin), db: AsyncSession = Depends(get_db), ): """List all users with their photo counts.""" result = await db.execute(select(User).order_by(User.created_at)) users = result.scalars().all() user_list = [] for u in users: count_result = await db.execute( select(sa_func.count(Photo.id)).where(Photo.user_id == u.id) ) photo_count = count_result.scalar() or 0 user_list.append(UserDetailResponse( id=u.id, username=u.username, email=u.email, role=u.role, is_active=u.is_active, media_path=u.media_path, created_at=u.created_at.isoformat() if u.created_at else None, photo_count=photo_count, )) return UserListResponse(users=user_list, total=len(user_list)) @router.post("/users", status_code=201, response_model=UserDetailResponse) async def create_user( body: CreateUserRequest, admin: User = Depends(require_admin), db: AsyncSession = Depends(get_db), ): """Create a new user. Creates their media directory and source root.""" if body.role not in ("admin", "user"): raise HTTPException(status_code=400, detail="Role must be 'admin' or 'user'") if len(body.username.strip()) < 2: raise HTTPException(status_code=400, detail="Username must be at least 2 characters") if len(body.password) < 6: raise HTTPException(status_code=400, detail="Password must be at least 6 characters") # Check for duplicate username existing = await db.execute( select(User).where(User.username == body.username.strip()) ) if existing.scalar_one_or_none() is not None: raise HTTPException(status_code=409, detail="Username already taken") media_path = os.path.join(settings.photo_dirs, body.username.strip()) os.makedirs(media_path, exist_ok=True) user = User( username=body.username.strip(), hashed_password=hash_password(body.password), role=body.role, media_path=media_path, ) db.add(user) await db.flush() # get user.id before creating source root source_root = SourceRoot( name=f"{user.username}'s Library", path=media_path, user_id=user.id, ) db.add(source_root) await db.commit() logger.info(f"Admin '{admin.username}' created user '{user.username}' (role={user.role})") return UserDetailResponse( id=user.id, username=user.username, email=user.email, role=user.role, is_active=user.is_active, media_path=user.media_path, created_at=user.created_at.isoformat() if user.created_at else None, photo_count=0, ) @router.get("/users/{user_id}", response_model=UserDetailResponse) async def get_user( user_id: str, admin: User = Depends(require_admin), db: AsyncSession = Depends(get_db), ): """Get a single user's details.""" result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if user is None: raise HTTPException(status_code=404, detail="User not found") count_result = await db.execute( select(sa_func.count(Photo.id)).where(Photo.user_id == user.id) ) photo_count = count_result.scalar() or 0 return UserDetailResponse( id=user.id, username=user.username, email=user.email, role=user.role, is_active=user.is_active, media_path=user.media_path, created_at=user.created_at.isoformat() if user.created_at else None, photo_count=photo_count, ) @router.patch("/users/{user_id}", response_model=UserDetailResponse) async def update_user( user_id: str, body: UpdateUserRequest, admin: User = Depends(require_admin), db: AsyncSession = Depends(get_db), ): """Update a user's role, active status, or password.""" result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if user is None: raise HTTPException(status_code=404, detail="User not found") if body.role is not None: if body.role not in ("admin", "user"): raise HTTPException(status_code=400, detail="Role must be 'admin' or 'user'") # Prevent demoting the last admin if user.role == "admin" and body.role == "user": admin_count = (await db.execute( select(sa_func.count(User.id)).where(User.role == "admin", User.is_active == True) )).scalar() if admin_count <= 1: raise HTTPException(status_code=400, detail="Cannot demote the last admin") user.role = body.role if body.is_active is not None: # Prevent deactivating the last admin if user.role == "admin" and not body.is_active: admin_count = (await db.execute( select(sa_func.count(User.id)).where(User.role == "admin", User.is_active == True) )).scalar() if admin_count <= 1: raise HTTPException(status_code=400, detail="Cannot deactivate the last admin") user.is_active = body.is_active if body.new_password is not None: if len(body.new_password) < 6: raise HTTPException(status_code=400, detail="Password must be at least 6 characters") user.hashed_password = hash_password(body.new_password) await db.commit() count_result = await db.execute( select(sa_func.count(Photo.id)).where(Photo.user_id == user.id) ) photo_count = count_result.scalar() or 0 return UserDetailResponse( id=user.id, username=user.username, email=user.email, role=user.role, is_active=user.is_active, media_path=user.media_path, created_at=user.created_at.isoformat() if user.created_at else None, photo_count=photo_count, ) @router.delete("/users/{user_id}") async def delete_user( user_id: str, admin: User = Depends(require_admin), db: AsyncSession = Depends(get_db), ): """Soft-delete a user by deactivating them. Media is preserved.""" result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if user is None: raise HTTPException(status_code=404, detail="User not found") if user.id == admin.id: raise HTTPException(status_code=400, detail="Cannot delete yourself") # Prevent deleting the last admin if user.role == "admin": admin_count = (await db.execute( select(sa_func.count(User.id)).where(User.role == "admin", User.is_active == True) )).scalar() if admin_count <= 1: raise HTTPException(status_code=400, detail="Cannot delete the last admin") user.is_active = False await db.commit() logger.info(f"Admin '{admin.username}' deactivated user '{user.username}'") return {"status": "ok", "detail": f"User '{user.username}' deactivated"}