User Profile sind nun verfügbar
This commit is contained in:
+177
@@ -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
|
||||
]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user