Added pushover support
This commit is contained in:
@@ -84,3 +84,34 @@ email:
|
|||||||
log_levels:
|
log_levels:
|
||||||
- "ERROR"
|
- "ERROR"
|
||||||
- "CRITICAL"
|
- "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
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ dependencies = [
|
|||||||
"generateds>=2.44.3",
|
"generateds>=2.44.3",
|
||||||
"httpx>=0.28.1",
|
"httpx>=0.28.1",
|
||||||
"lxml>=6.0.1",
|
"lxml>=6.0.1",
|
||||||
|
"pushover-complete>=2.0.0",
|
||||||
"pydantic[email]>=2.11.9",
|
"pydantic[email]>=2.11.9",
|
||||||
"pytest>=8.4.2",
|
"pytest>=8.4.2",
|
||||||
"pytest-asyncio>=1.2.0",
|
"pytest-asyncio>=1.2.0",
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ from .db import Reservation as DBReservation
|
|||||||
from .email_monitoring import ReservationStatsCollector
|
from .email_monitoring import ReservationStatsCollector
|
||||||
from .email_service import create_email_service
|
from .email_service import create_email_service
|
||||||
from .logging_config import get_logger, setup_logging
|
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 (
|
from .rate_limit import (
|
||||||
BURST_RATE_LIMIT,
|
BURST_RATE_LIMIT,
|
||||||
DEFAULT_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)
|
# Initialize email service (before logging setup so it can be used by handlers)
|
||||||
email_service = create_email_service(config)
|
email_service = create_email_service(config)
|
||||||
|
|
||||||
|
# Initialize pushover service
|
||||||
|
pushover_service = create_pushover_service(config)
|
||||||
|
|
||||||
# Setup logging from config with email monitoring
|
# Setup logging from config with email monitoring
|
||||||
# Only primary worker should have the report scheduler running
|
# Only primary worker should have the report scheduler running
|
||||||
email_handler, report_scheduler = setup_logging(
|
email_handler, report_scheduler = setup_logging(
|
||||||
@@ -246,6 +252,7 @@ async def lifespan(app: FastAPI):
|
|||||||
app.state.alpine_bits_server = AlpineBitsServer(config)
|
app.state.alpine_bits_server = AlpineBitsServer(config)
|
||||||
app.state.event_dispatcher = event_dispatcher
|
app.state.event_dispatcher = event_dispatcher
|
||||||
app.state.email_service = email_service
|
app.state.email_service = email_service
|
||||||
|
app.state.pushover_service = pushover_service
|
||||||
app.state.email_handler = email_handler
|
app.state.email_handler = email_handler
|
||||||
app.state.report_scheduler = report_scheduler
|
app.state.report_scheduler = report_scheduler
|
||||||
|
|
||||||
@@ -304,15 +311,36 @@ async def lifespan(app: FastAPI):
|
|||||||
try:
|
try:
|
||||||
# Use lookback_hours=24 to get stats from last 24 hours
|
# Use lookback_hours=24 to get stats from last 24 hours
|
||||||
stats = await stats_collector.collect_stats(lookback_hours=24)
|
stats = await stats_collector.collect_stats(lookback_hours=24)
|
||||||
success = await email_service.send_daily_report(
|
|
||||||
recipients=report_scheduler.recipients,
|
# Send via email (if configured)
|
||||||
stats=stats,
|
if email_service:
|
||||||
errors=None,
|
success = await email_service.send_daily_report(
|
||||||
)
|
recipients=report_scheduler.recipients,
|
||||||
if success:
|
stats=stats,
|
||||||
_LOGGER.info("Test daily report sent successfully on startup")
|
errors=None,
|
||||||
else:
|
)
|
||||||
_LOGGER.error("Failed to send test daily report on startup")
|
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:
|
except Exception:
|
||||||
_LOGGER.exception("Error sending test daily report on startup")
|
_LOGGER.exception("Error sending test daily report on startup")
|
||||||
|
|
||||||
|
|||||||
@@ -146,6 +146,52 @@ email_schema = Schema(
|
|||||||
extra=PREVENT_EXTRA,
|
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(
|
config_schema = Schema(
|
||||||
{
|
{
|
||||||
Required(CONF_DATABASE): database_schema,
|
Required(CONF_DATABASE): database_schema,
|
||||||
@@ -153,6 +199,7 @@ config_schema = Schema(
|
|||||||
Required(CONF_SERVER): server_info,
|
Required(CONF_SERVER): server_info,
|
||||||
Required(CONF_LOGGING): logger_schema,
|
Required(CONF_LOGGING): logger_schema,
|
||||||
Optional("email"): email_schema, # Email is optional
|
Optional("email"): email_schema, # Email is optional
|
||||||
|
Optional("pushover"): pushover_schema, # Pushover is optional
|
||||||
Optional("api_tokens", default=[]): [str], # API tokens for bearer auth
|
Optional("api_tokens", default=[]): [str], # API tokens for bearer auth
|
||||||
},
|
},
|
||||||
extra=PREVENT_EXTRA,
|
extra=PREVENT_EXTRA,
|
||||||
|
|||||||
127
src/alpine_bits_python/notification_adapters.py
Normal file
127
src/alpine_bits_python/notification_adapters.py
Normal 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,
|
||||||
|
)
|
||||||
177
src/alpine_bits_python/notification_service.py
Normal file
177
src/alpine_bits_python/notification_service.py
Normal 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
|
||||||
268
src/alpine_bits_python/pushover_service.py
Normal file
268
src/alpine_bits_python/pushover_service.py
Normal 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
14
uv.lock
generated
@@ -27,6 +27,7 @@ dependencies = [
|
|||||||
{ name = "generateds" },
|
{ name = "generateds" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "lxml" },
|
{ name = "lxml" },
|
||||||
|
{ name = "pushover-complete" },
|
||||||
{ name = "pydantic", extra = ["email"] },
|
{ name = "pydantic", extra = ["email"] },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "pytest-asyncio" },
|
{ name = "pytest-asyncio" },
|
||||||
@@ -55,6 +56,7 @@ requires-dist = [
|
|||||||
{ name = "generateds", specifier = ">=2.44.3" },
|
{ name = "generateds", specifier = ">=2.44.3" },
|
||||||
{ name = "httpx", specifier = ">=0.28.1" },
|
{ name = "httpx", specifier = ">=0.28.1" },
|
||||||
{ name = "lxml", specifier = ">=6.0.1" },
|
{ name = "lxml", specifier = ">=6.0.1" },
|
||||||
|
{ name = "pushover-complete", specifier = ">=2.0.0" },
|
||||||
{ name = "pydantic", extras = ["email"], specifier = ">=2.11.9" },
|
{ name = "pydantic", extras = ["email"], specifier = ">=2.11.9" },
|
||||||
{ name = "pytest", specifier = ">=8.4.2" },
|
{ name = "pytest", specifier = ">=8.4.2" },
|
||||||
{ name = "pytest-asyncio", specifier = ">=1.2.0" },
|
{ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pydantic"
|
name = "pydantic"
|
||||||
version = "2.11.9"
|
version = "2.11.9"
|
||||||
|
|||||||
Reference in New Issue
Block a user