Protect founder account and add attendance badge levels

This commit is contained in:
kai
2026-08-26 18:56:02 +02:00
parent c4fa6f077c
commit 41b2c5cd61
2 changed files with 39 additions and 13 deletions
+25 -4
View File
@@ -78,12 +78,14 @@ INITIAL_ADMIN_EMAIL = os.environ.get("INITIAL_ADMIN_EMAIL")
BADGE_DEFINITIONS = ( BADGE_DEFINITIONS = (
("founder", "Gründer", "⚔️", None, "Von Anfang an dabei und Pingu Concerts mit aufgebaut", "special"), ("founder", "Gründer", "⚔️", None, "Von Anfang an dabei und Pingu Concerts mit aufgebaut", "special"),
("admin", "Admin", "🛡", None, "Verantwortung für Pingu Concerts", "special"), ("admin", "Admin", "🏴‍☠", None, "Verantwortung für Pingu Concerts", "special"),
("beta_tester", "Beta Tester", "🧪", None, "In der Beta dabei", "beta"), ("beta_tester", "Beta Tester", "🧪", None, "In der Beta dabei", "beta"),
("first_gig", "Erster Gig", "🎸", 1, "Dein erstes besuchtes Konzert", "attendance"), ("first_gig", "Erster Gig", "🎸", 1, "Dein erstes besuchtes Konzert", "attendance"),
("regular", "Stammgast", "🤘", 5, "5 Konzerte besucht", "attendance"), ("regular", "Stammgast", "🤘", 5, "5 Konzerte besucht", "attendance"),
("ten_gigs", "Zehnerrunde", "🔥", 10, "10 Konzerte besucht", "attendance"), ("ten_gigs", "Zehnerrunde", "🔥", 10, "10 Konzerte besucht", "attendance"),
("tour_veteran", "Tourveteran", "", 25, "25 Konzerte besucht", "attendance"), ("tour_veteran", "Tourveteran", "", 25, "25 Konzerte besucht", "attendance"),
("fifty_gigs", "Fünfzig Konzerte", "💀", 50, "50 Konzerte besucht", "attendance"),
("hundred_gigs", "Hunderterclub", "👑", 100, "100 Konzerte besucht", "attendance"),
) )
ATTENDANCE_BADGE_CODES = tuple( ATTENDANCE_BADGE_CODES = tuple(
badge_code badge_code
@@ -119,6 +121,11 @@ def ensure_schema():
ADD COLUMN IF NOT EXISTS is_admin BOOLEAN NOT NULL DEFAULT FALSE ADD COLUMN IF NOT EXISTS is_admin BOOLEAN NOT NULL DEFAULT FALSE
""", """,
""" """
UPDATE users
SET is_admin = TRUE
WHERE LOWER(username) = 'kai'
""",
"""
ALTER TABLE users ALTER TABLE users
ADD COLUMN IF NOT EXISTS avatar_path TEXT ADD COLUMN IF NOT EXISTS avatar_path TEXT
""", """,
@@ -1107,11 +1114,13 @@ def grant_earned_badges(user_id: int, attended_count: int, registered_at):
(user_id,), (user_id,),
) )
if highest_attendance_badge: # Konzert-Patches sind eine Level-Leiste: immer nur die höchste
# erreichte Stufe anzeigen (ältere Stufen werden ersetzt).
cursor.execute( cursor.execute(
"DELETE FROM user_badges WHERE user_id = %s AND badge_code = ANY(%s)", "DELETE FROM user_badges WHERE user_id = %s AND badge_code = ANY(%s)",
(user_id, list(ATTENDANCE_BADGE_CODES)), (user_id, list(ATTENDANCE_BADGE_CODES)),
) )
if highest_attendance_badge:
cursor.execute( cursor.execute(
""" """
INSERT INTO user_badges (user_id, badge_code) INSERT INTO user_badges (user_id, badge_code)
@@ -1685,6 +1694,13 @@ def update_user_role(
with get_db_connection() as connection: with get_db_connection() as connection:
with connection.cursor() as cursor: with connection.cursor() as cursor:
cursor.execute("SELECT username FROM users WHERE id = %s", (user_id,))
target_user = cursor.fetchone()
if target_user and (target_user[0] or "").casefold() == "kai" and role != "admin":
return HTMLResponse(
"<h1>Der Gründer Kai muss Admin bleiben.</h1>",
status_code=400,
)
cursor.execute( cursor.execute(
"UPDATE users SET is_admin = %s WHERE id = %s", "UPDATE users SET is_admin = %s WHERE id = %s",
(role == "admin", user_id), (role == "admin", user_id),
@@ -1708,15 +1724,20 @@ def delete_user(request: Request, user_id: int):
with get_db_connection() as connection: with get_db_connection() as connection:
with connection.cursor() as cursor: with connection.cursor() as cursor:
cursor.execute("SELECT avatar_path FROM users WHERE id = %s", (user_id,)) cursor.execute("SELECT username, avatar_path FROM users WHERE id = %s", (user_id,))
target_user = cursor.fetchone() target_user = cursor.fetchone()
if target_user and (target_user[0] or "").casefold() == "kai":
return HTMLResponse(
"<h1>Der Gründer Kai kann nicht gelöscht werden.</h1>",
status_code=400,
)
cursor.execute("SELECT path FROM concert_photos WHERE user_id = %s", (user_id,)) cursor.execute("SELECT path FROM concert_photos WHERE user_id = %s", (user_id,))
photo_paths = [row[0] for row in cursor.fetchall()] photo_paths = [row[0] for row in cursor.fetchall()]
cursor.execute("DELETE FROM users WHERE id = %s", (user_id,)) cursor.execute("DELETE FROM users WHERE id = %s", (user_id,))
connection.commit() connection.commit()
if target_user: if target_user:
remove_uploaded_file(target_user[0], AVATAR_DIR, "/static/uploads/avatars/") remove_uploaded_file(target_user[1], AVATAR_DIR, "/static/uploads/avatars/")
for photo_path in photo_paths: for photo_path in photo_paths:
remove_uploaded_file(photo_path, PHOTO_DIR, "/static/uploads/photos/") remove_uploaded_file(photo_path, PHOTO_DIR, "/static/uploads/photos/")
+8 -3
View File
@@ -12,6 +12,7 @@
.role-form { display: flex; gap: 8px; } .role-form { display: flex; gap: 8px; }
.role-form label { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; } .role-form label { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
.role-form label input { width: auto; margin: 0; } .role-form label input { width: auto; margin: 0; }
.founder-lock { color: #fbbf24; font-weight: 700; white-space: nowrap; }
@media (max-width: 700px) { .user-row { grid-template-columns: 1fr; } } @media (max-width: 700px) { .user-row { grid-template-columns: 1fr; } }
</style> </style>
</head> </head>
@@ -31,15 +32,19 @@
<div class="admin-list"> <div class="admin-list">
{% for managed_user in users %} {% for managed_user in users %}
<div class="admin-row user-row"> <div class="admin-row user-row">
<div><strong>{{ managed_user.display_name }}</strong><div class="admin-meta">@{{ managed_user.username }} · {{ managed_user.email }} · seit {{ managed_user.created_at }}</div></div> <div><strong>{% if managed_user.username|lower == 'kai' %}🏴‍☠️ {% endif %}{{ managed_user.display_name }}</strong><div class="admin-meta">@{{ managed_user.username }} · {{ managed_user.email }} · seit {{ managed_user.created_at }}</div></div>
{% if managed_user.username|lower == 'kai' %}
<span class="founder-lock">🏴‍☠️ Gründer · Admin (fest)</span>
{% else %}
<form class="role-form" method="post" action="/admin/users/{{ managed_user.id }}"> <form class="role-form" method="post" action="/admin/users/{{ managed_user.id }}">
<select name="role" aria-label="Rolle für {{ managed_user.username }}"><option value="user" {% if not managed_user.is_admin %}selected{% endif %}>Benutzer</option><option value="admin" {% if managed_user.is_admin %}selected{% endif %}>Admin</option></select> <select name="role" aria-label="Rolle für {{ managed_user.username }}"><option value="user" {% if not managed_user.is_admin %}selected{% endif %}>Benutzer</option><option value="admin" {% if managed_user.is_admin %}selected{% endif %}>🏴‍☠️ Admin</option></select>
<button class="button" type="submit">Speichern</button> <button class="button" type="submit">Speichern</button>
</form> </form>
{% endif %}
<div class="role-form"> <div class="role-form">
<form method="post" action="/admin/users/{{ managed_user.id }}/account-link"><input type="hidden" name="purpose" value="password_reset"><button class="button button-secondary" type="submit">Passwort-Reset</button></form> <form method="post" action="/admin/users/{{ managed_user.id }}/account-link"><input type="hidden" name="purpose" value="password_reset"><button class="button button-secondary" type="submit">Passwort-Reset</button></form>
</div> </div>
{% if managed_user.id != user.id %}<form method="post" action="/admin/users/{{ managed_user.id }}/delete" onsubmit="return confirm('Diesen Benutzer wirklich löschen?');"><button class="admin-danger" type="submit">Löschen</button></form>{% endif %} {% if managed_user.id != user.id and managed_user.username|lower != 'kai' %}<form method="post" action="/admin/users/{{ managed_user.id }}/delete" onsubmit="return confirm('Diesen Benutzer wirklich löschen?');"><button class="admin-danger" type="submit">Löschen</button></form>{% endif %}
</div> </div>
{% endfor %} {% endfor %}
</div> </div>