diff --git a/README.md b/README.md index 9e476ad..07f79fa 100644 --- a/README.md +++ b/README.md @@ -224,11 +224,13 @@ Putzliga nutzt echte Web-Push-Benachrichtigungen mit Service Worker und VAPID. ```bash flask --app app.py notify-due flask --app app.py notify-monthly-winner +flask --app app.py notify-all ``` `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` `notify-monthly-winner`: @@ -237,12 +239,24 @@ flask --app app.py notify-monthly-winner - verweist auf das Scoreboard/Archiv des letzten Monats - 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. ### Produktiver Betrieb 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 flask --app /app/app.py notify-due flask --app /app/app.py notify-monthly-winner diff --git a/app/cli.py b/app/cli.py index 78157ce..b833fae 100644 --- a/app/cli.py +++ b/app/cli.py @@ -5,7 +5,7 @@ import click from .extensions import db from .models import BadgeDefinition, User 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 = [ @@ -175,3 +175,14 @@ def register_cli(app) -> None: def notify_monthly_winner_command(): result = send_monthly_winner_notifications() 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})" + ) diff --git a/app/forms.py b/app/forms.py index 140778d..6b87923 100644 --- a/app/forms.py +++ b/app/forms.py @@ -89,8 +89,9 @@ class SettingsProfileForm(FlaskForm): "Avatar", 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_badge_enabled = BooleanField("Push bei neuen Badges") submit = SubmitField("Einstellungen speichern") def __init__(self, original_email: str | None = None, *args, **kwargs): diff --git a/app/models.py b/app/models.py index e2061ac..008e381 100644 --- a/app/models.py +++ b/app/models.py @@ -28,6 +28,7 @@ class User(UserMixin, TimestampMixin, db.Model): 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_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( "TaskTemplate", diff --git a/app/routes/settings.py b/app/routes/settings.py index 4d5a6d7..2e29690 100644 --- a/app/routes/settings.py +++ b/app/routes/settings.py @@ -57,6 +57,7 @@ def index(): current_user.email = form.email.data.lower().strip() 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_badge_enabled = form.notification_badge_enabled.data if form.password.data: current_user.set_password(form.password.data) if form.avatar.data: diff --git a/app/services/badges.py b/app/services/badges.py index 9cd9184..f930cf6 100644 --- a/app/services/badges.py +++ b/app/services/badges.py @@ -25,24 +25,30 @@ def _max_day_streak(days: set[date]) -> int: 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() if not definition: - return False + return None existing = UserBadge.query.filter_by(user_id=user.id, badge_definition_id=definition.id).first() if existing: - return False + return None - db.session.add( - UserBadge( - user_id=user.id, - badge_definition_id=definition.id, - awarded_at=awarded_at or datetime.utcnow(), - context=json.dumps(context, sort_keys=True) if context else None, - ) + award = UserBadge( + user_id=user.id, + badge_definition_id=definition.id, + awarded_at=awarded_at or datetime.utcnow(), + 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]: @@ -101,20 +107,26 @@ def _completion_metrics(user: User) -> dict[str, int]: 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() metrics = _completion_metrics(user) - unlocked: list[str] = [] + unlocked: list[UserBadge] = [] for definition in definitions: metric_value = metrics.get(definition.trigger_type) if metric_value is None: continue - if metric_value >= definition.threshold and award_badge(user, definition.key): - unlocked.append(definition.name) + if metric_value >= definition.threshold: + award = award_badge(user, definition.key) + if award: + unlocked.append(award) if unlocked: db.session.commit() + if notify: + from .notifications import send_badge_notifications_for_awards + + send_badge_notifications_for_awards(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} -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) - unlocked: list[str] = [] + unlocked: list[UserBadge] = [] winners = _winner_user_ids(year, month) previous_year, previous_month_value = previous_month(year, month) previous_winners = _winner_user_ids(previous_year, previous_month_value) for user in User.query.order_by(User.id.asc()).all(): if user.id in winners: - if award_badge(user, "monthly_champion", awarded_at=award_time, context={"year": year, "month": month}): - unlocked.append(f"{user.name}: Monatssieger") + award = award_badge(user, "monthly_champion", awarded_at=award_time, context={"year": year, "month": month}) + if award: + unlocked.append(award) if user.id in previous_winners: - if award_badge(user, "title_defender", awarded_at=award_time, context={"year": year, "month": month}): - unlocked.append(f"{user.name}: Titelverteidiger") + award = award_badge(user, "title_defender", awarded_at=award_time, context={"year": year, "month": month}) + if award: + unlocked.append(award) 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}): - unlocked.append(f"{user.name}: Comeback Kid") + award = award_badge(user, "comeback_kid", awarded_at=award_time, context={"year": year, "month": month}) + if award: + unlocked.append(award) if _user_had_clean_month(user.id, year, month): - if award_badge(user, "white_glove", awarded_at=award_time, context={"year": year, "month": month}): - unlocked.append(f"{user.name}: Weiße Weste") + award = award_badge(user, "white_glove", awarded_at=award_time, context={"year": year, "month": month}) + if award: + unlocked.append(award) if unlocked: db.session.commit() + if notify: + from .notifications import send_badge_notifications_for_awards + + send_badge_notifications_for_awards(unlocked) return unlocked diff --git a/app/services/bootstrap.py b/app/services/bootstrap.py index fca3d5c..63d796a 100644 --- a/app/services/bootstrap.py +++ b/app/services/bootstrap.py @@ -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.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")} 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")) diff --git a/app/services/monthly.py b/app/services/monthly.py index d8abcb0..8751845 100644 --- a/app/services/monthly.py +++ b/app/services/monthly.py @@ -97,7 +97,7 @@ def archive_months_missing_up_to_previous() -> None: ) ) db.session.commit() - evaluate_monthly_badges(year, month) + evaluate_monthly_badges(year, month, notify=True) year, month = next_month(year, month) diff --git a/app/services/notifications.py b/app/services/notifications.py index 313c0b7..9b6aafc 100644 --- a/app/services/notifications.py +++ b/app/services/notifications.py @@ -2,14 +2,14 @@ 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 sqlalchemy import or_ 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 .dates import local_now, previous_month @@ -60,56 +60,96 @@ def _send_subscription(subscription: PushSubscription, payload: dict) -> bool: 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: result = NotificationResult() if not push_enabled(): result.skipped += 1 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( TaskInstance.completed_at.is_(None), - TaskInstance.assigned_user_id.isnot(None), - TaskInstance.due_date <= today + timedelta(days=1), + TaskInstance.due_date == today, + or_( + TaskInstance.assigned_user_id.isnot(None), + TaskInstance.assigned_user_secondary_id.isnot(None), + ), ).all() + tasks_by_user: dict[int, list[TaskInstance]] = {} for task in relevant_tasks: - user = task.assigned_user - if not user or not user.notification_task_due_enabled: + for assigned_user in task.assigned_users: + 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 continue - payload_marker = {"task_instance_id": task.id, "due_date": task.due_date.isoformat()} - if _notification_exists(user.id, "task_due", payload_marker): + personal_tasks = tasks_by_user.get(user.id, []) + if not personal_tasks: 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." + task_count = len(personal_tasks) payload = { - "title": f"Putzliga erinnert: {task.title}", - "body": body, + "title": "Putzliga für heute", + "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"), "badge": _absolute_url("/static/images/pwa-badge.png"), "url": _absolute_url("/my-tasks"), - "tag": f"task-{task.id}", + "tag": f"due-{today.isoformat()}-{user.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() + marker = {"date": today.isoformat()} + _merge_results(result, _send_payload_to_user(user, "task_due_daily", marker, payload)) return result @@ -140,14 +180,6 @@ def send_monthly_winner_notifications() -> NotificationResult: 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", @@ -157,16 +189,43 @@ def send_monthly_winner_notifications() -> NotificationResult: "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() + _merge_results(result, _send_payload_to_user(user, "monthly_winner", marker, payload)) 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(), + } diff --git a/app/services/tasks.py b/app/services/tasks.py index 0af4b8a..74053ee 100644 --- a/app/services/tasks.py +++ b/app/services/tasks.py @@ -139,7 +139,7 @@ def complete_task(task: TaskInstance, completed_by_user_id: int) -> TaskInstance ensure_next_recurring_task(task) db.session.commit() if task.completed_by_user: - evaluate_task_badges(task.completed_by_user) + evaluate_task_badges(task.completed_by_user, notify=True) return task diff --git a/app/templates/settings/index.html b/app/templates/settings/index.html index dfe7ccf..b399d86 100644 --- a/app/templates/settings/index.html +++ b/app/templates/settings/index.html @@ -40,12 +40,16 @@ + {{ form.submit(class_='button') }}