feat: add travel and concert streak patches

This commit is contained in:
kai
2026-08-28 13:58:41 +02:00
parent 2080c5c971
commit f05bae9ab1
2 changed files with 107 additions and 39 deletions
+86 -27
View File
@@ -89,6 +89,10 @@ BADGE_DEFINITIONS = (
("tour_veteran", "25 Gigs", "", 25, "25 besuchte Konzerte", "attendance"),
("fifty_gigs", "50 Gigs", "💀", 50, "50 besuchte Konzerte", "attendance"),
("hundred_gigs", "100 Gigs", "👑", 100, "100 besuchte Konzerte", "attendance"),
("border_breaker", "BORDER BREAKER", "🛂", 1, "Erstes Konzert im Ausland", "travel"),
("globe_banger", "GLOBE BANGER", "🌍", 5, "Konzerte in 5 Ländern", "travel"),
("double_trouble", "DOUBLE TROUBLE", "", 2, "Zwei Konzerte an zwei aufeinanderfolgenden Tagen", "streak"),
("iron_weekend", "IRON WEEKEND", "🤘", 3, "Drei Konzerte innerhalb eines Wochenendes", "streak"),
)
ATTENDANCE_BADGE_CODES = tuple(
badge_code
@@ -104,6 +108,14 @@ BADGE_BY_CODE = {
badge_code: (name, icon, threshold, description, category)
for badge_code, name, icon, threshold, description, category in BADGE_DEFINITIONS
}
BADGE_CATEGORY_LABELS = {
"special": "⚔️ Spezial-Patches",
"beta": "🧪 Community",
"attendance": "🎸 Konzert-Meilensteine",
"venue": "🤘 Stammorte",
"travel": "🌍 Unterwegs",
"streak": "⚡ Tourmodus",
}
def get_db_connection():
@@ -1129,41 +1141,65 @@ def remove_uploaded_file(path: str | None, destination_dir: str, url_prefix: str
return True
def attended_concert_stats(user_id: int) -> tuple[int, int]:
def normalize_country(value: str) -> str:
country = (value or "").strip().casefold()
aliases = {"de": "deutschland", "deu": "deutschland", "germany": "deutschland"}
return aliases.get(country, country)
def attended_concert_stats(user_id: int) -> dict:
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"""
WITH attended AS (
SELECT concerts.venue_id
, concerts.event_type
FROM concert_attendance
JOIN concerts ON concerts.id = concert_attendance.concert_id
WHERE concert_attendance.user_id = %s
AND concert_attendance.status = 'attending'
AND COALESCE(concerts.end_datetime, concerts.start_datetime) < CURRENT_TIMESTAMP
), venue_counts AS (
SELECT venue_id, COUNT(*) AS visit_count
FROM attended
WHERE venue_id IS NOT NULL AND event_type <> 'other'
GROUP BY venue_id
)
SELECT
(SELECT COUNT(*) FROM attended),
COALESCE((SELECT MAX(visit_count) FROM venue_counts), 0)
SELECT concerts.venue_id, concerts.event_type,
concerts.start_datetime::date, COALESCE(venues.country, '')
FROM concert_attendance
JOIN concerts ON concerts.id = concert_attendance.concert_id
LEFT JOIN venues ON venues.id = concerts.venue_id
WHERE concert_attendance.user_id = %s
AND concert_attendance.status = 'attending'
AND COALESCE(concerts.end_datetime, concerts.start_datetime) < CURRENT_TIMESTAMP
""",
(user_id,),
)
total, max_same_venue = cursor.fetchone()
return total, max_same_venue
rows = cursor.fetchall()
venue_counts = {}
countries = set()
dates = []
weekend_counts = {}
for venue_id, event_type, concert_date, country_value in rows:
if venue_id is not None and event_type != "other":
venue_counts[venue_id] = venue_counts.get(venue_id, 0) + 1
country = normalize_country(country_value)
if country:
countries.add(country)
dates.append(concert_date)
if concert_date.isoweekday() >= 5:
weekend_key = concert_date.isocalendar()[:2]
weekend_counts[weekend_key] = weekend_counts.get(weekend_key, 0) + 1
distinct_dates = sorted(set(dates))
return {
"total": len(rows),
"max_same_venue": max(venue_counts.values(), default=0),
"country_count": len(countries),
"has_foreign_concert": any(country != "deutschland" for country in countries),
"has_consecutive_days": any(
later - earlier == timedelta(days=1)
for earlier, later in zip(distinct_dates, distinct_dates[1:])
),
"has_iron_weekend": max(weekend_counts.values(), default=0) >= 3,
}
def grant_earned_badges(user_id: int, attended_count: int, max_same_venue, registered_at):
def grant_earned_badges(user_id: int, stats: dict, registered_at):
highest_attendance_badge = None
venue_badge = "regular" if max_same_venue >= 5 else None
venue_badge = "regular" if stats["max_same_venue"] >= 5 else None
for badge_code, _name, _icon, threshold, _description, category in BADGE_DEFINITIONS:
qualifies = category == "attendance" and threshold is not None and attended_count >= threshold
qualifies = category == "attendance" and threshold is not None and stats["total"] >= threshold
if qualifies:
highest_attendance_badge = badge_code
@@ -1199,6 +1235,20 @@ def grant_earned_badges(user_id: int, attended_count: int, max_same_venue, regis
"INSERT INTO user_badges (user_id, badge_code) VALUES (%s, %s) ON CONFLICT DO NOTHING",
(user_id, venue_badge),
)
achievement_codes = []
if stats["has_foreign_concert"]:
achievement_codes.append("border_breaker")
if stats["country_count"] >= 5:
achievement_codes.append("globe_banger")
if stats["has_consecutive_days"]:
achievement_codes.append("double_trouble")
if stats["has_iron_weekend"]:
achievement_codes.append("iron_weekend")
for badge_code in achievement_codes:
cursor.execute(
"INSERT INTO user_badges (user_id, badge_code) VALUES (%s, %s) ON CONFLICT DO NOTHING",
(user_id, badge_code),
)
connection.commit()
@@ -2337,11 +2387,10 @@ def render_profile(
{"username": row[0], "display_name": row[1], "avatar_path": row[2]}
for row in cursor.fetchall()
]
attended_count, max_same_venue = attended_concert_stats(profile["id"])
attendance_stats = attended_concert_stats(profile["id"])
grant_earned_badges(
profile["id"],
attended_count,
max_same_venue,
attendance_stats,
profile["registered_at"],
)
profile = load_profile(username)
@@ -2361,14 +2410,24 @@ def render_profile(
for code, name, icon, threshold, description, category in BADGE_DEFINITIONS
]
badges.sort(key=lambda badge: badge["sort_key"])
badge_groups = [
{
"code": category,
"label": label,
"badges": [badge for badge in badges if badge["earned"] and badge["category"] == category],
}
for category, label in BADGE_CATEGORY_LABELS.items()
if any(badge["earned"] and badge["category"] == category for badge in badges)
]
template = templates.get_template("profile.html")
return HTMLResponse(
template.render(
user=viewer,
profile=profile,
attended_count=attended_count,
attended_count=attendance_stats["total"],
badges=badges,
badge_groups=badge_groups,
is_own_profile=force_own or is_own_profile,
can_view_details=can_view_details,
friendship=friendship,