244 lines
7.9 KiB
Python
244 lines
7.9 KiB
Python
"""Administration : utilisateurs, activation des sources, santé, mises à jour."""
|
|
|
|
import asyncio
|
|
import logging
|
|
from urllib.parse import urlparse
|
|
|
|
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,
|
|
SourceScraper,
|
|
all_sources,
|
|
get_source,
|
|
import_all_scrapers,
|
|
)
|
|
from app.services.discover import discover
|
|
from app.services.settings import (
|
|
get_sonarr_config,
|
|
get_source_health,
|
|
get_torznab_apikey,
|
|
is_source_enabled,
|
|
reset_torznab_apikey,
|
|
set_sonarr_config,
|
|
set_source_base_url,
|
|
set_source_enabled,
|
|
set_source_health,
|
|
)
|
|
from app.services.sonarr import sonarr
|
|
from app.services.update import UpdateError, fetch_latest_version, trigger_update
|
|
from app.services.update import status as update_status
|
|
|
|
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
|
|
|
|
|
|
def _source_or_404(name: str) -> SourceScraper:
|
|
try:
|
|
return get_source(name)
|
|
except ScrapeError as exc:
|
|
raise HTTPException(404, str(exc)) from exc
|
|
|
|
|
|
@router.post("/sources/{name}/toggle")
|
|
async def toggle_source(name: str, payload: SourceToggle, admin: AdminUser) -> dict:
|
|
_source_or_404(name)
|
|
await set_source_enabled(name, payload.enabled)
|
|
return {"name": name, "enabled": payload.enabled}
|
|
|
|
class SourceUrlUpdate(BaseModel):
|
|
url: str
|
|
|
|
|
|
@router.put("/sources/{name}/url")
|
|
async def update_source_url(name: str, payload: SourceUrlUpdate, admin: AdminUser) -> dict:
|
|
"""Change l'URL d'une source (ex. le site a changé de domaine) ; vide = défaut."""
|
|
source = _source_or_404(name)
|
|
default = type(source).base_url
|
|
url = payload.url.strip().rstrip("/")
|
|
if url and url != default:
|
|
parsed = urlparse(url)
|
|
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
|
raise HTTPException(422, "URL invalide — format attendu : https://domaine.tld")
|
|
await set_source_base_url(name, url)
|
|
else:
|
|
url = default
|
|
await set_source_base_url(name, None)
|
|
source.base_url = url
|
|
logger.info("URL de la source %s : %s", name, url)
|
|
return {
|
|
"name": name,
|
|
"base_url": url,
|
|
"default_base_url": default,
|
|
"overridden": url != default,
|
|
}
|
|
|
|
|
|
@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 = _source_or_404(name)
|
|
try:
|
|
results = await asyncio.wait_for(source.search("naruto"), timeout=30)
|
|
healthy, detail = len(results) > 0, f"{len(results)} résultats"
|
|
except (ScrapeError, TimeoutError) as exc:
|
|
healthy, detail = False, str(exc)[:200]
|
|
logger.error("Health check %s KO : %s", name, exc)
|
|
except Exception as exc:
|
|
healthy, detail = False, f"Erreur inattendue : {exc}"[:200]
|
|
logger.exception("Health check %s : erreur inattendue", name)
|
|
state = await set_source_health(name, healthy, detail)
|
|
return {"name": name, **state}
|
|
|
|
|
|
@router.get("/sources")
|
|
async def sources_status(admin: AdminUser) -> list[dict]:
|
|
return [
|
|
{
|
|
"name": s.name,
|
|
"label": s.label,
|
|
"base_url": s.base_url,
|
|
"default_base_url": type(s).base_url,
|
|
"overridden": s.base_url != type(s).base_url,
|
|
"enabled": await is_source_enabled(s.name),
|
|
"health": await get_source_health(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()
|
|
|
|
# ---------------------------------------------------------------- mise à jour logicielle
|
|
|
|
|
|
@router.get("/update")
|
|
async def get_update(admin: AdminUser) -> dict:
|
|
"""Version courante, dernière version disponible et patchnote."""
|
|
return await update_status()
|
|
|
|
|
|
@router.post("/update/check")
|
|
async def check_update(admin: AdminUser) -> dict:
|
|
"""Force la re-vérification de la dernière version (ignore le cache)."""
|
|
await fetch_latest_version(force=True)
|
|
return await update_status()
|
|
|
|
|
|
@router.post("/update/apply")
|
|
async def apply_update(admin: AdminUser) -> dict:
|
|
"""Déclenche la mise à jour via Watchtower (le conteneur est recréé)."""
|
|
try:
|
|
return await trigger_update()
|
|
except UpdateError as exc:
|
|
raise HTTPException(502, str(exc)) from exc
|