User Profile sind nun verfügbar

This commit is contained in:
kai
2026-08-25 14:39:47 +02:00
parent 714194615f
commit 72ff659ac4
6 changed files with 291 additions and 4 deletions
+177
View File
@@ -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("<h1>Benutzer nicht gefunden</h1>", 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
]