feat: add travel and concert streak patches
This commit is contained in:
+86
-27
@@ -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,
|
||||
|
||||
+21
-12
@@ -26,6 +26,10 @@
|
||||
.kutte::before, .kutte::after { content: ""; position: absolute; top: 0; width: 30%; height: 62px; background: #111; border-bottom: 3px solid #404040; }
|
||||
.kutte::before { left: 0; border-radius: 24px 0 45% 0; }
|
||||
.kutte::after { right: 0; border-radius: 0 24px 0 45%; }
|
||||
.patch-categories { display:grid; gap:10px; }
|
||||
.patch-category { padding:10px 12px; background:rgba(5,5,5,.55); border:1px solid #44403c; border-radius:10px; }
|
||||
.patch-category > summary { color:#fca5a5; cursor:pointer; font-weight:700; }
|
||||
.patch-category .patch-grid { margin-top:14px; }
|
||||
.patch-grid { display: flex; flex-wrap: wrap; gap: 14px; justify-content: flex-start; align-items: flex-start; }
|
||||
.patch { padding: 5px; display: grid; place-items: center; text-align: center; background: #242424; border: 2px dashed #78716c; border-radius: 9px; color: #f8fafc; }
|
||||
.patch-image-frame { width: fit-content; height: fit-content; }
|
||||
@@ -122,22 +126,27 @@
|
||||
</section>
|
||||
<section class="badges-card">
|
||||
<h2>🥋 Virtuelle Kutte</h2>
|
||||
<p>Deine freigeschalteten Patches. Besuchs-Patches werden jeweils durch die höchste Stufe ersetzt.</p>
|
||||
<p>Deine freigeschalteten Patches nach Kategorien. Besuchs-Patches werden jeweils durch die höchste Stufe ersetzt.</p>
|
||||
<div class="kutte">
|
||||
<div class="patch-grid">
|
||||
{% for badge in badges %}
|
||||
{% if badge.earned %}
|
||||
<div class="patch earned {% if badge.image_path %}patch-image-frame{% else %}patch-icon-frame{% endif %}" title="{{ badge.name }} – {{ badge.description }}" aria-label="{{ badge.name }}: {{ badge.description }}">
|
||||
{% if badge.image_path %}
|
||||
<img class="patch-image" src="{{ badge.image_path }}" alt="Patch {{ badge.name }}">
|
||||
{% else %}
|
||||
<span class="patch-icon">{{ badge.icon }}</span>
|
||||
{% endif %}
|
||||
<div class="patch-categories">
|
||||
{% for group in badge_groups %}
|
||||
<details class="patch-category">
|
||||
<summary>{{ group.label }} · {{ group.badges|length }}</summary>
|
||||
<div class="patch-grid">
|
||||
{% for badge in group.badges %}
|
||||
<div class="patch earned {% if badge.image_path %}patch-image-frame{% else %}patch-icon-frame{% endif %}" title="{{ badge.name }} – {{ badge.description }}" aria-label="{{ badge.name }}: {{ badge.description }}">
|
||||
{% if badge.image_path %}
|
||||
<img class="patch-image" src="{{ badge.image_path }}" alt="Patch {{ badge.name }}">
|
||||
{% else %}
|
||||
<span class="patch-icon">{{ badge.icon }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</details>
|
||||
{% endfor %}
|
||||
|
||||
{% if not profile.earned_codes %}
|
||||
{% if not badge_groups %}
|
||||
<p>Noch keine Patches – dein erstes Konzert wartet!</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user