first commit

This commit is contained in:
2026-04-13 08:32:28 +02:00
commit 1074a91487
72 changed files with 4078 additions and 0 deletions

53
app/services/badges.py Normal file
View File

@@ -0,0 +1,53 @@
from __future__ import annotations
from collections import defaultdict
from datetime import date, timedelta
from ..models import BadgeDefinition, TaskInstance
def _max_day_streak(days: set[date]) -> int:
if not days:
return 0
streak = 1
best = 1
ordered = sorted(days)
for previous, current in zip(ordered, ordered[1:]):
if current == previous + timedelta(days=1):
streak += 1
else:
streak = 1
best = max(best, streak)
return best
def compute_badge_awards(definitions: list[BadgeDefinition], completed_tasks: list[TaskInstance]) -> list[dict]:
by_type: dict[str, int] = defaultdict(int)
completion_days: set[date] = set()
for task in completed_tasks:
if not task.completed_at:
continue
completion_day = task.completed_at.date()
completion_days.add(completion_day)
by_type["monthly_task_count"] += 1
if task.due_date and completion_day < task.due_date:
by_type["early_finisher_count"] += 1
if task.due_date and completion_day <= task.due_date:
by_type["on_time_count"] += 1
by_type["streak_days"] = _max_day_streak(completion_days)
awards = []
for definition in definitions:
metric_value = by_type.get(definition.trigger_type, 0)
if definition.active and metric_value >= definition.threshold:
awards.append(
{
"definition": definition,
"metric_value": metric_value,
"bonus_points": definition.bonus_points,
}
)
return awards

68
app/services/dates.py Normal file
View File

@@ -0,0 +1,68 @@
from __future__ import annotations
import calendar
from datetime import UTC, date, datetime
from zoneinfo import ZoneInfo
from flask import current_app
MONTH_NAMES = [
"",
"Januar",
"Februar",
"März",
"April",
"Mai",
"Juni",
"Juli",
"August",
"September",
"Oktober",
"November",
"Dezember",
]
def get_timezone() -> ZoneInfo:
return ZoneInfo(current_app.config["APP_TIMEZONE"])
def local_now() -> datetime:
return datetime.now(UTC).astimezone(get_timezone())
def today_local() -> date:
return local_now().date()
def previous_month(year: int, month: int) -> tuple[int, int]:
if month == 1:
return year - 1, 12
return year, month - 1
def next_month(year: int, month: int) -> tuple[int, int]:
if month == 12:
return year + 1, 1
return year, month + 1
def month_label(year: int, month: int) -> str:
return f"{MONTH_NAMES[month]} {year}"
def add_months(base_date: date, months: int) -> date:
month_index = base_date.month - 1 + months
year = base_date.year + month_index // 12
month = month_index % 12 + 1
day = min(base_date.day, calendar.monthrange(year, month)[1])
return date(year, month, day)
def month_bounds(year: int, month: int) -> tuple[datetime, datetime]:
start = datetime(year, month, 1)
next_year, next_month_value = next_month(year, month)
end = datetime(next_year, next_month_value, 1)
return start, end

123
app/services/monthly.py Normal file
View File

@@ -0,0 +1,123 @@
from __future__ import annotations
from collections import defaultdict
from datetime import datetime
from sqlalchemy import extract, select
from ..extensions import db
from ..models import BadgeDefinition, MonthlyScoreSnapshot, TaskInstance, User
from .badges import compute_badge_awards
from .dates import local_now, month_bounds, next_month, previous_month
def _build_ranking(rows: list[dict]) -> list[dict]:
rows.sort(key=lambda row: (-row["total_points"], -row["completed_tasks_count"], row["user"].name.lower()))
for index, row in enumerate(rows, start=1):
row["rank"] = index
return rows
def compute_monthly_scores(year: int, month: int) -> list[dict]:
start, end = month_bounds(year, month)
users = User.query.order_by(User.name.asc()).all()
badges = BadgeDefinition.query.filter_by(active=True).all()
completed_tasks = TaskInstance.query.filter(
TaskInstance.completed_at.isnot(None),
TaskInstance.completed_at >= start,
TaskInstance.completed_at < end,
).all()
tasks_by_user: dict[int, list[TaskInstance]] = defaultdict(list)
for task in completed_tasks:
if task.completed_by_user_id:
tasks_by_user[task.completed_by_user_id].append(task)
rows = []
for user in users:
personal_tasks = tasks_by_user.get(user.id, [])
base_points = sum(task.points_awarded for task in personal_tasks)
awards = compute_badge_awards(badges, personal_tasks)
bonus_points = sum(award["bonus_points"] for award in awards)
rows.append(
{
"user": user,
"base_points": base_points,
"bonus_points": bonus_points,
"total_points": base_points + bonus_points,
"completed_tasks_count": len(personal_tasks),
"badges": awards,
}
)
return _build_ranking(rows)
def ensure_monthly_snapshots(reference: datetime | None = None) -> None:
now = reference or local_now().replace(tzinfo=None)
target_year, target_month = previous_month(now.year, now.month)
if MonthlyScoreSnapshot.query.filter_by(year=target_year, month=target_month).count():
return
snapshot_rows = compute_monthly_scores(target_year, target_month)
for row in snapshot_rows:
db.session.add(
MonthlyScoreSnapshot(
year=target_year,
month=target_month,
user_id=row["user"].id,
total_points=row["total_points"],
completed_tasks_count=row["completed_tasks_count"],
rank=row["rank"],
)
)
db.session.commit()
def archive_months_missing_up_to_previous() -> None:
now = local_now()
previous_year, previous_month_value = previous_month(now.year, now.month)
latest = (
MonthlyScoreSnapshot.query.order_by(MonthlyScoreSnapshot.year.desc(), MonthlyScoreSnapshot.month.desc()).first()
)
if latest:
year, month = next_month(latest.year, latest.month)
else:
year, month = previous_year, previous_month_value
while (year, month) <= (previous_year, previous_month_value):
if not MonthlyScoreSnapshot.query.filter_by(year=year, month=month).count():
rows = compute_monthly_scores(year, month)
for row in rows:
db.session.add(
MonthlyScoreSnapshot(
year=year,
month=month,
user_id=row["user"].id,
total_points=row["total_points"],
completed_tasks_count=row["completed_tasks_count"],
rank=row["rank"],
)
)
db.session.commit()
year, month = next_month(year, month)
def get_archived_months(limit: int = 12) -> list[tuple[int, int]]:
rows = (
db.session.query(MonthlyScoreSnapshot.year, MonthlyScoreSnapshot.month)
.group_by(MonthlyScoreSnapshot.year, MonthlyScoreSnapshot.month)
.order_by(MonthlyScoreSnapshot.year.desc(), MonthlyScoreSnapshot.month.desc())
.limit(limit)
.all()
)
return [(row.year, row.month) for row in rows]
def get_snapshot_rows(year: int, month: int) -> list[MonthlyScoreSnapshot]:
return (
MonthlyScoreSnapshot.query.filter_by(year=year, month=month)
.order_by(MonthlyScoreSnapshot.rank.asc(), MonthlyScoreSnapshot.total_points.desc())
.all()
)

