first commit
This commit is contained in:
53
app/services/badges.py
Normal file
53
app/services/badges.py
Normal 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
68
app/services/dates.py
Normal 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
123
app/services/monthly.py
Normal 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()
|
||||
)
|
||||
|
||||
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
|
||||
126
app/services/tasks.py
Normal file
126
app/services/tasks.py
Normal 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
|
||||
|
||||
Reference in New Issue
Block a user