feat: improve scheduled push notifications

This commit is contained in:
2026-04-15 14:28:31 +02:00
parent dba87ebcf2
commit e4589df111
11 changed files with 195 additions and 80 deletions

View File

@@ -224,11 +224,13 @@ Putzliga nutzt echte Web-Push-Benachrichtigungen mit Service Worker und VAPID.
```bash ```bash
flask --app app.py notify-due flask --app app.py notify-due
flask --app app.py notify-monthly-winner flask --app app.py notify-monthly-winner
flask --app app.py notify-all
``` ```
`notify-due`: `notify-due`:
- prüft offene Aufgaben, die heute oder morgen fällig sind - sendet ab 09:00 Uhr genau einen Sammel-Push pro Nutzer und Tag
- berücksichtigt nur offene Aufgaben, die heute fällig sind
- berücksichtigt die Nutzeroption `notification_task_due_enabled` - berücksichtigt die Nutzeroption `notification_task_due_enabled`
`notify-monthly-winner`: `notify-monthly-winner`:
@@ -237,12 +239,24 @@ flask --app app.py notify-monthly-winner
- verweist auf das Scoreboard/Archiv des letzten Monats - verweist auf das Scoreboard/Archiv des letzten Monats
- berücksichtigt `notification_monthly_winner_enabled` - berücksichtigt `notification_monthly_winner_enabled`
Badge-Pushes:
- werden direkt beim Freischalten eines neuen Badges verschickt
- berücksichtigen `notification_badge_enabled`
- funktionieren für Aufgaben-Badges und Monats-Badges
Für iPhone/iPad muss zusätzlich sichergestellt sein, dass der Server ausgehend Apple Web Push erreichen kann. Laut WebKit sollten dafür Verbindungen zu `*.push.apple.com` möglich sein. Für iPhone/iPad muss zusätzlich sichergestellt sein, dass der Server ausgehend Apple Web Push erreichen kann. Laut WebKit sollten dafür Verbindungen zu `*.push.apple.com` möglich sein.
### Produktiver Betrieb ### Produktiver Betrieb
Auf Cloudron oder einem anderen Server solltest du diese Kommandos regelmäßig per Cronjob oder Task ausführen, zum Beispiel: Auf Cloudron oder einem anderen Server solltest du diese Kommandos regelmäßig per Cronjob oder Task ausführen, zum Beispiel:
```bash
flask --app /app/app.py notify-all
```
Wenn du lieber getrennt schedulen willst, funktionieren auch weiterhin:
```bash ```bash
flask --app /app/app.py notify-due flask --app /app/app.py notify-due
flask --app /app/app.py notify-monthly-winner flask --app /app/app.py notify-monthly-winner

View File

@@ -5,7 +5,7 @@ import click
from .extensions import db from .extensions import db
from .models import BadgeDefinition, User from .models import BadgeDefinition, User
from .services.monthly import archive_months_missing_up_to_previous from .services.monthly import archive_months_missing_up_to_previous
from .services.notifications import send_due_notifications, send_monthly_winner_notifications from .services.notifications import run_scheduled_notifications, send_due_notifications, send_monthly_winner_notifications
DEFAULT_BADGES = [ DEFAULT_BADGES = [
@@ -175,3 +175,14 @@ def register_cli(app) -> None:
def notify_monthly_winner_command(): def notify_monthly_winner_command():
result = send_monthly_winner_notifications() result = send_monthly_winner_notifications()
click.echo(f"Winner-Push: sent={result.sent} skipped={result.skipped} failed={result.failed}") click.echo(f"Winner-Push: sent={result.sent} skipped={result.skipped} failed={result.failed}")
@app.cli.command("notify-all")
def notify_all_command():
results = run_scheduled_notifications()
daily = results["daily_due"]
monthly = results["monthly_winner"]
click.echo(
"Scheduled-Pushes: "
f"daily(sent={daily.sent}, skipped={daily.skipped}, failed={daily.failed}) "
f"monthly(sent={monthly.sent}, skipped={monthly.skipped}, failed={monthly.failed})"
)

View File

@@ -89,8 +89,9 @@ class SettingsProfileForm(FlaskForm):
"Avatar", "Avatar",
validators=[Optional(), FileAllowed(["png", "jpg", "jpeg", "gif", "webp", "svg"], "Bitte ein Bild hochladen.")], validators=[Optional(), FileAllowed(["png", "jpg", "jpeg", "gif", "webp", "svg"], "Bitte ein Bild hochladen.")],
) )
notification_task_due_enabled = BooleanField("Push bei bald fälligen Aufgaben") notification_task_due_enabled = BooleanField("Push bei heutigen offenen Aufgaben")
notification_monthly_winner_enabled = BooleanField("Push zum Monatssieger") notification_monthly_winner_enabled = BooleanField("Push zum Monatssieger")
notification_badge_enabled = BooleanField("Push bei neuen Badges")
submit = SubmitField("Einstellungen speichern") submit = SubmitField("Einstellungen speichern")
def __init__(self, original_email: str | None = None, *args, **kwargs): def __init__(self, original_email: str | None = None, *args, **kwargs):

