""" 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"] )