first commit
This commit is contained in:
172
app/services/notifications.py
Normal file
172
app/services/notifications.py
Normal file
@@ -0,0 +1,172 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from flask import current_app
|
||||
from pywebpush import WebPushException, webpush
|
||||
|
||||
from ..extensions import db
|
||||
from ..models import NotificationLog, PushSubscription, TaskInstance, User
|
||||
from .monthly import archive_months_missing_up_to_previous, get_snapshot_rows
|
||||
from .dates import local_now, previous_month
|
||||
|
||||
|
||||
@dataclass
|
||||
class NotificationResult:
|
||||
sent: int = 0
|
||||
skipped: int = 0
|
||||
failed: int = 0
|
||||
|
||||
|
||||
def push_enabled() -> bool:
|
||||
return bool(current_app.config["VAPID_PUBLIC_KEY"] and current_app.config["VAPID_PRIVATE_KEY"])
|
||||
|
||||
|
||||
def _absolute_url(path: str) -> str:
|
||||
base = current_app.config["APP_BASE_URL"].rstrip("/") + "/"
|
||||
return urljoin(base, path.lstrip("/"))
|
||||
|
||||
|
||||
def _notification_exists(user_id: int, notification_type: str, payload: dict) -> bool:
|
||||
payload_value = json.dumps(payload, sort_keys=True)
|
||||
return (
|
||||
NotificationLog.query.filter_by(user_id=user_id, type=notification_type, payload=payload_value).first()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def _log_notification(user_id: int, notification_type: str, payload: dict) -> None:
|
||||
db.session.add(
|
||||
NotificationLog(user_id=user_id, type=notification_type, payload=json.dumps(payload, sort_keys=True))
|
||||
)
|
||||
|
||||
|
||||
def _send_subscription(subscription: PushSubscription, payload: dict) -> bool:
|
||||
try:
|
||||
webpush(
|
||||
subscription_info={
|
||||
"endpoint": subscription.endpoint,
|
||||
"keys": {"p256dh": subscription.p256dh, "auth": subscription.auth},
|
||||
},
|
||||
data=json.dumps(payload),
|
||||
vapid_private_key=current_app.config["VAPID_PRIVATE_KEY"],
|
||||
vapid_claims={"sub": current_app.config["VAPID_CLAIMS_SUBJECT"]},
|
||||
)
|
||||
return True
|
||||
except WebPushException:
|
||||
return False
|
||||
|
||||
|
||||
def send_due_notifications() -> NotificationResult:
|
||||
result = NotificationResult()
|
||||
if not push_enabled():
|
||||
result.skipped += 1
|
||||
return result
|
||||
|
||||
today = local_now().date()
|
||||
relevant_tasks = TaskInstance.query.filter(
|
||||
TaskInstance.completed_at.is_(None),
|
||||
TaskInstance.assigned_user_id.isnot(None),
|
||||
TaskInstance.due_date <= today + timedelta(days=1),
|
||||
).all()
|
||||
|
||||
for task in relevant_tasks:
|
||||
user = task.assigned_user
|
||||
if not user or not user.notification_task_due_enabled:
|
||||
result.skipped += 1
|
||||
continue
|
||||
|
||||
payload_marker = {"task_instance_id": task.id, "due_date": task.due_date.isoformat()}
|
||||
if _notification_exists(user.id, "task_due", payload_marker):
|
||||
result.skipped += 1
|
||||
continue
|
||||
|
||||
subscriptions = PushSubscription.query.filter_by(user_id=user.id).all()
|
||||
if not subscriptions:
|
||||
result.skipped += 1
|
||||
continue
|
||||
|
||||
body = "Heute ist ein guter Tag für Punkte." if task.due_date <= today else "Morgen wird's fällig."
|
||||
payload = {
|
||||
"title": f"Putzliga erinnert: {task.title}",
|
||||
"body": body,
|
||||
"icon": _absolute_url("/static/images/pwa-icon-192.png"),
|
||||
"badge": _absolute_url("/static/images/pwa-badge.png"),
|
||||
"url": _absolute_url("/my-tasks"),
|
||||
"tag": f"task-{task.id}",
|
||||
}
|
||||
|
||||
sent_any = False
|
||||
for subscription in subscriptions:
|
||||
if _send_subscription(subscription, payload):
|
||||
sent_any = True
|
||||
result.sent += 1
|
||||
else:
|
||||
result.failed += 1
|
||||
|
||||
if sent_any:
|
||||
_log_notification(user.id, "task_due", payload_marker)
|
||||
db.session.commit()
|
||||
return result
|
||||
|
||||
|
||||
def send_monthly_winner_notifications() -> NotificationResult:
|
||||
result = NotificationResult()
|
||||
if not push_enabled():
|
||||
result.skipped += 1
|
||||
return result
|
||||
|
||||
now = local_now()
|
||||
if not (now.day == 1 and now.hour >= 9):
|
||||
result.skipped += 1
|
||||
return result
|
||||
|
||||
archive_months_missing_up_to_previous()
|
||||
target_year, target_month = previous_month(now.year, now.month)
|
||||
rows = get_snapshot_rows(target_year, target_month)
|
||||
if not rows:
|
||||
result.skipped += 1
|
||||
return result
|
||||
|
||||
winners = [row.user.name for row in rows if row.rank == 1]
|
||||
winner_text = ", ".join(winners)
|
||||
users = User.query.order_by(User.name.asc()).all()
|
||||
marker = {"year": target_year, "month": target_month}
|
||||
|
||||
for user in users:
|
||||
if not user.notification_monthly_winner_enabled:
|
||||
result.skipped += 1
|
||||
continue
|
||||
if _notification_exists(user.id, "monthly_winner", marker):
|
||||
result.skipped += 1
|
||||
continue
|
||||
|
||||
subscriptions = PushSubscription.query.filter_by(user_id=user.id).all()
|
||||
if not subscriptions:
|
||||
result.skipped += 1
|
||||
continue
|
||||
|
||||
payload = {
|
||||
"title": "Der Haushalts-Champion des letzten Monats steht fest",
|
||||
"body": f"{winner_text} führt den letzten Monat an. Schau ins Scoreboard.",
|
||||
"icon": _absolute_url("/static/images/pwa-icon-192.png"),
|
||||
"badge": _absolute_url("/static/images/pwa-badge.png"),
|
||||
"url": _absolute_url(f"/scoreboard?archive={target_year}-{target_month:02d}"),
|
||||
"tag": f"winner-{target_year}-{target_month}",
|
||||
}
|
||||
sent_any = False
|
||||
for subscription in subscriptions:
|
||||
if _send_subscription(subscription, payload):
|
||||
sent_any = True
|
||||
result.sent += 1
|
||||
else:
|
||||
result.failed += 1
|
||||
|
||||
if sent_any:
|
||||
_log_notification(user.id, "monthly_winner", marker)
|
||||
db.session.commit()
|
||||
|
||||
return result
|
||||
Reference in New Issue
Block a user