homelab_automation/app/schemas/notification.py

393 lines
12 KiB
Python

"""
Schémas Pydantic pour le système de notifications ntfy.
"""
from typing import Optional, List, Dict, Any, Literal
from pydantic import BaseModel, Field, field_validator
import os
class NtfyConfig(BaseModel):
"""Configuration du service de notification ntfy."""
base_url: str = Field(
default="http://localhost:8150",
description="URL de base du serveur ntfy"
)
default_topic: str = Field(
default="homelab-events",
description="Topic par défaut pour les notifications"
)
enabled: bool = Field(
default=True,
description="Activer/désactiver les notifications"
)
timeout: int = Field(
default=5,
ge=1,
le=30,
description="Timeout en secondes pour les requêtes HTTP"
)
username: Optional[str] = Field(
default=None,
description="Nom d'utilisateur pour l'authentification Basic"
)
password: Optional[str] = Field(
default=None,
description="Mot de passe pour l'authentification Basic"
)
token: Optional[str] = Field(
default=None,
description="Token Bearer pour l'authentification"
)
msg_types: List[str] = Field(
default_factory=lambda: ["ALL"],
description=(
"Types de notifications à envoyer: ALL, ERR, WARN "
"(liste séparée par des virgules, ex: 'ERR,WARN')"
),
)
@classmethod
def from_env(cls) -> "NtfyConfig":
"""Crée une configuration à partir des variables d'environnement."""
enabled_str = os.environ.get("NTFY_ENABLED", "true").lower()
enabled = enabled_str in ("true", "1", "yes", "on")
# NTFY_MSG_TYPE: ALL, ERR, WARN, ou combinaison séparée par des virgules
raw_types = os.environ.get("NTFY_MSG_TYPE", "ALL")
tokens = [t.strip().upper() for t in raw_types.split(",") if t.strip()]
# Normaliser et filtrer les valeurs invalides
valid_tokens = {"ALL", "ERR", "WARN"}
selected = [t for t in tokens if t in valid_tokens]
if not selected or "ALL" in selected:
msg_types = ["ALL"]
else:
# Supprimer les doublons en conservant l'ordre
seen = set()
msg_types = []
for t in selected:
if t not in seen:
seen.add(t)
msg_types.append(t)
return cls(
base_url=os.environ.get("NTFY_BASE_URL", "http://localhost:8150"),
default_topic=os.environ.get("NTFY_DEFAULT_TOPIC", "homelab-events"),
enabled=enabled,
timeout=int(os.environ.get("NTFY_TIMEOUT", "5")),
username=os.environ.get("NTFY_USERNAME") or None,
password=os.environ.get("NTFY_PASSWORD") or None,
token=os.environ.get("NTFY_TOKEN") or None,
msg_types=msg_types,
)
@property
def has_auth(self) -> bool:
"""Vérifie si l'authentification est configurée."""
return bool(self.token) or (bool(self.username) and bool(self.password))
@property
def allowed_levels(self) -> set[str]:
"""Retourne les niveaux logiques autorisés: INFO, WARN, ERR.
Mapping:
- ALL -> {INFO, WARN, ERR}
- ERR -> {ERR}
- WARN -> {WARN}
- ERR,WARN -> {ERR, WARN}
"""
# Si ALL est présent, tout est autorisé
if "ALL" in self.msg_types:
return {"INFO", "WARN", "ERR"}
levels: set[str] = set()
for t in self.msg_types:
if t == "ERR":
levels.add("ERR")
elif t == "WARN":
levels.add("WARN")
# Fallback: si config vide/invalide, tout autoriser
return levels or {"INFO", "WARN", "ERR"}
class NtfyAction(BaseModel):
"""Action attachée à une notification ntfy."""
action: Literal["view", "broadcast", "http"] = Field(
default="view",
description="Type d'action"
)
label: str = Field(
...,
description="Texte du bouton d'action"
)
url: Optional[str] = Field(
default=None,
description="URL pour les actions 'view' et 'http'"
)
method: Optional[str] = Field(
default=None,
description="Méthode HTTP pour l'action 'http'"
)
headers: Optional[Dict[str, str]] = Field(
default=None,
description="Headers HTTP pour l'action 'http'"
)
body: Optional[str] = Field(
default=None,
description="Corps de la requête pour l'action 'http'"
)
clear: bool = Field(
default=False,
description="Effacer la notification après l'action"
)
class NotificationRequest(BaseModel):
"""Requête pour envoyer une notification."""
topic: Optional[str] = Field(
default=None,
description="Topic cible (utilise le topic par défaut si non spécifié)"
)
message: str = Field(
...,
min_length=1,
max_length=4096,
description="Corps du message"
)
title: Optional[str] = Field(
default=None,
max_length=250,
description="Titre de la notification"
)
priority: Optional[int] = Field(
default=None,
ge=1,
le=5,
description="Priorité: 1=min, 2=low, 3=default, 4=high, 5=urgent"
)
tags: Optional[List[str]] = Field(
default=None,
description="Tags/emojis (ex: ['warning', 'skull'])"
)
click: Optional[str] = Field(
default=None,
description="URL à ouvrir au clic sur la notification"
)
attach: Optional[str] = Field(
default=None,
description="URL d'une pièce jointe"
)
actions: Optional[List[NtfyAction]] = Field(
default=None,
description="Actions attachées à la notification"
)
delay: Optional[str] = Field(
default=None,
description="Délai avant envoi (ex: '30m', '1h', '2025-01-01T10:00:00')"
)
class NotificationResponse(BaseModel):
"""Réponse après envoi d'une notification."""
success: bool = Field(
...,
description="Indique si l'envoi a réussi"
)
topic: str = Field(
...,
description="Topic utilisé"
)
message_id: Optional[str] = Field(
default=None,
description="ID du message retourné par ntfy"
)
error: Optional[str] = Field(
default=None,
description="Message d'erreur en cas d'échec"
)
# ===== Helpers pour les notifications courantes =====
class NotificationTemplates:
"""Templates de notifications prédéfinis pour les cas d'usage courants."""
@staticmethod
def app_started() -> NotificationRequest:
"""Notification de démarrage de l'application."""
return NotificationRequest(
topic=None,
title="✅ Homelab Dashboard démarré",
message="L'application Homelab Automation Dashboard est maintenant en ligne et opérationnelle.",
priority=3,
tags=["white_check_mark", "rocket"]
)
@staticmethod
def app_stopped() -> NotificationRequest:
"""Notification d'arrêt de l'application."""
return NotificationRequest(
topic=None,
title="⚠️ Homelab Dashboard arrêté",
message="L'application Homelab Automation Dashboard a été arrêtée.",
priority=4,
tags=["warning", "octagonal_sign"]
)
@staticmethod
def backup_success(
hostname: str,
duration: str,
size: Optional[str] = None
) -> NotificationRequest:
"""Notification de succès de backup."""
details = [f"• Hôte : {hostname}"]
if duration:
details.append(f"• Durée : {duration}")
if size:
details.append(f"• Taille : {size}")
return NotificationRequest(
topic=None,
title="✅ Backup terminé avec succès",
message="\n".join(details),
priority=3,
tags=["white_check_mark", "floppy_disk"]
)
@staticmethod
def backup_failed(
hostname: str,
error: str
) -> NotificationRequest:
"""Notification d'échec de backup."""
return NotificationRequest(
topic=None,
title="❌ Échec du backup",
message=f"• Hôte : {hostname}\n• Erreur : {error}",
priority=5,
tags=["x", "warning"]
)
@staticmethod
def bootstrap_started(hostname: str) -> NotificationRequest:
"""Notification de début de bootstrap."""
return NotificationRequest(
topic=None,
title="🔧 Bootstrap en cours",
message=f"Configuration initiale en cours pour l'hôte {hostname}.",
priority=3,
tags=["wrench", "computer"]
)
@staticmethod
def bootstrap_success(hostname: str) -> NotificationRequest:
"""Notification de succès de bootstrap."""
return NotificationRequest(
topic=None,
title="✅ Bootstrap terminé avec succès",
message=f"L'hôte {hostname} est maintenant configuré et prêt pour Ansible.",
priority=3,
tags=["white_check_mark", "computer"]
)
@staticmethod
def bootstrap_failed(hostname: str, error: str) -> NotificationRequest:
"""Notification d'échec de bootstrap."""
return NotificationRequest(
topic=None,
title="❌ Échec du bootstrap",
message=f"• Hôte : {hostname}\n• Erreur : {error}",
priority=5,
tags=["x", "warning"]
)
@staticmethod
def health_status_changed(
hostname: str,
new_status: Literal["up", "down"],
details: Optional[str] = None
) -> NotificationRequest:
"""Notification de changement d'état de santé."""
if new_status == "down":
return NotificationRequest(
topic=None,
title="🔴 Hôte inaccessible",
message=f"L'hôte {hostname} ne répond plus." + (f"\n• Détails : {details}" if details else ""),
priority=5,
tags=["red_circle", "warning"]
)
else:
return NotificationRequest(
topic=None,
title="🟢 Hôte de nouveau accessible",
message=f"L'hôte {hostname} est de nouveau en ligne." + (f"\n• Détails : {details}" if details else ""),
priority=3,
tags=["green_circle", "white_check_mark"]
)
@staticmethod
def task_completed(
task_name: str,
target: str,
duration: Optional[str] = None
) -> NotificationRequest:
"""Notification de tâche terminée."""
lines = [
f"• Tâche : {task_name}",
f"• Cible : {target}",
]
if duration:
lines.append(f"• Durée : {duration}")
return NotificationRequest(
topic=None,
title="✅ Tâche exécutée avec succès",
message="\n".join(lines),
priority=3,
tags=["white_check_mark", "gear"]
)
@staticmethod
def task_failed(
task_name: str,
target: str,
error: str
) -> NotificationRequest:
"""Notification d'échec de tâche."""
return NotificationRequest(
topic=None,
title="❌ Échec de la tâche",
message=f"• Tâche : {task_name}\n• Cible : {target}\n• Erreur : {error}",
priority=5,
tags=["x", "warning"]
)
@staticmethod
def schedule_executed(
schedule_name: str,
success: bool,
details: Optional[str] = None
) -> NotificationRequest:
"""Notification d'exécution de schedule."""
if success:
return NotificationRequest(
topic=None,
title="✅ Planification exécutée avec succès",
message=f"• Schedule : {schedule_name}" + (f"\n• Détails : {details}" if details else ""),
priority=3,
tags=["white_check_mark", "calendar"]
)
else:
return NotificationRequest(
topic=None,
title="❌ Échec de la planification",
message=f"• Schedule : {schedule_name}" + (f"\n• Détails : {details}" if details else ""),
priority=5,
tags=["x", "calendar"]
)