View 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

126
app/services/tasks.py Normal file
View File

@@ -0,0 +1,126 @@
from __future__ import annotations
from datetime import date, datetime, timedelta
from sqlalchemy import select
from ..extensions import db
from ..models import TaskInstance, TaskTemplate
from .dates import add_months, today_local
def refresh_task_status(task: TaskInstance, reference_date: date | None = None) -> bool:
status = task.compute_status(reference_date or today_local())
if task.status != status:
task.status = status
return True
return False
def refresh_task_statuses(tasks: list[TaskInstance]) -> None:
dirty = any(refresh_task_status(task) for task in tasks)
if dirty:
db.session.commit()
def create_task_template_and_instance(form) -> TaskInstance:
template = TaskTemplate(
title=form.title.data.strip(),
description=(form.description.data or "").strip(),
default_points=form.default_points.data,
default_assigned_user_id=form.assigned_user_id.data,
recurrence_interval_value=form.recurrence_interval_value.data if form.recurrence_interval_unit.data != "none" else None,
recurrence_interval_unit=form.recurrence_interval_unit.data,
active=form.active.data,
)
db.session.add(template)
db.session.flush()
task = TaskInstance(
task_template_id=template.id,
title=template.title,
description=template.description,
assigned_user_id=template.default_assigned_user_id,
due_date=form.due_date.data,
points_awarded=template.default_points,
status="open",
)
refresh_task_status(task, form.due_date.data)
db.session.add(task)
db.session.commit()
return task
def update_template_and_instance(task: TaskInstance, form) -> TaskInstance:
template = task.task_template
template.title = form.title.data.strip()
template.description = (form.description.data or "").strip()
template.default_points = form.default_points.data
template.default_assigned_user_id = form.assigned_user_id.data
template.recurrence_interval_unit = form.recurrence_interval_unit.data
template.recurrence_interval_value = (
form.recurrence_interval_value.data if form.recurrence_interval_unit.data != "none" else None
)
template.active = form.active.data
task.title = template.title
task.description = template.description
task.assigned_user_id = template.default_assigned_user_id
task.points_awarded = template.default_points
task.due_date = form.due_date.data
refresh_task_status(task, form.due_date.data)
db.session.commit()
return task
def _next_due_date(task: TaskInstance) -> date | None:
template = task.task_template
value = template.recurrence_interval_value
if template.recurrence_interval_unit == "none" or not value:
return None
if template.recurrence_interval_unit == "days":
return task.due_date + timedelta(days=value)
if template.recurrence_interval_unit == "weeks":
return task.due_date + timedelta(weeks=value)
if template.recurrence_interval_unit == "months":
return add_months(task.due_date, value)
return None
def ensure_next_recurring_task(task: TaskInstance) -> TaskInstance | None:
next_due = _next_due_date(task)
if not next_due or not task.task_template.active:
return None
existing = db.session.scalar(
select(TaskInstance).where(
TaskInstance.task_template_id == task.task_template_id,
TaskInstance.due_date == next_due,
)
)
if existing:
return existing
next_task = TaskInstance(
task_template_id=task.task_template_id,
title=task.task_template.title,
description=task.task_template.description,
assigned_user_id=task.task_template.default_assigned_user_id,
due_date=next_due,
points_awarded=task.task_template.default_points,
status="open",
)
refresh_task_status(next_task, today_local())
db.session.add(next_task)
return next_task
def complete_task(task: TaskInstance, completed_by_user_id: int) -> TaskInstance:
if not task.completed_at:
task.completed_at = datetime.utcnow()
task.completed_by_user_id = completed_by_user_id
task.status = "completed"
ensure_next_recurring_task(task)
db.session.commit()
return task