View File

@@ -28,6 +28,7 @@ class User(UserMixin, TimestampMixin, db.Model):
calendar_feed_token = db.Column(db.String(255), nullable=True, unique=True, index=True) calendar_feed_token = db.Column(db.String(255), nullable=True, unique=True, index=True)
notification_task_due_enabled = db.Column(db.Boolean, nullable=False, default=True) notification_task_due_enabled = db.Column(db.Boolean, nullable=False, default=True)
notification_monthly_winner_enabled = db.Column(db.Boolean, nullable=False, default=True) notification_monthly_winner_enabled = db.Column(db.Boolean, nullable=False, default=True)
notification_badge_enabled = db.Column(db.Boolean, nullable=False, default=True)
assigned_task_templates = db.relationship( assigned_task_templates = db.relationship(
"TaskTemplate", "TaskTemplate",

View File

@@ -57,6 +57,7 @@ def index():
current_user.email = form.email.data.lower().strip() current_user.email = form.email.data.lower().strip()
current_user.notification_task_due_enabled = form.notification_task_due_enabled.data current_user.notification_task_due_enabled = form.notification_task_due_enabled.data
current_user.notification_monthly_winner_enabled = form.notification_monthly_winner_enabled.data current_user.notification_monthly_winner_enabled = form.notification_monthly_winner_enabled.data
current_user.notification_badge_enabled = form.notification_badge_enabled.data
if form.password.data: if form.password.data:
current_user.set_password(form.password.data) current_user.set_password(form.password.data)
if form.avatar.data: if form.avatar.data:

View File

@@ -25,24 +25,30 @@ def _max_day_streak(days: set[date]) -> int:
return best return best
def award_badge(user: User, badge_key: str, *, awarded_at: datetime | None = None, context: dict | None = None) -> bool: def award_badge(
user: User,
badge_key: str,
*,
awarded_at: datetime | None = None,
context: dict | None = None,
) -> UserBadge | None:
definition = BadgeDefinition.query.filter_by(key=badge_key, active=True).first() definition = BadgeDefinition.query.filter_by(key=badge_key, active=True).first()
if not definition: if not definition:
return False return None
existing = UserBadge.query.filter_by(user_id=user.id, badge_definition_id=definition.id).first() existing = UserBadge.query.filter_by(user_id=user.id, badge_definition_id=definition.id).first()
if existing: if existing:
return False return None
db.session.add( award = UserBadge(
UserBadge( user_id=user.id,
user_id=user.id, badge_definition_id=definition.id,
badge_definition_id=definition.id, awarded_at=awarded_at or datetime.utcnow(),
awarded_at=awarded_at or datetime.utcnow(), context=json.dumps(context, sort_keys=True) if context else None,
context=json.dumps(context, sort_keys=True) if context else None,
)
) )
return True db.session.add(award)
db.session.flush()
return award
def badge_awards_for_month(user_id: int, year: int, month: int) -> list[UserBadge]: def badge_awards_for_month(user_id: int, year: int, month: int) -> list[UserBadge]:
@@ -101,20 +107,26 @@ def _completion_metrics(user: User) -> dict[str, int]:
return metrics return metrics
def evaluate_task_badges(user: User) -> list[str]: def evaluate_task_badges(user: User, *, notify: bool = False) -> list[UserBadge]:
definitions = BadgeDefinition.query.filter_by(active=True).all() definitions = BadgeDefinition.query.filter_by(active=True).all()
metrics = _completion_metrics(user) metrics = _completion_metrics(user)
unlocked: list[str] = [] unlocked: list[UserBadge] = []
for definition in definitions: for definition in definitions:
metric_value = metrics.get(definition.trigger_type) metric_value = metrics.get(definition.trigger_type)
if metric_value is None: if metric_value is None:
continue continue
if metric_value >= definition.threshold and award_badge(user, definition.key): if metric_value >= definition.threshold:
unlocked.append(definition.name) award = award_badge(user, definition.key)
if award:
unlocked.append(award)
if unlocked: if unlocked:
db.session.commit() db.session.commit()
if notify:
from .notifications import send_badge_notifications_for_awards
send_badge_notifications_for_awards(unlocked)
return unlocked return unlocked
@@ -151,31 +163,39 @@ def _winner_user_ids(year: int, month: int) -> set[int]:
return {row.user_id for row in rows} return {row.user_id for row in rows}
def evaluate_monthly_badges(year: int, month: int) -> list[str]: def evaluate_monthly_badges(year: int, month: int, *, notify: bool = False) -> list[UserBadge]:
award_time = _month_end_award_time(year, month) award_time = _month_end_award_time(year, month)
unlocked: list[str] = [] unlocked: list[UserBadge] = []
winners = _winner_user_ids(year, month) winners = _winner_user_ids(year, month)
previous_year, previous_month_value = previous_month(year, month) previous_year, previous_month_value = previous_month(year, month)
previous_winners = _winner_user_ids(previous_year, previous_month_value) previous_winners = _winner_user_ids(previous_year, previous_month_value)
for user in User.query.order_by(User.id.asc()).all(): for user in User.query.order_by(User.id.asc()).all():
if user.id in winners: if user.id in winners:
if award_badge(user, "monthly_champion", awarded_at=award_time, context={"year": year, "month": month}): award = award_badge(user, "monthly_champion", awarded_at=award_time, context={"year": year, "month": month})
unlocked.append(f"{user.name}: Monatssieger") if award:
unlocked.append(award)
if user.id in previous_winners: if user.id in previous_winners:
if award_badge(user, "title_defender", awarded_at=award_time, context={"year": year, "month": month}): award = award_badge(user, "title_defender", awarded_at=award_time, context={"year": year, "month": month})
unlocked.append(f"{user.name}: Titelverteidiger") if award:
unlocked.append(award)
elif MonthlyScoreSnapshot.query.filter_by(year=previous_year, month=previous_month_value, user_id=user.id).first(): elif MonthlyScoreSnapshot.query.filter_by(year=previous_year, month=previous_month_value, user_id=user.id).first():
if award_badge(user, "comeback_kid", awarded_at=award_time, context={"year": year, "month": month}): award = award_badge(user, "comeback_kid", awarded_at=award_time, context={"year": year, "month": month})
unlocked.append(f"{user.name}: Comeback Kid") if award:
unlocked.append(award)
if _user_had_clean_month(user.id, year, month): if _user_had_clean_month(user.id, year, month):
if award_badge(user, "white_glove", awarded_at=award_time, context={"year": year, "month": month}): award = award_badge(user, "white_glove", awarded_at=award_time, context={"year": year, "month": month})
unlocked.append(f"{user.name}: Weiße Weste") if award:
unlocked.append(award)
if unlocked: if unlocked:
db.session.commit() db.session.commit()
if notify:
from .notifications import send_badge_notifications_for_awards
send_badge_notifications_for_awards(unlocked)
return unlocked return unlocked

View File

@@ -21,6 +21,10 @@ def ensure_schema_and_admins() -> None:
db.session.execute(text("ALTER TABLE user ADD COLUMN calendar_feed_token VARCHAR(255)")) db.session.execute(text("ALTER TABLE user ADD COLUMN calendar_feed_token VARCHAR(255)"))
db.session.commit() db.session.commit()
if "notification_badge_enabled" not in column_names:
db.session.execute(text("ALTER TABLE user ADD COLUMN notification_badge_enabled BOOLEAN NOT NULL DEFAULT 1"))
db.session.commit()
task_template_columns = {column["name"] for column in inspector.get_columns("task_template")} task_template_columns = {column["name"] for column in inspector.get_columns("task_template")}
if "default_assigned_user_secondary_id" not in task_template_columns: if "default_assigned_user_secondary_id" not in task_template_columns:
db.session.execute(text("ALTER TABLE task_template ADD COLUMN default_assigned_user_secondary_id INTEGER")) db.session.execute(text("ALTER TABLE task_template ADD COLUMN default_assigned_user_secondary_id INTEGER"))

View File

@@ -97,7 +97,7 @@ def archive_months_missing_up_to_previous() -> None:
) )
) )
db.session.commit() db.session.commit()
evaluate_monthly_badges(year, month) evaluate_monthly_badges(year, month, notify=True)
year, month = next_month(year, month) year, month = next_month(year, month)

