412 lines
15 KiB
Python
412 lines
15 KiB
Python
|
|
"""
|
||
|
|
Reminder Notification Service
|
||
|
|
Handles multi-channel delivery of reminders (Mattermost, Email, Frontend)
|
||
|
|
Includes rate limiting and user preference merging
|
||
|
|
"""
|
||
|
|
|
||
|
|
import logging
|
||
|
|
import asyncio
|
||
|
|
from typing import List, Dict, Optional, Tuple
|
||
|
|
from datetime import datetime, timedelta
|
||
|
|
import json
|
||
|
|
from jinja2 import Template
|
||
|
|
|
||
|
|
from app.core.config import settings
|
||
|
|
from app.core.database import execute_query, execute_insert
|
||
|
|
from app.services.email_service import EmailService
|
||
|
|
from app.backups.backend.notifications import MattermostNotification
|
||
|
|
|
||
|
|
logger = logging.getLogger(__name__)
|
||
|
|
|
||
|
|
|
||
|
|
class ReminderNotificationService:
|
||
|
|
"""Service for sending reminders via multiple notification channels"""
|
||
|
|
|
||
|
|
def __init__(self):
|
||
|
|
self.email_service = EmailService()
|
||
|
|
self.mattermost_service = MattermostNotification()
|
||
|
|
self.dry_run = settings.REMINDERS_DRY_RUN
|
||
|
|
self.max_per_hour = settings.REMINDERS_MAX_PER_USER_PER_HOUR
|
||
|
|
|
||
|
|
async def send_reminder(
|
||
|
|
self,
|
||
|
|
reminder_id: int,
|
||
|
|
sag_id: int,
|
||
|
|
case_title: str,
|
||
|
|
customer_name: str,
|
||
|
|
reminder_title: str,
|
||
|
|
reminder_message: Optional[str],
|
||
|
|
recipient_user_ids: List[int],
|
||
|
|
recipient_emails: List[str],
|
||
|
|
priority: str = "normal",
|
||
|
|
notify_mattermost: Optional[bool] = None,
|
||
|
|
notify_email: Optional[bool] = None,
|
||
|
|
notify_frontend: Optional[bool] = None,
|
||
|
|
override_user_preferences: bool = False,
|
||
|
|
additional_info: Optional[str] = None,
|
||
|
|
case_status: Optional[str] = None,
|
||
|
|
deadline: Optional[str] = None,
|
||
|
|
assigned_user: Optional[str] = None
|
||
|
|
) -> Dict[str, any]:
|
||
|
|
"""
|
||
|
|
Send reminder via configured notification channels
|
||
|
|
|
||
|
|
Returns dict with:
|
||
|
|
{
|
||
|
|
'success': bool,
|
||
|
|
'channels_used': List[str],
|
||
|
|
'errors': List[str],
|
||
|
|
'rate_limited_users': List[int],
|
||
|
|
'logged_id': int
|
||
|
|
}
|
||
|
|
"""
|
||
|
|
|
||
|
|
result = {
|
||
|
|
'success': False,
|
||
|
|
'channels_used': [],
|
||
|
|
'errors': [],
|
||
|
|
'rate_limited_users': [],
|
||
|
|
'logged_id': None
|
||
|
|
}
|
||
|
|
|
||
|
|
if self.dry_run:
|
||
|
|
logger.warning(f"🔒 DRY RUN: Would send reminder '{reminder_title}' for case #{sag_id}")
|
||
|
|
|
||
|
|
# Get case details
|
||
|
|
case_query = "SELECT id, titel FROM sag_sager WHERE id = %s"
|
||
|
|
case = execute_query(case_query, (sag_id,))
|
||
|
|
if not case:
|
||
|
|
logger.error(f"❌ Case #{sag_id} not found")
|
||
|
|
result['errors'].append(f"Case #{sag_id} not found")
|
||
|
|
return result
|
||
|
|
|
||
|
|
# Process each recipient user
|
||
|
|
for user_id in recipient_user_ids:
|
||
|
|
try:
|
||
|
|
# Check rate limit
|
||
|
|
if not await self._check_rate_limit(user_id):
|
||
|
|
logger.warning(f"⚠️ Rate limit exceeded for user {user_id}")
|
||
|
|
result['rate_limited_users'].append(user_id)
|
||
|
|
continue
|
||
|
|
|
||
|
|
# Get user preferences
|
||
|
|
user_prefs = await self._get_user_preferences(user_id)
|
||
|
|
|
||
|
|
# Merge with reminder-specific overrides
|
||
|
|
channels = self._determine_channels(
|
||
|
|
user_prefs,
|
||
|
|
notify_mattermost,
|
||
|
|
notify_email,
|
||
|
|
notify_frontend,
|
||
|
|
override_user_preferences
|
||
|
|
)
|
||
|
|
|
||
|
|
# Get user email
|
||
|
|
user_query = "SELECT email FROM users WHERE id = %s"
|
||
|
|
user = execute_query(user_query, (user_id,))
|
||
|
|
user_email = user[0]['email'] if user else None
|
||
|
|
|
||
|
|
# Send via channels
|
||
|
|
for channel in channels:
|
||
|
|
try:
|
||
|
|
if channel == 'mattermost' and settings.REMINDERS_MATTERMOST_ENABLED:
|
||
|
|
await self._send_mattermost(
|
||
|
|
reminder_title, reminder_message, sag_id, case_title,
|
||
|
|
priority, additional_info
|
||
|
|
)
|
||
|
|
result['channels_used'].append('mattermost')
|
||
|
|
|
||
|
|
elif channel == 'email' and settings.REMINDERS_EMAIL_ENABLED and user_email:
|
||
|
|
await self._send_email(
|
||
|
|
user_email, reminder_title, reminder_message,
|
||
|
|
sag_id, case_title, customer_name, priority,
|
||
|
|
case_status, deadline, assigned_user, additional_info
|
||
|
|
)
|
||
|
|
result['channels_used'].append('email')
|
||
|
|
|
||
|
|
elif channel == 'frontend':
|
||
|
|
# Frontend notifications are handled by polling, no action needed here
|
||
|
|
result['channels_used'].append('frontend')
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
error = f"Failed to send via {channel}: {str(e)}"
|
||
|
|
logger.error(f"❌ {error}")
|
||
|
|
result['errors'].append(error)
|
||
|
|
|
||
|
|
# Log notification
|
||
|
|
log_id = await self._log_reminder(
|
||
|
|
reminder_id, sag_id, user_id, result['channels_used'],
|
||
|
|
{
|
||
|
|
'title': reminder_title,
|
||
|
|
'message': reminder_message,
|
||
|
|
'case': case_title,
|
||
|
|
'priority': priority
|
||
|
|
},
|
||
|
|
'sent' if result['channels_used'] else 'failed'
|
||
|
|
)
|
||
|
|
result['logged_id'] = log_id
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
logger.error(f"❌ Error sending reminder to user {user_id}: {e}")
|
||
|
|
result['errors'].append(f"User {user_id}: {str(e)}")
|
||
|
|
|
||
|
|
# Process additional email addresses
|
||
|
|
for email_addr in recipient_emails:
|
||
|
|
try:
|
||
|
|
if settings.REMINDERS_EMAIL_ENABLED:
|
||
|
|
await self._send_email(
|
||
|
|
email_addr, reminder_title, reminder_message,
|
||
|
|
sag_id, case_title, customer_name, priority,
|
||
|
|
case_status, deadline, assigned_user, additional_info
|
||
|
|
)
|
||
|
|
result['channels_used'].append('email')
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
error = f"Failed to send email to {email_addr}: {str(e)}"
|
||
|
|
logger.error(f"❌ {error}")
|
||
|
|
result['errors'].append(error)
|
||
|
|
|
||
|
|
result['success'] = len(result['errors']) == 0 or len(result['channels_used']) > 0
|
||
|
|
|
||
|
|
return result
|
||
|
|
|
||
|
|
async def _check_rate_limit(self, user_id: int) -> bool:
|
||
|
|
"""Check if user has exceeded notification limit (max 5 per hour)"""
|
||
|
|
query = """
|
||
|
|
SELECT COUNT(*) as count
|
||
|
|
FROM sag_reminder_logs
|
||
|
|
WHERE user_id = %s
|
||
|
|
AND triggered_at > CURRENT_TIMESTAMP - INTERVAL '1 hour'
|
||
|
|
AND status = 'sent'
|
||
|
|
"""
|
||
|
|
result = execute_query(query, (user_id,))
|
||
|
|
count = result[0]['count'] if result else 0
|
||
|
|
|
||
|
|
if count >= self.max_per_hour:
|
||
|
|
logger.warning(f"⚠️ Rate limit reached for user {user_id}: {count}/{self.max_per_hour} notifications")
|
||
|
|
return False
|
||
|
|
|
||
|
|
return True
|
||
|
|
|
||
|
|
async def _get_user_preferences(self, user_id: int) -> Dict:
|
||
|
|
"""Get user notification preferences"""
|
||
|
|
query = """
|
||
|
|
SELECT notify_mattermost, notify_email, notify_frontend
|
||
|
|
FROM user_notification_preferences
|
||
|
|
WHERE user_id = %s
|
||
|
|
"""
|
||
|
|
result = execute_query(query, (user_id,))
|
||
|
|
|
||
|
|
if result:
|
||
|
|
return {
|
||
|
|
'mattermost': result[0].get('notify_mattermost', True),
|
||
|
|
'email': result[0].get('notify_email', False),
|
||
|
|
'frontend': result[0].get('notify_frontend', True)
|
||
|
|
}
|
||
|
|
|
||
|
|
# Default preferences
|
||
|
|
return {
|
||
|
|
'mattermost': True,
|
||
|
|
'email': False,
|
||
|
|
'frontend': True
|
||
|
|
}
|
||
|
|
|
||
|
|
def _determine_channels(
|
||
|
|
self,
|
||
|
|
user_prefs: Dict,
|
||
|
|
notify_mattermost: Optional[bool],
|
||
|
|
notify_email: Optional[bool],
|
||
|
|
notify_frontend: Optional[bool],
|
||
|
|
override: bool
|
||
|
|
) -> List[str]:
|
||
|
|
"""Determine which channels to use (merge user prefs with reminder overrides)"""
|
||
|
|
channels = []
|
||
|
|
|
||
|
|
# Mattermost
|
||
|
|
mm = notify_mattermost if notify_mattermost is not None else user_prefs.get('mattermost', True)
|
||
|
|
if mm:
|
||
|
|
channels.append('mattermost')
|
||
|
|
|
||
|
|
# Email
|
||
|
|
em = notify_email if notify_email is not None else user_prefs.get('email', False)
|
||
|
|
if em:
|
||
|
|
channels.append('email')
|
||
|
|
|
||
|
|
# Frontend
|
||
|
|
fe = notify_frontend if notify_frontend is not None else user_prefs.get('frontend', True)
|
||
|
|
if fe:
|
||
|
|
channels.append('frontend')
|
||
|
|
|
||
|
|
return channels
|
||
|
|
|
||
|
|
async def _send_mattermost(
|
||
|
|
self,
|
||
|
|
title: str,
|
||
|
|
message: Optional[str],
|
||
|
|
case_id: int,
|
||
|
|
case_title: str,
|
||
|
|
priority: str,
|
||
|
|
additional_info: Optional[str]
|
||
|
|
) -> bool:
|
||
|
|
"""Send reminder via Mattermost"""
|
||
|
|
if self.dry_run:
|
||
|
|
logger.warning(f"🔒 DRY RUN: Mattermost reminder '{title}'")
|
||
|
|
return True
|
||
|
|
|
||
|
|
try:
|
||
|
|
color_map = {
|
||
|
|
'low': '#6c757d',
|
||
|
|
'normal': '#0f4c75',
|
||
|
|
'high': '#ffc107',
|
||
|
|
'urgent': '#dc3545'
|
||
|
|
}
|
||
|
|
|
||
|
|
payload = {
|
||
|
|
'text': f'🔔 **{title}**',
|
||
|
|
'attachments': [{
|
||
|
|
'title': case_title,
|
||
|
|
'title_link': f"http://localhost:8000/sag/{case_id}",
|
||
|
|
'text': message or additional_info or 'Se reminder i systemet',
|
||
|
|
'color': color_map.get(priority, color_map['normal']),
|
||
|
|
'fields': [
|
||
|
|
{
|
||
|
|
'title': 'Prioritet',
|
||
|
|
'value': priority.capitalize(),
|
||
|
|
'short': True
|
||
|
|
},
|
||
|
|
{
|
||
|
|
'title': 'Sag ID',
|
||
|
|
'value': f'#{case_id}',
|
||
|
|
'short': True
|
||
|
|
}
|
||
|
|
],
|
||
|
|
'actions': [{
|
||
|
|
'name': 'Åbn sag',
|
||
|
|
'type': 'button',
|
||
|
|
'text': 'Se mere',
|
||
|
|
'url': f"http://localhost:8000/sag/{case_id}"
|
||
|
|
}]
|
||
|
|
}]
|
||
|
|
}
|
||
|
|
|
||
|
|
success, msg = await self.mattermost_service._send_webhook(payload, 'reminder_notification')
|
||
|
|
if success:
|
||
|
|
logger.info(f"✅ Mattermost reminder sent: {title}")
|
||
|
|
else:
|
||
|
|
logger.error(f"❌ Mattermost error: {msg}")
|
||
|
|
return success
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
logger.error(f"❌ Mattermost send failed: {e}")
|
||
|
|
return False
|
||
|
|
|
||
|
|
async def _send_email(
|
||
|
|
self,
|
||
|
|
to_email: str,
|
||
|
|
title: str,
|
||
|
|
message: Optional[str],
|
||
|
|
case_id: int,
|
||
|
|
case_title: str,
|
||
|
|
customer_name: str,
|
||
|
|
priority: str,
|
||
|
|
case_status: Optional[str],
|
||
|
|
deadline: Optional[str],
|
||
|
|
assigned_user: Optional[str],
|
||
|
|
additional_info: Optional[str]
|
||
|
|
) -> bool:
|
||
|
|
"""Send reminder via email"""
|
||
|
|
if self.dry_run:
|
||
|
|
logger.warning(f"🔒 DRY RUN: Email reminder to {to_email}")
|
||
|
|
return True
|
||
|
|
|
||
|
|
try:
|
||
|
|
# Load email template
|
||
|
|
with open('templates/emails/reminder.html', 'r') as f:
|
||
|
|
template_html = f.read()
|
||
|
|
|
||
|
|
# Prepare context
|
||
|
|
context = {
|
||
|
|
'header_title': 'Reminder: ' + title,
|
||
|
|
'reminder_title': title,
|
||
|
|
'reminder_message': message or '',
|
||
|
|
'case_id': case_id,
|
||
|
|
'case_title': case_title,
|
||
|
|
'customer_name': customer_name,
|
||
|
|
'case_status': case_status or 'Unknown',
|
||
|
|
'priority': priority,
|
||
|
|
'deadline': deadline,
|
||
|
|
'assigned_user': assigned_user or 'Ikke tildelt',
|
||
|
|
'additional_info': additional_info or '',
|
||
|
|
'action_url': f"http://localhost:8000/sag/{case_id}",
|
||
|
|
'footer_date': datetime.now().strftime("%d. %B %Y")
|
||
|
|
}
|
||
|
|
|
||
|
|
# Render template
|
||
|
|
template = Template(template_html)
|
||
|
|
body_html = template.render(context)
|
||
|
|
body_text = f"{title}\n\n{message or ''}\n\nSag: {case_title} (#{case_id})\nKunde: {customer_name}"
|
||
|
|
|
||
|
|
# Send email
|
||
|
|
success, msg = await self.email_service.send_email(
|
||
|
|
to_addresses=[to_email],
|
||
|
|
subject=f"[{priority.upper()}] Reminder: {title}",
|
||
|
|
body_text=body_text,
|
||
|
|
body_html=body_html,
|
||
|
|
reply_to=settings.EMAIL_SMTP_FROM_ADDRESS
|
||
|
|
)
|
||
|
|
|
||
|
|
if success:
|
||
|
|
logger.info(f"✅ Email reminder sent to {to_email}")
|
||
|
|
else:
|
||
|
|
logger.error(f"❌ Email error: {msg}")
|
||
|
|
|
||
|
|
return success
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
logger.error(f"❌ Email send failed: {e}")
|
||
|
|
return False
|
||
|
|
|
||
|
|
async def _log_reminder(
|
||
|
|
self,
|
||
|
|
reminder_id: int,
|
||
|
|
sag_id: int,
|
||
|
|
user_id: int,
|
||
|
|
channels: List[str],
|
||
|
|
payload: Dict,
|
||
|
|
status: str = 'sent'
|
||
|
|
) -> Optional[int]:
|
||
|
|
"""Log reminder execution"""
|
||
|
|
try:
|
||
|
|
query = """
|
||
|
|
INSERT INTO sag_reminder_logs (
|
||
|
|
reminder_id, sag_id, user_id,
|
||
|
|
channels_used, notification_payload,
|
||
|
|
status, triggered_at
|
||
|
|
)
|
||
|
|
VALUES (%s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP)
|
||
|
|
RETURNING id
|
||
|
|
"""
|
||
|
|
|
||
|
|
result = execute_insert(query, (
|
||
|
|
reminder_id,
|
||
|
|
sag_id,
|
||
|
|
user_id,
|
||
|
|
channels,
|
||
|
|
json.dumps(payload),
|
||
|
|
status
|
||
|
|
))
|
||
|
|
|
||
|
|
if result:
|
||
|
|
log_id = result[0]['id'] if isinstance(result[0], dict) else result[0]
|
||
|
|
logger.info(f"📝 Reminder logged (ID: {log_id})")
|
||
|
|
return log_id
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
logger.error(f"❌ Failed to log reminder: {e}")
|
||
|
|
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
# Global instance
|
||
|
|
reminder_notification_service = ReminderNotificationService()
|