From eef70516a91e380d948b0158a79edf80e1117378 Mon Sep 17 00:00:00 2001 From: Jonas Linter Date: Thu, 16 Oct 2025 11:08:39 +0200 Subject: [PATCH] Added pushover support --- config/config.yaml | 31 ++ pyproject.toml | 1 + src/alpine_bits_python/api.py | 46 ++- src/alpine_bits_python/config_loader.py | 47 +++ .../notification_adapters.py | 127 +++++++++ .../notification_service.py | 177 ++++++++++++ src/alpine_bits_python/pushover_service.py | 268 ++++++++++++++++++ uv.lock | 14 + 8 files changed, 702 insertions(+), 9 deletions(-) create mode 100644 src/alpine_bits_python/notification_adapters.py create mode 100644 src/alpine_bits_python/notification_service.py create mode 100644 src/alpine_bits_python/pushover_service.py diff --git a/config/config.yaml b/config/config.yaml index e70353d..7dbc4c9 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -84,3 +84,34 @@ email: log_levels: - "ERROR" - "CRITICAL" + +# Pushover configuration for push notifications (alternative to email) +pushover: + # Pushover API credentials (get from https://pushover.net) + user_key: !secret PUSHOVER_USER_KEY # Your user/group key + api_token: !secret PUSHOVER_API_TOKEN # Your application API token + + # Monitoring and alerting (same structure as email) + monitoring: + # Daily report configuration + daily_report: + enabled: true # Set to true to enable daily reports + send_time: "08:00" # Time to send daily report (24h format, local time) + include_stats: true # Include reservation/customer stats + include_errors: true # Include error summary + priority: 0 # Pushover priority: -2=lowest, -1=low, 0=normal, 1=high, 2=emergency + + # Error alert configuration (hybrid approach) + error_alerts: + enabled: true # Set to true to enable error alerts + # Alert is sent immediately if threshold is reached + error_threshold: 5 # Send immediate alert after N errors + # Otherwise, alert is sent after buffer time expires + buffer_minutes: 15 # Wait N minutes before sending buffered errors + # Cooldown period to prevent alert spam + cooldown_minutes: 15 # Wait N min before sending another alert + # Error severity levels to monitor + log_levels: + - "ERROR" + - "CRITICAL" + priority: 1 # Pushover priority: -2=lowest, -1=low, 0=normal, 1=high, 2=emergency diff --git a/pyproject.toml b/pyproject.toml index 98846fe..19b289e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "generateds>=2.44.3", "httpx>=0.28.1", "lxml>=6.0.1", + "pushover-complete>=2.0.0", "pydantic[email]>=2.11.9", "pytest>=8.4.2", "pytest-asyncio>=1.2.0", diff --git a/src/alpine_bits_python/api.py b/src/alpine_bits_python/api.py index 0af607b..26c62b3 100644 --- a/src/alpine_bits_python/api.py +++ b/src/alpine_bits_python/api.py @@ -43,6 +43,9 @@ from .db import Reservation as DBReservation from .email_monitoring import ReservationStatsCollector from .email_service import create_email_service from .logging_config import get_logger, setup_logging +from .notification_adapters import EmailNotificationAdapter, PushoverNotificationAdapter +from .notification_service import NotificationService +from .pushover_service import create_pushover_service from .rate_limit import ( BURST_RATE_LIMIT, DEFAULT_RATE_LIMIT, @@ -229,6 +232,9 @@ async def lifespan(app: FastAPI): # Initialize email service (before logging setup so it can be used by handlers) email_service = create_email_service(config) + # Initialize pushover service + pushover_service = create_pushover_service(config) + # Setup logging from config with email monitoring # Only primary worker should have the report scheduler running email_handler, report_scheduler = setup_logging( @@ -246,6 +252,7 @@ async def lifespan(app: FastAPI): app.state.alpine_bits_server = AlpineBitsServer(config) app.state.event_dispatcher = event_dispatcher app.state.email_service = email_service + app.state.pushover_service = pushover_service app.state.email_handler = email_handler app.state.report_scheduler = report_scheduler @@ -304,15 +311,36 @@ async def lifespan(app: FastAPI): try: # Use lookback_hours=24 to get stats from last 24 hours stats = await stats_collector.collect_stats(lookback_hours=24) - success = await email_service.send_daily_report( - recipients=report_scheduler.recipients, - stats=stats, - errors=None, - ) - if success: - _LOGGER.info("Test daily report sent successfully on startup") - else: - _LOGGER.error("Failed to send test daily report on startup") + + # Send via email (if configured) + if email_service: + success = await email_service.send_daily_report( + recipients=report_scheduler.recipients, + stats=stats, + errors=None, + ) + if success: + _LOGGER.info("Test daily report sent via email successfully on startup") + else: + _LOGGER.error("Failed to send test daily report via email on startup") + + # Send via Pushover (if configured) + if pushover_service: + pushover_config = config.get("pushover", {}) + pushover_monitoring = pushover_config.get("monitoring", {}) + pushover_daily_report = pushover_monitoring.get("daily_report", {}) + priority = pushover_daily_report.get("priority", 0) + + success = await pushover_service.send_daily_report( + stats=stats, + errors=None, + priority=priority, + ) + if success: + _LOGGER.info("Test daily report sent via Pushover successfully on startup") + else: + _LOGGER.error("Failed to send test daily report via Pushover on startup") + except Exception: _LOGGER.exception("Error sending test daily report on startup") diff --git a/src/alpine_bits_python/config_loader.py b/src/alpine_bits_python/config_loader.py index 8c34603..6a79854 100644 --- a/src/alpine_bits_python/config_loader.py +++ b/src/alpine_bits_python/config_loader.py @@ -146,6 +146,52 @@ email_schema = Schema( extra=PREVENT_EXTRA, ) +# Pushover daily report configuration schema +pushover_daily_report_schema = Schema( + { + Required("enabled", default=False): Boolean(), + Required("send_time", default="08:00"): str, + Required("include_stats", default=True): Boolean(), + Required("include_errors", default=True): Boolean(), + Required("priority", default=0): Range(min=-2, max=2), # Pushover priority levels + }, + extra=PREVENT_EXTRA, +) + +# Pushover error alerts configuration schema +pushover_error_alerts_schema = Schema( + { + Required("enabled", default=False): Boolean(), + Required("error_threshold", default=5): Range(min=1), + Required("buffer_minutes", default=15): Range(min=1), + Required("cooldown_minutes", default=15): Range(min=0), + Required("log_levels", default=["ERROR", "CRITICAL"]): [ + In(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]) + ], + Required("priority", default=1): Range(min=-2, max=2), # Pushover priority levels + }, + extra=PREVENT_EXTRA, +) + +# Pushover monitoring configuration schema +pushover_monitoring_schema = Schema( + { + Optional("daily_report", default={}): pushover_daily_report_schema, + Optional("error_alerts", default={}): pushover_error_alerts_schema, + }, + extra=PREVENT_EXTRA, +) + +# Complete pushover configuration schema +pushover_schema = Schema( + { + Optional("user_key"): str, # Optional but required for pushover to work + Optional("api_token"): str, # Optional but required for pushover to work + Optional("monitoring", default={}): pushover_monitoring_schema, + }, + extra=PREVENT_EXTRA, +) + config_schema = Schema( { Required(CONF_DATABASE): database_schema, @@ -153,6 +199,7 @@ config_schema = Schema( Required(CONF_SERVER): server_info, Required(CONF_LOGGING): logger_schema, Optional("email"): email_schema, # Email is optional + Optional("pushover"): pushover_schema, # Pushover is optional Optional("api_tokens", default=[]): [str], # API tokens for bearer auth }, extra=PREVENT_EXTRA, diff --git a/src/alpine_bits_python/notification_adapters.py b/src/alpine_bits_python/notification_adapters.py new file mode 100644 index 0000000..5cfb7fa --- /dev/null +++ b/src/alpine_bits_python/notification_adapters.py @@ -0,0 +1,127 @@ +"""Adapters for notification backends. + +This module provides adapters that wrap email and Pushover services +to work with the unified notification service interface. +""" + +from typing import Any + +from .email_service import EmailService +from .logging_config import get_logger +from .pushover_service import PushoverService + +_LOGGER = get_logger(__name__) + + +class EmailNotificationAdapter: + """Adapter for EmailService to work with NotificationService.""" + + def __init__(self, email_service: EmailService, recipients: list[str]): + """Initialize the email notification adapter. + + Args: + email_service: EmailService instance + recipients: List of recipient email addresses + + """ + self.email_service = email_service + self.recipients = recipients + + async def send_alert(self, title: str, message: str, **kwargs) -> bool: + """Send an alert via email. + + Args: + title: Email subject + message: Email body + **kwargs: Ignored for email + + Returns: + True if sent successfully + + """ + return await self.email_service.send_alert( + recipients=self.recipients, + subject=title, + body=message, + ) + + async def send_daily_report( + self, + stats: dict[str, Any], + errors: list[dict[str, Any]] | None = None, + **kwargs, + ) -> bool: + """Send a daily report via email. + + Args: + stats: Statistics dictionary + errors: Optional list of errors + **kwargs: Ignored for email + + Returns: + True if sent successfully + + """ + return await self.email_service.send_daily_report( + recipients=self.recipients, + stats=stats, + errors=errors, + ) + + +class PushoverNotificationAdapter: + """Adapter for PushoverService to work with NotificationService.""" + + def __init__(self, pushover_service: PushoverService, priority: int = 0): + """Initialize the Pushover notification adapter. + + Args: + pushover_service: PushoverService instance + priority: Default priority level for notifications + + """ + self.pushover_service = pushover_service + self.priority = priority + + async def send_alert(self, title: str, message: str, **kwargs) -> bool: + """Send an alert via Pushover. + + Args: + title: Notification title + message: Notification message + **kwargs: Can include 'priority' to override default + + Returns: + True if sent successfully + + """ + priority = kwargs.get("priority", self.priority) + return await self.pushover_service.send_alert( + title=title, + message=message, + priority=priority, + ) + + async def send_daily_report( + self, + stats: dict[str, Any], + errors: list[dict[str, Any]] | None = None, + **kwargs, + ) -> bool: + """Send a daily report via Pushover. + + Args: + stats: Statistics dictionary + errors: Optional list of errors + **kwargs: Can include 'priority' to override default + + Returns: + True if sent successfully + + """ + priority = kwargs.get("priority", self.priority) + return await self.pushover_service.send_daily_report( + stats=stats, + errors=errors, + priority=priority, + ) diff --git a/src/alpine_bits_python/notification_service.py b/src/alpine_bits_python/notification_service.py new file mode 100644 index 0000000..076a397 --- /dev/null +++ b/src/alpine_bits_python/notification_service.py @@ -0,0 +1,177 @@ +"""Unified notification service supporting multiple backends. + +This module provides a unified interface for sending notifications through +different channels (email, Pushover, etc.) for alerts and daily reports. +""" + +from typing import Any, Protocol + +from .logging_config import get_logger + +_LOGGER = get_logger(__name__) + + +class NotificationBackend(Protocol): + """Protocol for notification backends.""" + + async def send_alert(self, title: str, message: str, **kwargs) -> bool: + """Send an alert notification. + + Args: + title: Alert title/subject + message: Alert message/body + **kwargs: Backend-specific parameters + + Returns: + True if sent successfully, False otherwise + + """ + ... + + async def send_daily_report( + self, + stats: dict[str, Any], + errors: list[dict[str, Any]] | None = None, + **kwargs, + ) -> bool: + """Send a daily report notification. + + Args: + stats: Statistics dictionary + errors: Optional list of errors + **kwargs: Backend-specific parameters + + Returns: + True if sent successfully, False otherwise + + """ + ... + + +class NotificationService: + """Unified notification service that supports multiple backends. + + This service can send notifications through multiple channels simultaneously + (email, Pushover, etc.) based on configuration. + """ + + def __init__(self): + """Initialize the notification service.""" + self.backends: dict[str, NotificationBackend] = {} + + def register_backend(self, name: str, backend: NotificationBackend) -> None: + """Register a notification backend. + + Args: + name: Backend name (e.g., "email", "pushover") + backend: Backend instance implementing NotificationBackend protocol + + """ + self.backends[name] = backend + _LOGGER.info("Registered notification backend: %s", name) + + async def send_alert( + self, + title: str, + message: str, + backends: list[str] | None = None, + **kwargs, + ) -> dict[str, bool]: + """Send an alert through specified backends. + + Args: + title: Alert title/subject + message: Alert message/body + backends: List of backend names to use (None = all registered) + **kwargs: Backend-specific parameters + + Returns: + Dictionary mapping backend names to success status + + """ + if backends is None: + backends = list(self.backends.keys()) + + results = {} + for backend_name in backends: + backend = self.backends.get(backend_name) + if backend is None: + _LOGGER.warning("Backend not found: %s", backend_name) + results[backend_name] = False + continue + + try: + success = await backend.send_alert(title, message, **kwargs) + results[backend_name] = success + except Exception: + _LOGGER.exception( + "Error sending alert through backend %s", backend_name + ) + results[backend_name] = False + + return results + + async def send_daily_report( + self, + stats: dict[str, Any], + errors: list[dict[str, Any]] | None = None, + backends: list[str] | None = None, + **kwargs, + ) -> dict[str, bool]: + """Send a daily report through specified backends. + + Args: + stats: Statistics dictionary + errors: Optional list of errors + backends: List of backend names to use (None = all registered) + **kwargs: Backend-specific parameters + + Returns: + Dictionary mapping backend names to success status + + """ + if backends is None: + backends = list(self.backends.keys()) + + results = {} + for backend_name in backends: + backend = self.backends.get(backend_name) + if backend is None: + _LOGGER.warning("Backend not found: %s", backend_name) + results[backend_name] = False + continue + + try: + success = await backend.send_daily_report(stats, errors, **kwargs) + results[backend_name] = success + except Exception: + _LOGGER.exception( + "Error sending daily report through backend %s", backend_name + ) + results[backend_name] = False + + return results + + def get_backend(self, name: str) -> NotificationBackend | None: + """Get a specific notification backend. + + Args: + name: Backend name + + Returns: + Backend instance or None if not found + + """ + return self.backends.get(name) + + def has_backend(self, name: str) -> bool: + """Check if a backend is registered. + + Args: + name: Backend name + + Returns: + True if backend is registered + + """ + return name in self.backends diff --git a/src/alpine_bits_python/pushover_service.py b/src/alpine_bits_python/pushover_service.py new file mode 100644 index 0000000..69aa955 --- /dev/null +++ b/src/alpine_bits_python/pushover_service.py @@ -0,0 +1,268 @@ +"""Pushover service for sending push notifications. + +This module provides push notification functionality for the AlpineBits application, +including error alerts and daily reports via Pushover. +""" + +import asyncio +from datetime import datetime +from typing import Any + +from pushover_complete import PushoverAPI + +from .logging_config import get_logger + +_LOGGER = get_logger(__name__) + + +class PushoverConfig: + """Configuration for Pushover service. + + Attributes: + user_key: Pushover user/group key + api_token: Pushover application API token + + """ + + def __init__(self, config: dict[str, Any]): + """Initialize Pushover configuration from config dict. + + Args: + config: Pushover configuration dictionary + + """ + self.user_key: str | None = config.get("user_key") + self.api_token: str | None = config.get("api_token") + + # Validate configuration + if not self.user_key or not self.api_token: + msg = "Both user_key and api_token are required for Pushover" + raise ValueError(msg) + + +class PushoverService: + """Service for sending push notifications via Pushover. + + This service handles sending notifications through the Pushover API, + including alerts and daily reports. + """ + + def __init__(self, config: PushoverConfig): + """Initialize Pushover service. + + Args: + config: Pushover configuration + + """ + self.config = config + self.api = PushoverAPI(config.api_token) + + async def send_notification( + self, + title: str, + message: str, + priority: int = 0, + url: str | None = None, + url_title: str | None = None, + ) -> bool: + """Send a push notification via Pushover. + + Args: + title: Notification title + message: Notification message + priority: Priority level (-2 to 2, default 0) + url: Optional supplementary URL + url_title: Optional title for the URL + + Returns: + True if notification was sent successfully, False otherwise + + """ + try: + # Send notification in thread pool (API is blocking) + loop = asyncio.get_event_loop() + await loop.run_in_executor( + None, + self._send_pushover, + title, + message, + priority, + url, + url_title, + ) + + _LOGGER.info("Pushover notification sent successfully: %s", title) + return True + + except Exception: + _LOGGER.exception("Failed to send Pushover notification: %s", title) + return False + + def _send_pushover( + self, + title: str, + message: str, + priority: int, + url: str | None, + url_title: str | None, + ) -> None: + """Send notification via Pushover (blocking operation). + + Args: + title: Notification title + message: Notification message + priority: Priority level + url: Optional URL + url_title: Optional URL title + + Raises: + Exception: If notification sending fails + + """ + kwargs = { + "user": self.config.user_key, + "title": title, + "message": message, + "priority": priority, + } + + if url: + kwargs["url"] = url + if url_title: + kwargs["url_title"] = url_title + + self.api.send_message(**kwargs) + + async def send_alert( + self, + title: str, + message: str, + priority: int = 1, + ) -> bool: + """Send an alert notification (convenience method). + + Args: + title: Alert title + message: Alert message + priority: Priority level (default 1 for high priority) + + Returns: + True if notification was sent successfully, False otherwise + + """ + return await self.send_notification(title, message, priority=priority) + + async def send_daily_report( + self, + stats: dict[str, Any], + errors: list[dict[str, Any]] | None = None, + priority: int = 0, + ) -> bool: + """Send a daily report notification. + + Args: + stats: Dictionary containing statistics to include in report + errors: Optional list of errors to include + priority: Priority level (default 0 for normal) + + Returns: + True if notification was sent successfully, False otherwise + + """ + date_str = datetime.now().strftime("%Y-%m-%d") + title = f"AlpineBits Daily Report - {date_str}" + + # Build message body (Pushover has a 1024 character limit) + message = self._build_daily_report_message(date_str, stats, errors) + + return await self.send_notification(title, message, priority=priority) + + def _build_daily_report_message( + self, + date_str: str, + stats: dict[str, Any], + errors: list[dict[str, Any]] | None, + ) -> str: + """Build daily report message for Pushover. + + Args: + date_str: Date string for the report + stats: Statistics dictionary + errors: Optional list of errors + + Returns: + Formatted message string (max 1024 chars for Pushover) + + """ + lines = [f"Report for {date_str}", ""] + + # Add statistics (simplified for push notification) + if stats: + # Handle reporting period + period = stats.get("reporting_period", {}) + if period: + start = period.get("start", "") + end = period.get("end", "") + if start and end: + # Extract just the time portion + start_time = start.split(" ")[1] if " " in start else start + end_time = end.split(" ")[1] if " " in end else end + lines.append(f"Period: {start_time} - {end_time}") + + # Total reservations + total = stats.get("total_reservations", 0) + lines.append(f"Total Reservations: {total}") + + # Per-hotel breakdown (top 5 only to save space) + hotels = stats.get("hotels", []) + if hotels: + lines.append("") + lines.append("By Hotel:") + for hotel in hotels[:5]: # Top 5 hotels + hotel_name = hotel.get("hotel_name", "Unknown") + count = hotel.get("reservations", 0) + # Truncate long hotel names + if len(hotel_name) > 20: + hotel_name = hotel_name[:17] + "..." + lines.append(f" • {hotel_name}: {count}") + + if len(hotels) > 5: + lines.append(f" • ... and {len(hotels) - 5} more") + + # Add error summary if present + if errors: + lines.append("") + lines.append(f"Errors: {len(errors)} (see logs)") + + message = "\n".join(lines) + + # Truncate if too long (Pushover limit is 1024 chars) + if len(message) > 1020: + message = message[:1017] + "..." + + return message + + +def create_pushover_service(config: dict[str, Any]) -> PushoverService | None: + """Create a Pushover service from configuration. + + Args: + config: Full application configuration dictionary + + Returns: + PushoverService instance if Pushover is configured, None otherwise + + """ + pushover_config = config.get("pushover") + if not pushover_config: + _LOGGER.info("Pushover not configured, push notification service disabled") + return None + + try: + pushover_cfg = PushoverConfig(pushover_config) + service = PushoverService(pushover_cfg) + _LOGGER.info("Pushover service initialized successfully") + return service + except Exception: + _LOGGER.exception("Failed to initialize Pushover service") + return None diff --git a/uv.lock b/uv.lock index 488bb2b..5f02cd1 100644 --- a/uv.lock +++ b/uv.lock @@ -27,6 +27,7 @@ dependencies = [ { name = "generateds" }, { name = "httpx" }, { name = "lxml" }, + { name = "pushover-complete" }, { name = "pydantic", extra = ["email"] }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -55,6 +56,7 @@ requires-dist = [ { name = "generateds", specifier = ">=2.44.3" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "lxml", specifier = ">=6.0.1" }, + { name = "pushover-complete", specifier = ">=2.0.0" }, { name = "pydantic", extras = ["email"], specifier = ">=2.11.9" }, { name = "pytest", specifier = ">=8.4.2" }, { name = "pytest-asyncio", specifier = ">=1.2.0" }, @@ -646,6 +648,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cc/35/cc0aaecf278bb4575b8555f2b137de5ab821595ddae9da9d3cd1da4072c7/propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f", size = 12663, upload-time = "2025-06-09T22:56:04.484Z" }, ] +[[package]] +name = "pushover-complete" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/ae/2ed5c277e22316d8a31e2f67c6c9fd5021189ed3754e144aad53d874d687/pushover_complete-2.0.0.tar.gz", hash = "sha256:24fc7d84d73426840e7678fee80d36f40df0114cb30352ba4f99ab3842ed21a7", size = 19035, upload-time = "2025-05-20T12:47:59.464Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c2/7debacdeb30d5956e5c5573f129ea2a422eeaaba8993ddfc61c9c0e54c95/pushover_complete-2.0.0-py3-none-any.whl", hash = "sha256:9dbb540daf86b26375e0aaa4b798ad5936b27047ee82cf3213bafeee96929527", size = 9952, upload-time = "2025-05-20T12:47:58.248Z" }, +] + [[package]] name = "pydantic" version = "2.11.9"