email_notifications #7

Merged
jonas merged 18 commits from email_notifications into main 2025-10-16 13:20:27 +00:00
8 changed files with 702 additions and 9 deletions
Showing only changes of commit eef70516a9 - Show all commits

View File

@@ -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

View File

@@ -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",

View File

@@ -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)
# 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 successfully on startup")
_LOGGER.info("Test daily report sent via email successfully on startup")
else:
_LOGGER.error("Failed to send test daily report on startup")
_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")

View File

@@ -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,

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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

14
uv.lock generated
View File

@@ -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"