feat: add persistent badges and admin badge page

This commit is contained in:
2026-04-13 10:19:38 +02:00
parent 3c99c3683e
commit c36abe82a8
27 changed files with 576 additions and 100 deletions

View File

@@ -1,17 +1,21 @@
from __future__ import annotations
import json
from collections import defaultdict
from datetime import date, timedelta
from datetime import date, datetime, time, timedelta
from ..models import BadgeDefinition, TaskInstance
from sqlalchemy import and_
from ..extensions import db
from ..models import BadgeDefinition, MonthlyScoreSnapshot, TaskInstance, User, UserBadge
from .dates import month_bounds, previous_month
def _max_day_streak(days: set[date]) -> int:
if not days:
return 0
streak = 1
best = 1
ordered = sorted(days)
best = streak = 1
for previous, current in zip(ordered, ordered[1:]):
if current == previous + timedelta(days=1):
streak += 1
@@ -21,33 +25,166 @@ def _max_day_streak(days: set[date]) -> int:
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()
def award_badge(user: User, badge_key: str, *, awarded_at: datetime | None = None, context: dict | None = None) -> bool:
definition = BadgeDefinition.query.filter_by(key=badge_key, active=True).first()
if not definition:
return False
for task in completed_tasks:
if not task.completed_at:
continue
existing = UserBadge.query.filter_by(user_id=user.id, badge_definition_id=definition.id).first()
if existing:
return False
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,
)
)
return True
def badge_awards_for_month(user_id: int, year: int, month: int) -> list[UserBadge]:
start, end = month_bounds(year, month)
return (
UserBadge.query.filter(
UserBadge.user_id == user_id,
UserBadge.awarded_at >= start,
UserBadge.awarded_at < end,
)
.join(BadgeDefinition)
.filter(BadgeDefinition.active.is_(True))
.order_by(UserBadge.awarded_at.asc())
.all()
)
def earned_badges_for_user(user_id: int) -> list[UserBadge]:
return (
UserBadge.query.filter_by(user_id=user_id)
.join(BadgeDefinition)
.order_by(UserBadge.awarded_at.desc())
.all()
)
def _completion_metrics(user: User) -> dict[str, int]:
tasks = (
TaskInstance.query.filter(
TaskInstance.completed_by_user_id == user.id,
TaskInstance.completed_at.isnot(None),
)
.order_by(TaskInstance.completed_at.asc())
.all()
)
completion_days: set[date] = set()
metrics = defaultdict(int)
max_points = 0
for task in tasks:
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
metrics["first_task_completed"] += 1
metrics["total_tasks_completed"] += 1
if completion_day <= task.due_date:
metrics["on_time_tasks_completed"] += 1
if completion_day <= task.due_date - timedelta(days=1):
metrics["early_tasks_completed"] += 1
if task.assigned_user_id and task.assigned_user_id != user.id:
metrics["foreign_tasks_completed"] += 1
max_points = max(max_points, task.points_awarded)
by_type["streak_days"] = _max_day_streak(completion_days)
metrics["streak_days"] = _max_day_streak(completion_days)
metrics["high_point_task"] = max_points
return metrics
def evaluate_task_badges(user: User) -> list[str]:
definitions = BadgeDefinition.query.filter_by(active=True).all()
metrics = _completion_metrics(user)
unlocked: list[str] = []
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
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 unlocked:
db.session.commit()
return unlocked
def _month_end_award_time(year: int, month: int) -> datetime:
_, end = month_bounds(year, month)
return end - timedelta(seconds=1)
def _user_had_clean_month(user_id: int, year: int, month: int) -> bool:
start_date = date(year, month, 1)
end_date = (month_bounds(year, month)[1] - timedelta(days=1)).date()
tasks = TaskInstance.query.filter(
TaskInstance.assigned_user_id == user_id,
TaskInstance.due_date >= start_date,
TaskInstance.due_date <= end_date,
).all()
if not tasks:
return False
for task in tasks:
if not task.completed_at:
return False
if task.completed_at.date() > task.due_date:
return False
return True
def _winner_user_ids(year: int, month: int) -> set[int]:
rows = MonthlyScoreSnapshot.query.filter_by(year=year, month=month, rank=1).all()
return {row.user_id for row in rows}
def evaluate_monthly_badges(year: int, month: int) -> list[str]:
award_time = _month_end_award_time(year, month)
unlocked: list[str] = []
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")
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")
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")
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")
if unlocked:
db.session.commit()
return unlocked
def sync_existing_badges() -> None:
for user in User.query.order_by(User.id.asc()).all():
evaluate_task_badges(user)
archived_months = (
db.session.query(MonthlyScoreSnapshot.year, MonthlyScoreSnapshot.month)
.group_by(MonthlyScoreSnapshot.year, MonthlyScoreSnapshot.month)
.order_by(MonthlyScoreSnapshot.year.asc(), MonthlyScoreSnapshot.month.asc())
.all()
)
for row in archived_months:
evaluate_monthly_badges(row.year, row.month)

View File

@@ -3,11 +3,9 @@ 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 ..models import MonthlyScoreSnapshot, TaskInstance, User
from .badges import badge_awards_for_month, evaluate_monthly_badges
from .dates import local_now, month_bounds, next_month, previous_month
@@ -21,7 +19,6 @@ def _build_ranking(rows: list[dict]) -> list[dict]:
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,
@@ -37,8 +34,8 @@ def compute_monthly_scores(year: int, month: int) -> list[dict]:
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)
earned_badges = badge_awards_for_month(user.id, year, month)
bonus_points = sum(badge.badge_definition.bonus_points for badge in earned_badges if badge.badge_definition.active)
rows.append(
{
"user": user,
@@ -46,7 +43,7 @@ def compute_monthly_scores(year: int, month: int) -> list[dict]:
"bonus_points": bonus_points,
"total_points": base_points + bonus_points,
"completed_tasks_count": len(personal_tasks),
"badges": awards,
"badges": earned_badges,
}
)
return _build_ranking(rows)
@@ -100,6 +97,7 @@ def archive_months_missing_up_to_previous() -> None:
)
)
db.session.commit()
evaluate_monthly_badges(year, month)
year, month = next_month(year, month)
@@ -120,4 +118,3 @@ def get_snapshot_rows(year: int, month: int) -> list[MonthlyScoreSnapshot]:
.order_by(MonthlyScoreSnapshot.rank.asc(), MonthlyScoreSnapshot.total_points.desc())
.all()
)

View File

@@ -6,6 +6,7 @@ from sqlalchemy import select
from ..extensions import db
from ..models import TaskInstance, TaskTemplate
from .badges import evaluate_task_badges
from .dates import add_months, today_local
@@ -122,5 +123,6 @@ def complete_task(task: TaskInstance, completed_by_user_id: int) -> TaskInstance
task.status = "completed"
ensure_next_recurring_task(task)
db.session.commit()
if task.completed_by_user:
evaluate_task_badges(task.completed_by_user)
return task