View File

@@ -2,14 +2,14 @@ from __future__ import annotations
import json import json
from dataclasses import dataclass from dataclasses import dataclass
from datetime import timedelta
from urllib.parse import urljoin from urllib.parse import urljoin
from flask import current_app from flask import current_app
from pywebpush import WebPushException, webpush from pywebpush import WebPushException, webpush
from sqlalchemy import or_
from ..extensions import db from ..extensions import db
from ..models import NotificationLog, PushSubscription, TaskInstance, User from ..models import NotificationLog, PushSubscription, TaskInstance, User, UserBadge
from .monthly import archive_months_missing_up_to_previous, get_snapshot_rows from .monthly import archive_months_missing_up_to_previous, get_snapshot_rows
from .dates import local_now, previous_month from .dates import local_now, previous_month
@@ -60,56 +60,96 @@ def _send_subscription(subscription: PushSubscription, payload: dict) -> bool:
return False return False
def _subscriptions_for_user(user_id: int) -> list[PushSubscription]:
return PushSubscription.query.filter_by(user_id=user_id).all()
def _send_payload_to_user(user: User, notification_type: str, marker: dict, payload: dict) -> NotificationResult:
result = NotificationResult()
subscriptions = _subscriptions_for_user(user.id)
if not subscriptions:
result.skipped += 1
return result
if _notification_exists(user.id, notification_type, marker):
result.skipped += 1
return result
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, notification_type, marker)
db.session.commit()
elif result.failed == 0:
result.skipped += 1
return result
def _merge_results(base: NotificationResult, extra: NotificationResult) -> NotificationResult:
base.sent += extra.sent
base.skipped += extra.skipped
base.failed += extra.failed
return base
def send_due_notifications() -> NotificationResult: def send_due_notifications() -> NotificationResult:
result = NotificationResult() result = NotificationResult()
if not push_enabled(): if not push_enabled():
result.skipped += 1 result.skipped += 1
return result return result
today = local_now().date() now = local_now()
if now.hour < 9:
result.skipped += 1
return result
today = now.date()
relevant_tasks = TaskInstance.query.filter( relevant_tasks = TaskInstance.query.filter(
TaskInstance.completed_at.is_(None), TaskInstance.completed_at.is_(None),
TaskInstance.assigned_user_id.isnot(None), TaskInstance.due_date == today,
TaskInstance.due_date <= today + timedelta(days=1), or_(
TaskInstance.assigned_user_id.isnot(None),
TaskInstance.assigned_user_secondary_id.isnot(None),
),
).all() ).all()
tasks_by_user: dict[int, list[TaskInstance]] = {}
for task in relevant_tasks: for task in relevant_tasks:
user = task.assigned_user for assigned_user in task.assigned_users:
if not user or not user.notification_task_due_enabled: tasks_by_user.setdefault(assigned_user.id, []).append(task)
for user in User.query.order_by(User.name.asc()).all():
if not user.notification_task_due_enabled:
result.skipped += 1 result.skipped += 1
continue continue
payload_marker = {"task_instance_id": task.id, "due_date": task.due_date.isoformat()} personal_tasks = tasks_by_user.get(user.id, [])
if _notification_exists(user.id, "task_due", payload_marker): if not personal_tasks:
result.skipped += 1 result.skipped += 1
continue continue
subscriptions = PushSubscription.query.filter_by(user_id=user.id).all() task_count = len(personal_tasks)
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 = { payload = {
"title": f"Putzliga erinnert: {task.title}", "title": "Putzliga für heute",
"body": body, "body": (
"Heute wartet 1 offene Aufgabe auf dich. Zeit zum Punkte sammeln."
if task_count == 1
else f"Heute warten {task_count} offene Aufgaben auf dich. Zeit zum Punkte sammeln."
),
"icon": _absolute_url("/static/images/pwa-icon-192.png"), "icon": _absolute_url("/static/images/pwa-icon-192.png"),
"badge": _absolute_url("/static/images/pwa-badge.png"), "badge": _absolute_url("/static/images/pwa-badge.png"),
"url": _absolute_url("/my-tasks"), "url": _absolute_url("/my-tasks"),
"tag": f"task-{task.id}", "tag": f"due-{today.isoformat()}-{user.id}",
} }
marker = {"date": today.isoformat()}
sent_any = False _merge_results(result, _send_payload_to_user(user, "task_due_daily", marker, payload))
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 return result
@@ -140,14 +180,6 @@ def send_monthly_winner_notifications() -> NotificationResult:
if not user.notification_monthly_winner_enabled: if not user.notification_monthly_winner_enabled:
result.skipped += 1 result.skipped += 1
continue 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 = { payload = {
"title": "Der Haushalts-Champion des letzten Monats steht fest", "title": "Der Haushalts-Champion des letzten Monats steht fest",
@@ -157,16 +189,43 @@ def send_monthly_winner_notifications() -> NotificationResult:
"url": _absolute_url(f"/scoreboard?archive={target_year}-{target_month:02d}"), "url": _absolute_url(f"/scoreboard?archive={target_year}-{target_month:02d}"),
"tag": f"winner-{target_year}-{target_month}", "tag": f"winner-{target_year}-{target_month}",
} }
sent_any = False _merge_results(result, _send_payload_to_user(user, "monthly_winner", marker, payload))
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 return result
def send_badge_notifications_for_awards(awards: list[UserBadge]) -> NotificationResult:
result = NotificationResult()
if not push_enabled():
result.skipped += len(awards) or 1
return result
for award in awards:
user = award.user
definition = award.badge_definition
if not user or not definition:
result.skipped += 1
continue
if not user.notification_badge_enabled:
result.skipped += 1
continue
marker = {"user_badge_id": award.id}
payload = {
"title": "Neues Badge freigeschaltet",
"body": f"{definition.name}: {definition.description}",
"icon": _absolute_url("/static/images/pwa-icon-192.png"),
"badge": _absolute_url("/static/images/pwa-badge.png"),
"url": _absolute_url("/settings"),
"tag": f"badge-{award.id}",
}
_merge_results(result, _send_payload_to_user(user, "badge_award", marker, payload))
return result
def run_scheduled_notifications() -> dict[str, NotificationResult]:
return {
"daily_due": send_due_notifications(),
"monthly_winner": send_monthly_winner_notifications(),
}

