diff --git a/app/main.py b/app/main.py
index 1b0f2f2..38ca41d 100644
--- a/app/main.py
+++ b/app/main.py
@@ -35,8 +35,16 @@ PHOTO_DIR = os.path.join(
"photos"
)
+AVATAR_DIR = os.path.join(
+ BASE_DIR,
+ "static",
+ "uploads",
+ "avatars"
+)
+
os.makedirs(UPLOAD_DIR, exist_ok=True)
os.makedirs(PHOTO_DIR, exist_ok=True)
+os.makedirs(AVATAR_DIR, exist_ok=True)
SESSION_COOKIE = "pingu_session"
SESSION_DAYS = 30
@@ -46,6 +54,13 @@ INITIAL_ADMIN_USERNAME = os.environ.get("INITIAL_ADMIN_USERNAME")
INITIAL_ADMIN_PASSWORD = os.environ.get("INITIAL_ADMIN_PASSWORD")
INITIAL_ADMIN_EMAIL = os.environ.get("INITIAL_ADMIN_EMAIL")
+BADGE_DEFINITIONS = (
+ ("first_gig", "Erster Gig", "🎸", 1, "Dein erstes besuchtes Konzert"),
+ ("regular", "Stammgast", "🤘", 5, "5 Konzerte besucht"),
+ ("ten_gigs", "Zehnerrunde", "🔥", 10, "10 Konzerte besucht"),
+ ("tour_veteran", "Tourveteran", "âš¡", 25, "25 Konzerte besucht"),
+)
+
def get_db_connection():
return psycopg.connect(DATABASE_URL)
@@ -69,6 +84,10 @@ def ensure_schema():
ADD COLUMN IF NOT EXISTS is_admin BOOLEAN NOT NULL DEFAULT FALSE
""",
"""
+ ALTER TABLE users
+ ADD COLUMN IF NOT EXISTS avatar_path TEXT
+ """,
+ """
CREATE TABLE IF NOT EXISTS registration_invites (
id SERIAL PRIMARY KEY,
token_hash TEXT NOT NULL UNIQUE,
@@ -120,6 +139,14 @@ def ensure_schema():
PRIMARY KEY (concert_id, user_id)
)
""",
+ """
+ CREATE TABLE IF NOT EXISTS user_badges (
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ badge_code VARCHAR(50) NOT NULL,
+ awarded_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (user_id, badge_code)
+ )
+ """,
]
with get_db_connection() as connection:
@@ -449,6 +476,74 @@ def save_image(upload: UploadFile, destination_dir: str, url_prefix: str):
return f"{url_prefix}{filename}", None
+def attended_concert_count(user_id: int) -> int:
+ with get_db_connection() as connection:
+ with connection.cursor() as cursor:
+ cursor.execute(
+ """
+ SELECT COUNT(*)
+ 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
+ """,
+ (user_id,),
+ )
+ return cursor.fetchone()[0]
+
+
+def grant_earned_badges(user_id: int, attended_count: int):
+ with get_db_connection() as connection:
+ with connection.cursor() as cursor:
+ for badge_code, _name, _icon, threshold, _description in BADGE_DEFINITIONS:
+ if attended_count >= threshold:
+ cursor.execute(
+ """
+ INSERT INTO user_badges (user_id, badge_code)
+ VALUES (%s, %s)
+ ON CONFLICT (user_id, badge_code) DO NOTHING
+ """,
+ (user_id, badge_code),
+ )
+ connection.commit()
+
+
+def load_profile(username: str):
+ with get_db_connection() as connection:
+ with connection.cursor() as cursor:
+ cursor.execute(
+ """
+ SELECT id, username, display_name, avatar_path, created_at
+ FROM users
+ WHERE LOWER(username) = LOWER(%s)
+ """,
+ (username,),
+ )
+ row = cursor.fetchone()
+
+ if not row:
+ return None
+
+ cursor.execute(
+ "SELECT badge_code FROM user_badges WHERE user_id = %s",
+ (row[0],),
+ )
+ earned_codes = {badge_row[0] for badge_row in cursor.fetchall()}
+
+ return {
+ "id": row[0],
+ "username": row[1],
+ "display_name": row[2] or row[1],
+ "avatar_path": row[3],
+ "created_at": row[4].strftime("%d.%m.%Y"),
+ "earned_codes": earned_codes,
+ }
+
+
def resolve_venue(
cursor,
venue_id: str,
@@ -1027,6 +1122,86 @@ def home(request: Request):
)
+# ============================================================
+# Profiles
+# ============================================================
+
+def render_profile(request: Request, username: str):
+ viewer = get_current_user(request)
+ profile = load_profile(username)
+
+ if not profile:
+ return HTMLResponse("
Benutzer nicht gefunden
", status_code=404)
+
+ attended_count = attended_concert_count(profile["id"])
+ grant_earned_badges(profile["id"], attended_count)
+ profile = load_profile(username)
+ badges = [
+ {
+ "code": code,
+ "name": name,
+ "icon": icon,
+ "threshold": threshold,
+ "description": description,
+ "earned": code in profile["earned_codes"],
+ }
+ for code, name, icon, threshold, description in BADGE_DEFINITIONS
+ ]
+
+ template = templates.get_template("profile.html")
+ return template.render(
+ user=viewer,
+ profile=profile,
+ attended_count=attended_count,
+ badges=badges,
+ is_own_profile=viewer["id"] == profile["id"],
+ )
+
+
+@app.get("/profile", response_class=HTMLResponse)
+def own_profile(request: Request):
+ user = get_current_user(request)
+ return render_profile(request, user["username"])
+
+
+@app.get("/users/{username}", response_class=HTMLResponse)
+def user_profile(request: Request, username: str):
+ return render_profile(request, username)
+
+
+@app.post("/profile")
+async def update_profile(
+ request: Request,
+ display_name: str = Form(""),
+ avatar: UploadFile | None = File(None),
+):
+ user = get_current_user(request)
+ avatar_path, error = save_image(
+ avatar,
+ AVATAR_DIR,
+ "/static/uploads/avatars/",
+ )
+
+ if error:
+ return error
+
+ with get_db_connection() as connection:
+ with connection.cursor() as cursor:
+ cursor.execute(
+ """
+ UPDATE users
+ SET
+ display_name = COALESCE(NULLIF(%s, ''), display_name),
+ avatar_path = COALESCE(%s, avatar_path)
+ WHERE id = %s
+ """,
+ (display_name.strip(), avatar_path, user["id"]),
+ )
+ connection.commit()
+
+ return RedirectResponse("/profile", status_code=303)
+
+
# ============================================================
# New concert
# ============================================================
@@ -1124,6 +1299,7 @@ def concert_detail(request: Request, concert_id: int):
"body": row[1],
"created_at": row[2].strftime("%d.%m.%Y %H:%M"),
"author": row[3] or row[4],
+ "username": row[4],
}
for row in comment_rows
]
@@ -1143,6 +1319,7 @@ def concert_detail(request: Request, concert_id: int):
"user_id": row[0],
"status": row[1],
"name": row[2] or row[3],
+ "username": row[3],
}
for row in attendance_rows
]
diff --git a/app/templates/concert_detail.html b/app/templates/concert_detail.html
index e04e04b..1c1d0e5 100644
--- a/app/templates/concert_detail.html
+++ b/app/templates/concert_detail.html
@@ -255,7 +255,7 @@
{% if attending_users %}
{% else %}
@@ -269,7 +269,7 @@
{% if maybe_users %}
{% else %}
@@ -283,7 +283,7 @@
{% if ticket_seekers %}
{% for seeker in ticket_seekers %}
- - {{ seeker.name }}
+ - {{ seeker.name }}
{% endfor %}
{% else %}
@@ -323,7 +323,7 @@
{% for comment in comments %}