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') }}