View File

@@ -139,7 +139,7 @@ def complete_task(task: TaskInstance, completed_by_user_id: int) -> TaskInstance
ensure_next_recurring_task(task) ensure_next_recurring_task(task)
db.session.commit() db.session.commit()
if task.completed_by_user: if task.completed_by_user:
evaluate_task_badges(task.completed_by_user) evaluate_task_badges(task.completed_by_user, notify=True)
return task return task

View File

@@ -40,12 +40,16 @@
</div> </div>
<label class="checkbox"> <label class="checkbox">
{{ form.notification_task_due_enabled() }} {{ form.notification_task_due_enabled() }}
<span>Push für heute oder morgen fällige Aufgaben</span> <span>Täglicher Push um 09:00 Uhr, wenn heute Aufgaben für dich offen sind</span>
</label> </label>
<label class="checkbox"> <label class="checkbox">
{{ form.notification_monthly_winner_enabled() }} {{ form.notification_monthly_winner_enabled() }}
<span>Push zum Monatssieger am 1. um 09:00 Uhr</span> <span>Push zum Monatssieger am 1. um 09:00 Uhr</span>
</label> </label>
<label class="checkbox">
{{ form.notification_badge_enabled() }}
<span>Push, wenn du ein neues Badge freischaltest</span>
</label>
{{ form.submit(class_='button') }} {{ form.submit(class_='button') }}
</form> </form>
</article> </article>