"""Admin endpoints: user management, services, sources, reindex, settings. Registration is admin-only — this is a private instance, there is no public sign-up (plan §6.4). """ import uuid from fastapi import APIRouter, Query, status from app.api.deps import SourceRegistryDep, SubsonicAuthServiceDep, SuperUser, UserServiceDep from app.api.health import _check_db, _check_ml, _check_redis from app.api.schemas.admin import ( AdminSettingsOut, ReindexJob, ReindexResponse, ServicesStatusOut, ) from app.api.schemas.source import SourceInfoOut from app.api.schemas.subsonic import SubsonicPasswordResponse from app.api.schemas.user import ( CreateUserRequest, ResetPasswordRequest, UpdateUserRequest, UserResponse, ) from app.core.config import get_settings from app.domain.errors import DependencyUnavailableError, NotSupportedError from app.workers.queue import enqueue router = APIRouter(prefix="/admin", tags=["admin"]) @router.get("/users", response_model=list[UserResponse]) async def list_users( _admin: SuperUser, users: UserServiceDep, limit: int = Query(default=50, ge=1, le=200), offset: int = Query(default=0, ge=0), ) -> list[UserResponse]: result = await users.list_users(limit=limit, offset=offset) return [UserResponse.from_entity(u) for u in result] @router.post("/users", response_model=UserResponse, status_code=status.HTTP_201_CREATED) async def create_user( body: CreateUserRequest, _admin: SuperUser, users: UserServiceDep ) -> UserResponse: user = await users.create_user( username=body.username, password=body.password, is_superuser=body.is_superuser, ) return UserResponse.from_entity(user) @router.get("/users/{user_id}", response_model=UserResponse) async def get_user(user_id: uuid.UUID, _admin: SuperUser, users: UserServiceDep) -> UserResponse: return UserResponse.from_entity(await users.get_user(user_id)) @router.patch("/users/{user_id}", response_model=UserResponse) async def update_user( user_id: uuid.UUID, body: UpdateUserRequest, _admin: SuperUser, users: UserServiceDep, ) -> UserResponse: user = await users.get_user(user_id) if body.is_superuser is not None: user = await users.set_superuser(user_id, is_superuser=body.is_superuser) if body.is_active is not None: user = await users.set_active(user_id, is_active=body.is_active) return UserResponse.from_entity(user) @router.post("/users/{user_id}/reset-password", status_code=status.HTTP_204_NO_CONTENT) async def reset_password( user_id: uuid.UUID, body: ResetPasswordRequest, _admin: SuperUser, users: UserServiceDep, ) -> None: await users.reset_password(user_id, new_password=body.new_password) @router.delete("/users/{user_id}", response_model=UserResponse) async def deactivate_user( user_id: uuid.UUID, _admin: SuperUser, users: UserServiceDep ) -> UserResponse: """Soft delete — deactivates the account and revokes its sessions.""" return UserResponse.from_entity(await users.deactivate(user_id)) @router.post("/users/{user_id}/subsonic-password", response_model=SubsonicPasswordResponse) async def rotate_user_subsonic_password( user_id: uuid.UUID, _admin: SuperUser, subsonic: SubsonicAuthServiceDep ) -> SubsonicPasswordResponse: """Rotate any user's Subsonic app-password and return the new plaintext.""" return SubsonicPasswordResponse(password=await subsonic.rotate(user_id)) @router.get("/services") async def list_services(_admin: SuperUser) -> ServicesStatusOut: """Backing-dependency health for the admin dashboard — same probes as the readiness endpoint (DB + Redis required, ML optional).""" database = await _check_db() redis = await _check_redis() ml = await _check_ml() return ServicesStatusOut(database=database, redis=redis, ml=ml) @router.get("/sources") async def list_admin_sources( _admin: SuperUser, registry: SourceRegistryDep ) -> list[SourceInfoOut]: """Configured sources and their live availability (same view as ``/sources``, admin-scoped).""" return [SourceInfoOut.from_entity(info) for info in registry.infos()] @router.post("/reindex") async def trigger_reindex(admin: SuperUser, registry: SourceRegistryDep) -> ReindexResponse: """Enqueue a full re-scan of every indexable source. The walk + file copies run in the worker (never the request cycle); re-scans are idempotent.""" indexables = registry.indexables() if not indexables: raise DependencyUnavailableError("No indexable source is configured.") jobs: list[ReindexJob] = [] for backend in indexables: job_id = await enqueue("scan_local_folder", source=backend.name, added_by=str(admin.id)) jobs.append(ReindexJob(source=backend.name, job_id=job_id)) return ReindexResponse(jobs=jobs) @router.get("/settings") async def get_admin_settings(_admin: SuperUser) -> AdminSettingsOut: """Effective, non-secret instance configuration. Reflects the environment the process booted with; secrets/connection strings are never returned — only whether each optional integration is configured.""" settings = get_settings() return AdminSettingsOut( environment=settings.environment, allow_registration=settings.allow_registration, storage_backend=settings.storage_backend, media_path=str(settings.media_path), youtube_enabled=settings.youtube_enabled, coverart_enabled=settings.coverart_enabled, ml_configured=settings.ml_service_url is not None, acoustid_configured=settings.acoustid_api_key is not None, local_import_configured=settings.local_media_import_path is not None, ) # -- runtime config mutation (intentionally unsupported) ---------------------- # The instance is env-configured (CLAUDE.md: nothing hardcoded, all from env) and # get_settings() is a cached singleton, so config is not mutable at runtime. # These endpoints answer 501 with a clear reason rather than silently no-op'ing; # a persistent override layer that shadows env would be a deliberate future # departure. Read the effective config via GET /admin/settings. @router.patch("/sources/{source}") async def update_admin_source(source: str, _admin: SuperUser) -> None: raise NotSupportedError( "Sources are configured via environment variables (e.g. YOUTUBE_ENABLED, " "LOCAL_MEDIA_IMPORT_PATH); runtime changes are not supported." ) @router.patch("/settings") async def update_admin_settings(_admin: SuperUser) -> None: raise NotSupportedError( "Instance settings are managed via environment configuration; " "runtime changes are not supported." )