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
]
+4 -4
View File
@@ -255,7 +255,7 @@
{% if attending_users %}
<ul>
{% for attendee in attending_users %}
<li>{{ attendee.name }}</li>
<li><a href="/users/{{ attendee.username }}">{{ attendee.name }}</a></li>
{% endfor %}
</ul>
{% else %}
@@ -269,7 +269,7 @@
{% if maybe_users %}
<ul>
{% for maybe_user in maybe_users %}
<li>{{ maybe_user.name }}</li>
<li><a href="/users/{{ maybe_user.username }}">{{ maybe_user.name }}</a></li>
{% endfor %}
</ul>
{% else %}
@@ -283,7 +283,7 @@
{% if ticket_seekers %}
<ul>
{% for seeker in ticket_seekers %}
<li>{{ seeker.name }}</li>
<li><a href="/users/{{ seeker.username }}">{{ seeker.name }}</a></li>
{% endfor %}
</ul>
{% else %}
@@ -323,7 +323,7 @@
{% for comment in comments %}
<article class="comment">
<div class="comment-meta">
<strong>{{ comment.author }}</strong>
<strong><a href="/users/{{ comment.username }}">{{ comment.author }}</a></strong>
<span>{{ comment.created_at }}</span>
</div>
<p>{{ comment.body }}</p>
+4
View File
@@ -167,6 +167,10 @@
<div class="header-actions">
<a href="/profile" class="admin-button">
👤 Mein Profil
</a>
{% if user.is_admin %}
<a href="/admin" class="admin-button">
+88
View File
@@ -0,0 +1,88 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ profile.display_name }} · Pingu Concerts</title>
<link rel="stylesheet" href="/static/css/style.css">
<style>
.profile-layout { display: grid; grid-template-columns: minmax(240px, .8fr) minmax(0, 1.4fr); gap: 24px; align-items: start; }
.profile-card, .badges-card, .profile-edit { padding: 24px; background: var(--surface); border: 1px solid var(--border); border-radius: 16px; }
.profile-card { text-align: center; }
.avatar, .avatar-fallback { width: 132px; height: 132px; margin: 0 auto 16px; border-radius: 50%; border: 3px solid var(--accent); object-fit: cover; }
.avatar-fallback { display: grid; place-items: center; background: #283048; font-size: 3rem; font-weight: 800; }
.profile-name { margin: 0; font-size: 1.7rem; }
.profile-handle, .profile-since { color: var(--muted); margin: 6px 0 0; }
.stat { margin-top: 22px; padding: 16px; background: #0f1420; border-radius: 12px; }
.stat strong { display: block; color: #c4b5fd; font-size: 2.2rem; }
.kutte { position: relative; min-height: 320px; padding: 58px 36px 28px; background: linear-gradient(90deg, #151515 0 46%, #222 46% 54%, #151515 54%); border: 4px solid #353535; border-radius: 28px 28px 14px 14px; box-shadow: inset 0 0 0 2px #090909; }
.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-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-icon { font-size: 1.6rem; }
.patch small { color: #d1d5db; }
.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); }
.profile-edit { margin-top: 24px; }
.profile-edit form { margin: 0; max-width: none; }
.profile-edit input { margin-bottom: 14px; }
@media (max-width: 720px) { .profile-layout { grid-template-columns: 1fr; } .kutte { padding-left: 20px; padding-right: 20px; } }
</style>
</head>
<body>
<header>
<div class="header-inner">
<a href="/" class="logo">🎸 Pingu <span>Concerts</span></a>
<a href="/profile">Mein Profil</a>
</div>
</header>
<main>
<div class="page-title">
<h1>Profil</h1>
<p>Deine Konzertgeschichte und deine Kutte.</p>
</div>
<div class="profile-layout">
<section class="profile-card">
{% if profile.avatar_path %}
<img class="avatar" src="{{ profile.avatar_path }}" alt="Profilbild von {{ profile.display_name }}">
{% else %}
<div class="avatar-fallback" aria-label="Kein Profilbild">{{ profile.display_name[:1] }}</div>
{% endif %}
<h2 class="profile-name">{{ profile.display_name }}</h2>
<p class="profile-handle">@{{ profile.username }}</p>
<p class="profile-since">Dabei seit {{ profile.created_at }}</p>
<div class="stat"><strong>{{ attended_count }}</strong>besuchte Konzerte</div>
</section>
<section class="badges-card">
<h2>🥋 Virtuelle Kutte</h2>
<p>Für besuchte Konzerte schaltest du neue Patches frei.</p>
<div class="kutte">
<div class="patch-grid">
{% for badge in badges %}
<div class="patch {% if badge.earned %}earned{% else %}locked{% endif %}" title="{{ badge.description }}">
<span class="patch-icon">{{ badge.icon }}</span>
<strong>{{ badge.name }}</strong>
<small>{% if badge.earned %}Freigeschaltet{% else %}ab {{ badge.threshold }} Konzerten{% endif %}</small>
</div>
{% endfor %}
</div>
</div>
</section>
</div>
{% if is_own_profile %}
<section class="profile-edit">
<h2>Profil bearbeiten</h2>
<form method="post" action="/profile" enctype="multipart/form-data">
<label for="display-name">Anzeigename</label>
<input id="display-name" name="display_name" type="text" value="{{ profile.display_name }}" maxlength="100">
<label for="avatar">Profilbild</label>
<input id="avatar" name="avatar" type="file" accept="image/jpeg,image/png,image/webp">
<button class="button" type="submit">Profil speichern</button>
</form>
</section>
{% endif %}
</main>
</body>
</html>
+8
View File
@@ -4,6 +4,7 @@ CREATE TABLE users (
email VARCHAR(255) NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name VARCHAR(100),
avatar_path TEXT,
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
@@ -79,6 +80,13 @@ CREATE TABLE concert_attendance (
PRIMARY KEY (concert_id, user_id)
);
CREATE TABLE 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)
);
CREATE INDEX idx_concerts_start_datetime
ON concerts(start_datetime);
+10
View File
@@ -13,6 +13,9 @@ CREATE TABLE IF NOT EXISTS users (
ALTER TABLE users
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,
@@ -61,3 +64,10 @@ CREATE TABLE IF NOT EXISTS concert_attendance (
CREATE INDEX IF NOT EXISTS idx_concert_attendance_concert
ON concert_attendance(concert_id, status);
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)
);