Kutten Test

This commit is contained in:
kai
2026-08-25 15:10:07 +02:00
parent 72ff659ac4
commit caddac9eab
2 changed files with 95 additions and 23 deletions
+78 -17
View File
@@ -42,9 +42,17 @@ AVATAR_DIR = os.path.join(
"avatars" "avatars"
) )
PATCH_DIR = os.path.join(
BASE_DIR,
"static",
"uploads",
"patches"
)
os.makedirs(UPLOAD_DIR, exist_ok=True) os.makedirs(UPLOAD_DIR, exist_ok=True)
os.makedirs(PHOTO_DIR, exist_ok=True) os.makedirs(PHOTO_DIR, exist_ok=True)
os.makedirs(AVATAR_DIR, exist_ok=True) os.makedirs(AVATAR_DIR, exist_ok=True)
os.makedirs(PATCH_DIR, exist_ok=True)
SESSION_COOKIE = "pingu_session" SESSION_COOKIE = "pingu_session"
SESSION_DAYS = 30 SESSION_DAYS = 30
@@ -55,11 +63,22 @@ INITIAL_ADMIN_PASSWORD = os.environ.get("INITIAL_ADMIN_PASSWORD")
INITIAL_ADMIN_EMAIL = os.environ.get("INITIAL_ADMIN_EMAIL") INITIAL_ADMIN_EMAIL = os.environ.get("INITIAL_ADMIN_EMAIL")
BADGE_DEFINITIONS = ( BADGE_DEFINITIONS = (
("first_gig", "Erster Gig", "🎸", 1, "Dein erstes besuchtes Konzert"), ("beta_tester", "Beta Tester", "🧪", None, "In der Beta dabei", "beta"),
("regular", "Stammgast", "🤘", 5, "5 Konzerte besucht"), ("first_gig", "Erster Gig", "🎸", 1, "Dein erstes besuchtes Konzert", "attendance"),
("ten_gigs", "Zehnerrunde", "🔥", 10, "10 Konzerte besucht"), ("regular", "Stammgast", "🤘", 5, "5 Konzerte besucht", "attendance"),
("tour_veteran", "Tourveteran", "", 25, "25 Konzerte besucht"), ("ten_gigs", "Zehnerrunde", "🔥", 10, "10 Konzerte besucht", "attendance"),
("tour_veteran", "Tourveteran", "", 25, "25 Konzerte besucht", "attendance"),
) )
ATTENDANCE_BADGE_CODES = tuple(
badge_code
for badge_code, _name, _icon, _threshold, _description, category in BADGE_DEFINITIONS
if category == "attendance"
)
BETA_REGISTRATION_DEADLINE = datetime(2026, 9, 16)
BADGE_BY_CODE = {
badge_code: (name, icon, threshold, description, category)
for badge_code, name, icon, threshold, description, category in BADGE_DEFINITIONS
}
def get_db_connection(): def get_db_connection():
@@ -147,6 +166,14 @@ def ensure_schema():
PRIMARY KEY (user_id, badge_code) PRIMARY KEY (user_id, badge_code)
) )
""", """,
"""
CREATE TABLE IF NOT EXISTS badge_assets (
badge_code VARCHAR(50) PRIMARY KEY,
path TEXT NOT NULL,
updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)
""",
] ]
with get_db_connection() as connection: with get_db_connection() as connection:
@@ -496,22 +523,48 @@ def attended_concert_count(user_id: int) -> int:
return cursor.fetchone()[0] return cursor.fetchone()[0]
def grant_earned_badges(user_id: int, attended_count: int): def grant_earned_badges(user_id: int, attended_count: int, registered_at):
highest_attendance_badge = None
for badge_code, _name, _icon, threshold, _description, category in BADGE_DEFINITIONS:
if category == "attendance" and attended_count >= threshold:
highest_attendance_badge = badge_code
with get_db_connection() as connection: with get_db_connection() as connection:
with connection.cursor() as cursor: with connection.cursor() as cursor:
for badge_code, _name, _icon, threshold, _description in BADGE_DEFINITIONS: if registered_at < BETA_REGISTRATION_DEADLINE:
if attended_count >= threshold: cursor.execute(
cursor.execute( """
""" INSERT INTO user_badges (user_id, badge_code)
INSERT INTO user_badges (user_id, badge_code) VALUES (%s, 'beta_tester')
VALUES (%s, %s) ON CONFLICT (user_id, badge_code) DO NOTHING
ON CONFLICT (user_id, badge_code) DO NOTHING """,
""", (user_id,),
(user_id, badge_code), )
)
if highest_attendance_badge:
cursor.execute(
"DELETE FROM user_badges WHERE user_id = %s AND badge_code = ANY(%s)",
(user_id, list(ATTENDANCE_BADGE_CODES)),
)
cursor.execute(
"""
INSERT INTO user_badges (user_id, badge_code)
VALUES (%s, %s)
ON CONFLICT (user_id, badge_code) DO NOTHING
""",
(user_id, highest_attendance_badge),
)
connection.commit() connection.commit()
def load_badge_assets():
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute("SELECT badge_code, path FROM badge_assets")
return {row[0]: row[1] for row in cursor.fetchall()}
def load_profile(username: str): def load_profile(username: str):
with get_db_connection() as connection: with get_db_connection() as connection:
with connection.cursor() as cursor: with connection.cursor() as cursor:
@@ -540,6 +593,7 @@ def load_profile(username: str):
"display_name": row[2] or row[1], "display_name": row[2] or row[1],
"avatar_path": row[3], "avatar_path": row[3],
"created_at": row[4].strftime("%d.%m.%Y"), "created_at": row[4].strftime("%d.%m.%Y"),
"registered_at": row[4],
"earned_codes": earned_codes, "earned_codes": earned_codes,
} }
@@ -1134,8 +1188,13 @@ def render_profile(request: Request, username: str):
return HTMLResponse("<h1>Benutzer nicht gefunden</h1>", status_code=404) return HTMLResponse("<h1>Benutzer nicht gefunden</h1>", status_code=404)
attended_count = attended_concert_count(profile["id"]) attended_count = attended_concert_count(profile["id"])
grant_earned_badges(profile["id"], attended_count) grant_earned_badges(
profile["id"],
attended_count,
profile["registered_at"],
)
profile = load_profile(username) profile = load_profile(username)
badge_assets = load_badge_assets()
badges = [ badges = [
{ {
"code": code, "code": code,
@@ -1143,9 +1202,11 @@ def render_profile(request: Request, username: str):
"icon": icon, "icon": icon,
"threshold": threshold, "threshold": threshold,
"description": description, "description": description,
"category": category,
"earned": code in profile["earned_codes"], "earned": code in profile["earned_codes"],
"image_path": badge_assets.get(code),
} }
for code, name, icon, threshold, description in BADGE_DEFINITIONS for code, name, icon, threshold, description, category in BADGE_DEFINITIONS
] ]
template = templates.get_template("profile.html") template = templates.get_template("profile.html")
+17 -6
View File
@@ -22,6 +22,7 @@
.patch-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(105px, 1fr)); gap: 14px; } .patch-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(105px, 1fr)); gap: 14px; }
.patch { min-height: 94px; padding: 10px 7px; display: grid; place-items: center; align-content: center; gap: 4px; text-align: center; background: #242424; border: 2px dashed #78716c; border-radius: 9px; color: #f8fafc; } .patch { min-height: 94px; padding: 10px 7px; display: grid; place-items: center; align-content: center; gap: 4px; text-align: center; background: #242424; border: 2px dashed #78716c; border-radius: 9px; color: #f8fafc; }
.patch-icon { font-size: 1.6rem; } .patch-icon { font-size: 1.6rem; }
.patch-image { width: 58px; height: 58px; object-fit: contain; }
.patch small { color: #d1d5db; } .patch small { color: #d1d5db; }
.patch.locked { opacity: .38; filter: grayscale(1); } .patch.locked { opacity: .38; filter: grayscale(1); }
.patch.earned { border-style: solid; border-color: #a78bfa; box-shadow: 0 0 12px rgba(167, 139, 250, .35); } .patch.earned { border-style: solid; border-color: #a78bfa; box-shadow: 0 0 12px rgba(167, 139, 250, .35); }
@@ -57,16 +58,26 @@
</section> </section>
<section class="badges-card"> <section class="badges-card">
<h2>🥋 Virtuelle Kutte</h2> <h2>🥋 Virtuelle Kutte</h2>
<p>Für besuchte Konzerte schaltest du neue Patches frei.</p> <p>Deine freigeschalteten Patches. Besuchs-Patches werden jeweils durch die höchste Stufe ersetzt.</p>
<div class="kutte"> <div class="kutte">
<div class="patch-grid"> <div class="patch-grid">
{% for badge in badges %} {% for badge in badges %}
<div class="patch {% if badge.earned %}earned{% else %}locked{% endif %}" title="{{ badge.description }}"> {% if badge.earned %}
<span class="patch-icon">{{ badge.icon }}</span> <div class="patch earned" title="{{ badge.description }}">
<strong>{{ badge.name }}</strong> {% if badge.image_path %}
<small>{% if badge.earned %}Freigeschaltet{% else %}ab {{ badge.threshold }} Konzerten{% endif %}</small> <img class="patch-image" src="{{ badge.image_path }}" alt="Patch {{ badge.name }}">
</div> {% else %}
<span class="patch-icon">{{ badge.icon }}</span>
{% endif %}
<strong>{{ badge.name }}</strong>
<small>Freigeschaltet</small>
</div>
{% endif %}
{% endfor %} {% endfor %}
{% if not profile.earned_codes %}
<p>Noch keine Patches dein erstes Konzert wartet!</p>
{% endif %}
</div> </div>
</div> </div>
</section> </section>