"""Administration : utilisateurs, activation des sources, santé.""" import asyncio import logging from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel from app import auth from app.db import db from app.routers.auth import AdminUser from app.scrapers.base import ScrapeError, all_sources, get_source, import_all_scrapers from app.services.discover import discover from app.services.settings import ( get_sonarr_config, get_torznab_apikey, is_source_enabled, reset_torznab_apikey, set_sonarr_config, set_source_enabled, ) from app.services.sonarr import sonarr logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/admin", tags=["admin"]) import_all_scrapers() # ---------------------------------------------------------------- utilisateurs @router.get("/users") async def list_users(admin: AdminUser) -> list[dict]: rows = await db.fetchall( "SELECT id, username, is_admin, is_active, created_at FROM users ORDER BY id" ) return [dict(row) for row in rows] @router.post("/users/{user_id}/toggle-active") async def toggle_active(user_id: int, admin: AdminUser) -> dict: if user_id == admin.id: raise HTTPException(400, "Impossible de désactiver son propre compte") row = await db.fetchone("SELECT is_active FROM users WHERE id = ?", (user_id,)) if row is None: raise HTTPException(404, "Utilisateur introuvable") new_state = 0 if row["is_active"] else 1 await db.execute("UPDATE users SET is_active = ? WHERE id = ?", (new_state, user_id)) if not new_state: await auth.revoke_all_refresh_tokens(user_id) return {"is_active": bool(new_state)} @router.post("/users/{user_id}/toggle-admin") async def toggle_admin(user_id: int, admin: AdminUser) -> dict: if user_id == admin.id: raise HTTPException(400, "Impossible de modifier ses propres droits") row = await db.fetchone("SELECT is_admin FROM users WHERE id = ?", (user_id,)) if row is None: raise HTTPException(404, "Utilisateur introuvable") new_state = 0 if row["is_admin"] else 1 await db.execute("UPDATE users SET is_admin = ? WHERE id = ?", (new_state, user_id)) return {"is_admin": bool(new_state)} @router.delete("/users/{user_id}") async def delete_user(user_id: int, admin: AdminUser) -> dict: if user_id == admin.id: raise HTTPException(400, "Impossible de supprimer son propre compte") cursor = await db.execute("DELETE FROM users WHERE id = ?", (user_id,)) if cursor.rowcount == 0: raise HTTPException(404, "Utilisateur introuvable") return {"ok": True} @router.get("/stats") async def stats(admin: AdminUser) -> dict: users = await db.fetchone("SELECT COUNT(*) AS n FROM users") downloads = await db.fetchall("SELECT status, COUNT(*) AS n FROM downloads GROUP BY status") return { "users": users["n"], "downloads": {row["status"]: row["n"] for row in downloads}, } # ---------------------------------------------------------------- sources class SourceToggle(BaseModel): enabled: bool @router.post("/sources/{name}/toggle") async def toggle_source(name: str, payload: SourceToggle, admin: AdminUser) -> dict: get_source(name) # 404 implicite si inconnue await set_source_enabled(name, payload.enabled) return {"name": name, "enabled": payload.enabled} @router.post("/sources/{name}/health") async def health_check(name: str, admin: AdminUser) -> dict: """Test de santé manuel : la source doit répondre à une recherche simple.""" source = get_source(name) try: results = await asyncio.wait_for(source.search("naruto"), timeout=30) healthy = len(results) > 0 detail = f"{len(results)} résultats" except (ScrapeError, TimeoutError) as exc: healthy = False detail = str(exc)[:200] logger.error("Health check %s KO : %s", name, exc) return {"name": name, "healthy": healthy, "detail": detail} @router.get("/sources") async def sources_status(admin: AdminUser) -> list[dict]: return [ { "name": s.name, "label": s.label, "base_url": s.base_url, "enabled": await is_source_enabled(s.name), } for s in all_sources() ] # ---------------------------------------------------------------- intégrations *arr class SonarrConfig(BaseModel): url: str apikey: str @router.get("/integrations") async def integrations(admin: AdminUser, request: Request) -> dict: """Configuration Torznab (indexeur) et Sonarr (recommandations).""" config = await get_sonarr_config() base = str(request.base_url).rstrip("/") return { "torznab": { "apikey": await get_torznab_apikey(), "endpoint": f"{base}/torznab/api", }, "sonarr": config, } @router.post("/integrations/torznab/regenerate") async def regenerate_torznab_key(admin: AdminUser) -> dict: key = await reset_torznab_apikey() return {"apikey": key} @router.put("/integrations/sonarr") async def save_sonarr(payload: SonarrConfig, admin: AdminUser) -> dict: """Enregistre la connexion Sonarr et recalcule les recommandations.""" await set_sonarr_config(payload.url, payload.apikey) sonarr.invalidate() discover.invalidate_for_you() return {"ok": True} @router.post("/integrations/sonarr/test") async def test_sonarr(admin: AdminUser) -> dict: return await sonarr.test_connection()