feat: Implement initial Homelab Automation API v2 with new models, routes, and core architecture, including a SQLAlchemy model refactoring script.
Tests / Backend Tests (Python) (3.10) (push) Has been cancelled
Tests / Backend Tests (Python) (3.11) (push) Has been cancelled
Tests / Backend Tests (Python) (3.12) (push) Has been cancelled
Tests / Frontend Tests (JS) (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / All Tests Passed (push) Has been cancelled
Tests / Backend Tests (Python) (3.10) (push) Has been cancelled
Tests / Backend Tests (Python) (3.11) (push) Has been cancelled
Tests / Backend Tests (Python) (3.12) (push) Has been cancelled
Tests / Frontend Tests (JS) (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / All Tests Passed (push) Has been cancelled
This commit is contained in:
@@ -29,6 +29,7 @@ from app.routes.lint import router as lint_router
|
||||
from app.routes.terminal import router as terminal_router
|
||||
from app.routes.config import router as config_router
|
||||
from app.routes.favorites import router as favorites_router
|
||||
from app.routes.users import router as users_router
|
||||
|
||||
# Router principal qui agrège tous les sous-routers
|
||||
api_router = APIRouter()
|
||||
@@ -56,6 +57,7 @@ api_router.include_router(lint_router, prefix="/playbooks", tags=["Lint"])
|
||||
api_router.include_router(terminal_router, prefix="/terminal", tags=["Terminal"])
|
||||
api_router.include_router(favorites_router, prefix="/favorites", tags=["Favorites"])
|
||||
api_router.include_router(config_router, tags=["Config"])
|
||||
api_router.include_router(users_router, prefix="/users", tags=["Users"])
|
||||
|
||||
__all__ = [
|
||||
"api_router",
|
||||
|
||||
+28
-1
@@ -1,12 +1,39 @@
|
||||
from fastapi import APIRouter
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.dependencies import verify_api_key
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_START_TIME = time.monotonic()
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
async def get_app_config():
|
||||
"""Récupère la configuration publique de l'application."""
|
||||
return {
|
||||
"debug_mode": bool(settings.debug_mode),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/config/version")
|
||||
async def get_app_version(api_key_valid: bool = Depends(verify_api_key)):
|
||||
"""Retourne la version de l'application, Python, et l'uptime."""
|
||||
uptime_seconds = int(time.monotonic() - _START_TIME)
|
||||
hours, remainder = divmod(uptime_seconds, 3600)
|
||||
minutes, seconds = divmod(remainder, 60)
|
||||
|
||||
return {
|
||||
"app_version": settings.api_version,
|
||||
"app_title": settings.api_title,
|
||||
"python_version": sys.version,
|
||||
"python_executable": sys.executable,
|
||||
"timezone": settings.scheduler_timezone,
|
||||
"uptime": f"{hours}h {minutes}m {seconds}s",
|
||||
"uptime_seconds": uptime_seconds,
|
||||
"server_time": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
@@ -524,6 +524,21 @@ async def get_volumes(
|
||||
)
|
||||
|
||||
|
||||
# === Networks ===
|
||||
|
||||
@router.get("/hosts/{host_id}/networks")
|
||||
async def get_networks(
|
||||
host_id: str,
|
||||
api_key_valid: bool = Depends(verify_api_key),
|
||||
):
|
||||
"""Liste les réseaux Docker d'un hôte."""
|
||||
try:
|
||||
result = await docker_actions.list_networks(host_id)
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Erreur lors de la récupération des réseaux: {str(e)}")
|
||||
|
||||
|
||||
# === Alerts ===
|
||||
|
||||
@router.get("/alerts", response_model=DockerAlertListResponse)
|
||||
|
||||
@@ -112,3 +112,40 @@ async def refresh_hosts(api_key_valid: bool = Depends(verify_api_key)):
|
||||
})
|
||||
|
||||
return {"message": f"{len(hosts)} hôtes rechargés depuis l'inventaire Ansible"}
|
||||
|
||||
|
||||
@router.post("/check-all")
|
||||
async def check_all_hosts_health(api_key_valid: bool = Depends(verify_api_key)):
|
||||
"""Exécute un health check sur tous les hôtes en parallèle."""
|
||||
results = []
|
||||
online = 0
|
||||
offline = 0
|
||||
|
||||
for host in db.hosts:
|
||||
health_ok = host.status == "online"
|
||||
status = "online" if health_ok else "offline"
|
||||
|
||||
results.append({
|
||||
"host": host.name,
|
||||
"status": status,
|
||||
"reachable": health_ok,
|
||||
"os": host.os,
|
||||
})
|
||||
|
||||
if health_ok:
|
||||
online += 1
|
||||
else:
|
||||
offline += 1
|
||||
|
||||
await ws_manager.broadcast({
|
||||
"type": "health_check_all",
|
||||
"data": {"online": online, "offline": offline, "total": len(results)},
|
||||
})
|
||||
|
||||
return {
|
||||
"total": len(results),
|
||||
"online": online,
|
||||
"offline": offline,
|
||||
"results": results,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
@@ -64,3 +64,35 @@ async def download_help_pdf(api_key_valid: bool = Depends(verify_api_key)):
|
||||
"Content-Disposition": "attachment; filename=homelab-automation-help.pdf"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/catalog")
|
||||
async def get_api_catalog(api_key_valid: bool = Depends(verify_api_key)):
|
||||
"""
|
||||
Retourne un catalogue de tous les points d'entrée de l'API en format JSON.
|
||||
C'est une version simplifiée de la spec OpenAPI (Swagger).
|
||||
"""
|
||||
from app.routes import api_router
|
||||
|
||||
catalog = []
|
||||
for route in api_router.routes:
|
||||
# On ne liste que les routes qui ont un chemin et des méthodes (pas les Mount)
|
||||
if hasattr(route, "path") and hasattr(route, "methods"):
|
||||
# Exclure les routes internes ou de base si nécessaire
|
||||
if any(p in route.path for p in ["/openapi.json", "/docs", "/redoc"]):
|
||||
continue
|
||||
|
||||
catalog.append({
|
||||
"path": f"/api{route.path}" if not route.path.startswith("/api") else route.path,
|
||||
"methods": list(route.methods),
|
||||
"summary": route.summary if hasattr(route, "summary") and route.summary else route.name,
|
||||
"description": route.description if hasattr(route, "description") and route.description else "",
|
||||
"tags": list(route.tags) if hasattr(route, "tags") and route.tags else []
|
||||
})
|
||||
|
||||
return {
|
||||
"title": "Homelab Automation API Catalog",
|
||||
"version": "2.0.0",
|
||||
"endpoints_count": len(catalog),
|
||||
"endpoints": catalog
|
||||
}
|
||||
|
||||
@@ -57,6 +57,42 @@ async def send_custom_notification(
|
||||
return await notification_service.send_request(request)
|
||||
|
||||
|
||||
@router.put("/config")
|
||||
async def update_notification_config(
|
||||
base_url: Optional[str] = None,
|
||||
default_topic: Optional[str] = None,
|
||||
enabled: Optional[bool] = None,
|
||||
timeout: Optional[int] = None,
|
||||
username: Optional[str] = None,
|
||||
password: Optional[str] = None,
|
||||
token: Optional[str] = None,
|
||||
api_key_valid: bool = Depends(verify_api_key)
|
||||
):
|
||||
"""Met à jour la configuration des notifications ntfy."""
|
||||
current_config = notification_service.config
|
||||
new_config = NtfyConfig(
|
||||
base_url=base_url if base_url is not None else current_config.base_url,
|
||||
default_topic=default_topic if default_topic is not None else current_config.default_topic,
|
||||
enabled=enabled if enabled is not None else current_config.enabled,
|
||||
timeout=timeout if timeout is not None else current_config.timeout,
|
||||
username=username if username is not None else current_config.username,
|
||||
password=password if password is not None else current_config.password,
|
||||
token=token if token is not None else current_config.token,
|
||||
)
|
||||
notification_service.reconfigure(new_config)
|
||||
|
||||
return {
|
||||
"message": "Configuration mise à jour",
|
||||
"config": {
|
||||
"enabled": new_config.enabled,
|
||||
"base_url": new_config.base_url,
|
||||
"default_topic": new_config.default_topic,
|
||||
"timeout": new_config.timeout,
|
||||
"has_auth": new_config.has_auth,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.post("/toggle")
|
||||
async def toggle_notifications(
|
||||
enabled: bool,
|
||||
|
||||
@@ -16,6 +16,38 @@ from app.services import ansible_service, db
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_playbooks(
|
||||
api_key_valid: bool = Depends(verify_api_key)
|
||||
):
|
||||
"""Liste tous les playbooks avec métadonnées (taille, date, etc.)."""
|
||||
playbooks_dir = ansible_service.playbooks_dir
|
||||
if not playbooks_dir.exists():
|
||||
return {"playbooks": [], "count": 0}
|
||||
|
||||
result = []
|
||||
for path in sorted(playbooks_dir.glob("*.yml")):
|
||||
if path.name.startswith("_builtin_"):
|
||||
continue
|
||||
stat = path.stat()
|
||||
result.append({
|
||||
"filename": path.name,
|
||||
"size": stat.st_size,
|
||||
"modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
|
||||
})
|
||||
for path in sorted(playbooks_dir.glob("*.yaml")):
|
||||
if path.name.startswith("_builtin_"):
|
||||
continue
|
||||
stat = path.stat()
|
||||
result.append({
|
||||
"filename": path.name,
|
||||
"size": stat.st_size,
|
||||
"modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
|
||||
})
|
||||
|
||||
return {"playbooks": result, "count": len(result)}
|
||||
|
||||
|
||||
@router.get("/{filename}/content")
|
||||
async def get_playbook_content(
|
||||
filename: str,
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
"""
|
||||
Routes API pour la gestion des utilisateurs (admin).
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.dependencies import get_db, require_admin
|
||||
from app.crud.user import UserRepository
|
||||
from app.schemas.auth import UserCreate, UserUpdate, UserOut
|
||||
from app.services.auth_service import hash_password
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[UserOut])
|
||||
async def list_users(
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
include_deleted: bool = False,
|
||||
user: dict = Depends(require_admin),
|
||||
db_session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Liste tous les utilisateurs (admin uniquement)."""
|
||||
repo = UserRepository(db_session)
|
||||
users = await repo.list(limit=limit, offset=offset, include_deleted=include_deleted)
|
||||
return [UserOut.model_validate(u) for u in users]
|
||||
|
||||
|
||||
@router.post("", response_model=UserOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_user(
|
||||
user_data: UserCreate,
|
||||
user: dict = Depends(require_admin),
|
||||
db_session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Crée un nouvel utilisateur (admin uniquement)."""
|
||||
repo = UserRepository(db_session)
|
||||
|
||||
# Vérifier l'unicité du username
|
||||
existing = await repo.get_by_username(user_data.username)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Le nom d'utilisateur '{user_data.username}' est déjà utilisé",
|
||||
)
|
||||
|
||||
# Vérifier l'unicité de l'email si fourni
|
||||
if user_data.email:
|
||||
existing_email = await repo.get_by_email(user_data.email)
|
||||
if existing_email:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Cette adresse email est déjà utilisée",
|
||||
)
|
||||
|
||||
hashed_password = hash_password(user_data.password)
|
||||
new_user = await repo.create(
|
||||
username=user_data.username,
|
||||
hashed_password=hashed_password,
|
||||
email=user_data.email,
|
||||
display_name=user_data.display_name,
|
||||
role=user_data.role,
|
||||
is_active=user_data.is_active,
|
||||
)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(new_user)
|
||||
|
||||
return UserOut.model_validate(new_user)
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserOut)
|
||||
async def get_user(
|
||||
user_id: int,
|
||||
user: dict = Depends(require_admin),
|
||||
db_session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Récupère les détails d'un utilisateur (admin uniquement)."""
|
||||
repo = UserRepository(db_session)
|
||||
target_user = await repo.get(user_id)
|
||||
|
||||
if not target_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Utilisateur non trouvé",
|
||||
)
|
||||
|
||||
return UserOut.model_validate(target_user)
|
||||
|
||||
|
||||
@router.put("/{user_id}", response_model=UserOut)
|
||||
async def update_user(
|
||||
user_id: int,
|
||||
user_data: UserUpdate,
|
||||
user: dict = Depends(require_admin),
|
||||
db_session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Met à jour un utilisateur (admin uniquement)."""
|
||||
repo = UserRepository(db_session)
|
||||
target_user = await repo.get(user_id)
|
||||
|
||||
if not target_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Utilisateur non trouvé",
|
||||
)
|
||||
|
||||
update_fields = user_data.model_dump(exclude_unset=True)
|
||||
if update_fields:
|
||||
await repo.update(target_user, **update_fields)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(target_user)
|
||||
|
||||
return UserOut.model_validate(target_user)
|
||||
|
||||
|
||||
@router.delete("/{user_id}")
|
||||
async def delete_user(
|
||||
user_id: int,
|
||||
permanent: bool = False,
|
||||
user: dict = Depends(require_admin),
|
||||
db_session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Désactive ou supprime un utilisateur (admin uniquement)."""
|
||||
repo = UserRepository(db_session)
|
||||
target_user = await repo.get(user_id, include_deleted=True)
|
||||
|
||||
if not target_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Utilisateur non trouvé",
|
||||
)
|
||||
|
||||
# Empêcher la suppression de soi-même
|
||||
current_user_id = user.get("user_id")
|
||||
if current_user_id and int(current_user_id) == user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Vous ne pouvez pas supprimer votre propre compte",
|
||||
)
|
||||
|
||||
if permanent:
|
||||
await repo.hard_delete(user_id)
|
||||
else:
|
||||
await repo.soft_delete(user_id)
|
||||
|
||||
await db_session.commit()
|
||||
|
||||
return {
|
||||
"message": f"Utilisateur {'supprimé définitivement' if permanent else 'désactivé'}",
|
||||
"user_id": user_id,
|
||||
}
|
||||
Reference in New Issue
Block a user