feat: expand MetalCircle app and privacy controls

This commit is contained in:
kai
2026-08-28 12:57:31 +02:00
parent bba611ca94
commit e72b93a74a
9 changed files with 239 additions and 33 deletions
+2 -2
View File
@@ -6,5 +6,5 @@ INITIAL_ADMIN_USERNAME=Kai
INITIAL_ADMIN_PASSWORD=CHANGE_ME_TO_A_LONG_RANDOM_PASSWORD INITIAL_ADMIN_PASSWORD=CHANGE_ME_TO_A_LONG_RANDOM_PASSWORD
INITIAL_ADMIN_EMAIL=admin@example.invalid INITIAL_ADMIN_EMAIL=admin@example.invalid
# In Produktion hinter HTTPS auf true setzen. # Produktion ausschließlich hinter HTTPS betreiben.
COOKIE_SECURE=false COOKIE_SECURE=true
+163 -14
View File
@@ -83,17 +83,21 @@ BADGE_DEFINITIONS = (
("admin", "Admin", "🏴‍☠️", None, "Verantwortung für MetalCircle", "special"), ("admin", "Admin", "🏴‍☠️", None, "Verantwortung für MetalCircle", "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 am selben Veranstaltungsort besucht", "attendance"), ("regular", "Stammgast", "🤘", 5, "5 Konzerte am selben Veranstaltungsort besucht", "venue"),
("ten_gigs", "Stammgast · Level 10", "🔥", 10, "10 Konzerte am selben Veranstaltungsort besucht", "attendance"), ("ten_gigs", "10 Gigs", "🔥", 10, "10 besuchte Konzerte", "attendance"),
("tour_veteran", "Stammgast · Level 25", "", 25, "25 Konzerte am selben Veranstaltungsort besucht", "attendance"), ("tour_veteran", "25 Gigs", "", 25, "25 besuchte Konzerte", "attendance"),
("fifty_gigs", "Stammgast · Level 50", "💀", 50, "50 Konzerte am selben Veranstaltungsort besucht", "attendance"), ("fifty_gigs", "50 Gigs", "💀", 50, "50 besuchte Konzerte", "attendance"),
("hundred_gigs", "Stammgast · Level 100", "👑", 100, "100 Konzerte am selben Veranstaltungsort besucht", "attendance"), ("hundred_gigs", "100 Gigs", "👑", 100, "100 besuchte Konzerte", "attendance"),
) )
ATTENDANCE_BADGE_CODES = tuple( ATTENDANCE_BADGE_CODES = tuple(
badge_code badge_code
for badge_code, _name, _icon, _threshold, _description, category in BADGE_DEFINITIONS for badge_code, _name, _icon, _threshold, _description, category in BADGE_DEFINITIONS
if category == "attendance" if category == "attendance"
) )
VENUE_BADGE_CODES = tuple(
badge_code for badge_code, _name, _icon, _threshold, _description, category in BADGE_DEFINITIONS
if category == "venue"
)
BETA_REGISTRATION_DEADLINE = datetime(2026, 9, 16) BETA_REGISTRATION_DEADLINE = datetime(2026, 9, 16)
BADGE_BY_CODE = { BADGE_BY_CODE = {
badge_code: (name, icon, threshold, description, category) badge_code: (name, icon, threshold, description, category)
@@ -344,6 +348,19 @@ def ensure_schema():
ON friendships (LEAST(requester_id, addressee_id), GREATEST(requester_id, addressee_id)) ON friendships (LEAST(requester_id, addressee_id), GREATEST(requester_id, addressee_id))
""", """,
""" """
CREATE TABLE IF NOT EXISTS user_blocks (
blocker_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
blocked_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (blocker_id, blocked_id),
CHECK (blocker_id <> blocked_id)
)
""",
"""
CREATE INDEX IF NOT EXISTS idx_user_blocks_blocked
ON user_blocks (blocked_id, blocker_id)
""",
"""
CREATE TABLE IF NOT EXISTS direct_messages ( CREATE TABLE IF NOT EXISTS direct_messages (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
sender_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, sender_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -412,13 +429,22 @@ app = FastAPI(title="MetalCircle", lifespan=lifespan)
@app.middleware("http") @app.middleware("http")
async def security_controls(request: Request, call_next): async def security_controls(request: Request, call_next):
if COOKIE_SECURE and request.url.path not in {"/impressum", "/datenschutz"}:
forwarded_proto = request.headers.get("x-forwarded-proto", request.url.scheme).split(",", 1)[0].strip()
if forwarded_proto != "https":
target = str(request.url).replace("http://", "https://", 1)
return RedirectResponse(target, status_code=308)
if request.method in {"POST", "PUT", "PATCH", "DELETE"}: if request.method in {"POST", "PUT", "PATCH", "DELETE"}:
origin = request.headers.get("origin") origin = request.headers.get("origin")
fetch_site = request.headers.get("sec-fetch-site") fetch_site = request.headers.get("sec-fetch-site")
origin_host = urlparse(origin).netloc if origin else None origin_host = urlparse(origin).netloc if origin else None
expected_host = request.headers.get("host", request.url.netloc) expected_host = request.headers.get("host", request.url.netloc)
if fetch_site == "cross-site" or (origin_host and origin_host != expected_host): referer = request.headers.get("referer")
referer_host = urlparse(referer).netloc if referer else None
if fetch_site == "cross-site" or (origin_host and origin_host != expected_host) or (referer_host and referer_host != expected_host):
return HTMLResponse("Anfrage aus fremder Quelle abgelehnt.", status_code=403) return HTMLResponse("Anfrage aus fremder Quelle abgelehnt.", status_code=403)
if request.url.path not in {"/login", "/register"} and request.url.path.startswith("/password-reset") is False and not origin and not referer:
return HTMLResponse("CSRF-Prüfung fehlgeschlagen.", status_code=403)
path = request.url.path path = request.url.path
if path == "/login": if path == "/login":
@@ -1133,11 +1159,10 @@ def attended_concert_stats(user_id: int) -> tuple[int, int]:
def grant_earned_badges(user_id: int, attended_count: int, max_same_venue, registered_at): def grant_earned_badges(user_id: int, attended_count: int, max_same_venue, registered_at):
highest_attendance_badge = None highest_attendance_badge = None
venue_badge = "regular" if max_same_venue >= 5 else None
for badge_code, _name, _icon, threshold, _description, category in BADGE_DEFINITIONS: for badge_code, _name, _icon, threshold, _description, category in BADGE_DEFINITIONS:
qualifies = category == "attendance" and attended_count >= threshold qualifies = category == "attendance" and threshold is not None and attended_count >= threshold
if category == "attendance" and threshold >= 5:
qualifies = max_same_venue >= threshold
if qualifies: if qualifies:
highest_attendance_badge = badge_code highest_attendance_badge = badge_code
@@ -1157,7 +1182,7 @@ def grant_earned_badges(user_id: int, attended_count: int, max_same_venue, regis
# erreichte Stufe anzeigen (ältere Stufen werden ersetzt). # 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 + VENUE_BADGE_CODES)),
) )
if highest_attendance_badge: if highest_attendance_badge:
cursor.execute( cursor.execute(
@@ -1168,6 +1193,11 @@ def grant_earned_badges(user_id: int, attended_count: int, max_same_venue, regis
""", """,
(user_id, highest_attendance_badge), (user_id, highest_attendance_badge),
) )
if venue_badge:
cursor.execute(
"INSERT INTO user_badges (user_id, badge_code) VALUES (%s, %s) ON CONFLICT DO NOTHING",
(user_id, venue_badge),
)
connection.commit() connection.commit()
@@ -2221,6 +2251,7 @@ def render_profile(
is_own_profile = bool(viewer and viewer["id"] == profile["id"]) is_own_profile = bool(viewer and viewer["id"] == profile["id"])
friendship = None friendship = None
block_status = {"blocked_by_viewer": False, "blocked_viewer": False}
if viewer and not is_own_profile: if viewer and not is_own_profile:
with get_db_connection() as connection: with get_db_connection() as connection:
with connection.cursor() as cursor: with connection.cursor() as cursor:
@@ -2239,13 +2270,26 @@ def render_profile(
"id": row[0], "requester_id": row[1], "id": row[0], "requester_id": row[1],
"addressee_id": row[2], "status": row[3], "addressee_id": row[2], "status": row[3],
} }
cursor.execute(
"""
SELECT blocker_id, blocked_id FROM user_blocks
WHERE (blocker_id = %s AND blocked_id = %s)
OR (blocker_id = %s AND blocked_id = %s)
""",
(viewer["id"], profile["id"], profile["id"], viewer["id"]),
)
for blocker_id, _blocked_id in cursor.fetchall():
if blocker_id == viewer["id"]:
block_status["blocked_by_viewer"] = True
else:
block_status["blocked_viewer"] = True
can_view_details = ( can_view_details = (
profile["profile_visibility"] == "public" profile["profile_visibility"] == "public"
or is_own_profile or is_own_profile
or bool(viewer and viewer["is_admin"]) or bool(viewer and viewer["is_admin"])
or bool(friendship and friendship["status"] == "accepted") or bool(friendship and friendship["status"] == "accepted")
) )
connections = {"incoming": [], "outgoing": [], "friends": []} connections = {"incoming": [], "outgoing": [], "friends": [], "blocked": []}
if is_own_profile: if is_own_profile:
with get_db_connection() as connection: with get_db_connection() as connection:
with connection.cursor() as cursor: with connection.cursor() as cursor:
@@ -2276,6 +2320,20 @@ def render_profile(
connections["incoming"].append(item) connections["incoming"].append(item)
else: else:
connections["outgoing"].append(item) connections["outgoing"].append(item)
cursor.execute(
"""
SELECT u.username, COALESCE(u.display_name, u.username), u.avatar_path
FROM user_blocks b
JOIN users u ON u.id = b.blocked_id
WHERE b.blocker_id = %s
ORDER BY COALESCE(u.display_name, u.username), u.username
""",
(viewer["id"],),
)
connections["blocked"] = [
{"username": row[0], "display_name": row[1], "avatar_path": row[2]}
for row in cursor.fetchall()
]
attended_count, max_same_venue = attended_concert_stats(profile["id"]) attended_count, max_same_venue = attended_concert_stats(profile["id"])
grant_earned_badges( grant_earned_badges(
profile["id"], profile["id"],
@@ -2311,6 +2369,7 @@ def render_profile(
is_own_profile=force_own or is_own_profile, is_own_profile=force_own or is_own_profile,
can_view_details=can_view_details, can_view_details=can_view_details,
friendship=friendship, friendship=friendship,
block_status=block_status,
connections=connections, connections=connections,
form_error=form_error, form_error=form_error,
form_success=form_success, form_success=form_success,
@@ -2389,6 +2448,16 @@ def export_profile_data(request: Request):
) )
friendships = cursor.fetchall() friendships = cursor.fetchall()
cursor.execute(
"""
SELECT blocker_id, blocked_id, created_at
FROM user_blocks WHERE blocker_id = %s OR blocked_id = %s
ORDER BY created_at
""",
(user_id, user_id),
)
blocks = cursor.fetchall()
cursor.execute( cursor.execute(
""" """
SELECT id, sender_id, recipient_id, body, read_at, created_at SELECT id, sender_id, recipient_id, body, read_at, created_at
@@ -2454,6 +2523,7 @@ def export_profile_data(request: Request):
), ),
"attendance": rows_to_dicts(attendance, ("concert_id", "status", "updated_at")), "attendance": rows_to_dicts(attendance, ("concert_id", "status", "updated_at")),
"friendships": rows_to_dicts(friendships, ("id", "requester_id", "addressee_id", "status", "created_at", "updated_at")), "friendships": rows_to_dicts(friendships, ("id", "requester_id", "addressee_id", "status", "created_at", "updated_at")),
"blocks": rows_to_dicts(blocks, ("blocker_id", "blocked_id", "created_at")),
"messages": rows_to_dicts(messages, ("id", "sender_id", "recipient_id", "body", "read_at", "created_at")), "messages": rows_to_dicts(messages, ("id", "sender_id", "recipient_id", "body", "read_at", "created_at")),
"event_invitations": rows_to_dicts(invitations, ("concert_id", "invited_by", "viewed_at", "created_at")), "event_invitations": rows_to_dicts(invitations, ("concert_id", "invited_by", "viewed_at", "created_at")),
"comments": rows_to_dicts(comments, ("id", "concert_id", "body", "created_at")), "comments": rows_to_dicts(comments, ("id", "concert_id", "body", "created_at")),
@@ -2476,9 +2546,18 @@ def delete_own_account(request: Request):
user_id = user["id"] user_id = user["id"]
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,))
avatar_path = cursor.fetchone()[0]
cursor.execute("SELECT path FROM concert_photos WHERE user_id = %s", (user_id,))
photo_paths = [row[0] for row in cursor.fetchall()]
cursor.execute("SELECT flyer_path FROM concerts WHERE created_by = %s AND flyer_path IS NOT NULL", (user_id,))
flyer_paths = [row[0] for row in cursor.fetchall()]
cursor.execute("UPDATE registration_invites SET used_by = NULL WHERE used_by = %s", (user_id,)) cursor.execute("UPDATE registration_invites SET used_by = NULL WHERE used_by = %s", (user_id,))
cursor.execute("UPDATE concerts SET flyer_path = NULL WHERE created_by = %s", (user_id,))
cursor.execute("DELETE FROM users WHERE id = %s", (user_id,)) cursor.execute("DELETE FROM users WHERE id = %s", (user_id,))
connection.commit() connection.commit()
for path, directory, prefix in [(avatar_path, AVATAR_DIR, "/static/uploads/avatars/")] + [(p, PHOTO_DIR, "/static/uploads/photos/") for p in photo_paths] + [(p, UPLOAD_DIR, "/static/uploads/flyers/") for p in flyer_paths]:
remove_uploaded_file(path, directory, prefix)
response = RedirectResponse("/login", status_code=303) response = RedirectResponse("/login", status_code=303)
response.delete_cookie(SESSION_COOKIE, path="/", secure=COOKIE_SECURE, samesite="lax") response.delete_cookie(SESSION_COOKIE, path="/", secure=COOKIE_SECURE, samesite="lax")
return response return response
@@ -2566,6 +2645,16 @@ def send_friend_request(request: Request, username: str):
return HTMLResponse("Du kannst dir nicht selbst eine Anfrage schicken.", status_code=400) return HTMLResponse("Du kannst dir nicht selbst eine Anfrage schicken.", status_code=400)
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 1 FROM user_blocks
WHERE (blocker_id = %s AND blocked_id = %s)
OR (blocker_id = %s AND blocked_id = %s)
""",
(user["id"], profile["id"], profile["id"], user["id"]),
)
if cursor.fetchone():
return HTMLResponse("Freundschaftsanfrage wegen einer Blockierung nicht möglich.", status_code=403)
cursor.execute( cursor.execute(
""" """
INSERT INTO friendships (requester_id, addressee_id) INSERT INTO friendships (requester_id, addressee_id)
@@ -2578,6 +2667,51 @@ def send_friend_request(request: Request, username: str):
return RedirectResponse(f"/users/{profile['username']}", status_code=303) return RedirectResponse(f"/users/{profile['username']}", status_code=303)
@app.post("/users/{username}/block")
def block_user(request: Request, username: str):
user = get_current_user(request)
profile = load_profile(username)
if not profile:
return HTMLResponse("Benutzer nicht gefunden.", status_code=404)
if profile["id"] == user["id"]:
return HTMLResponse("Du kannst dich nicht selbst blockieren.", status_code=400)
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"""
INSERT INTO user_blocks (blocker_id, blocked_id)
VALUES (%s, %s) ON CONFLICT DO NOTHING
""",
(user["id"], profile["id"]),
)
cursor.execute(
"""
DELETE FROM friendships
WHERE (requester_id = %s AND addressee_id = %s)
OR (requester_id = %s AND addressee_id = %s)
""",
(user["id"], profile["id"], profile["id"], user["id"]),
)
connection.commit()
return RedirectResponse(f"/users/{profile['username']}", status_code=303)
@app.post("/users/{username}/unblock")
def unblock_user(request: Request, username: str):
user = get_current_user(request)
profile = load_profile(username)
if not profile:
return HTMLResponse("Benutzer nicht gefunden.", status_code=404)
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"DELETE FROM user_blocks WHERE blocker_id = %s AND blocked_id = %s",
(user["id"], profile["id"]),
)
connection.commit()
return RedirectResponse(f"/users/{profile['username']}", status_code=303)
@app.post("/friendships/{friendship_id}/{action}") @app.post("/friendships/{friendship_id}/{action}")
def manage_friendship(request: Request, friendship_id: int, action: str, return_to: str = Form("")): def manage_friendship(request: Request, friendship_id: int, action: str, return_to: str = Form("")):
user = get_current_user(request) user = get_current_user(request)
@@ -2626,6 +2760,11 @@ def load_chat_partner(cursor, user, username: str):
FROM users u FROM users u
WHERE LOWER(u.username) = LOWER(%s) WHERE LOWER(u.username) = LOWER(%s)
AND u.id <> %s AND u.id <> %s
AND NOT EXISTS (
SELECT 1 FROM user_blocks b
WHERE (b.blocker_id = %s AND b.blocked_id = u.id)
OR (b.blocker_id = u.id AND b.blocked_id = %s)
)
AND (%s OR u.is_admin OR EXISTS ( AND (%s OR u.is_admin OR EXISTS (
SELECT 1 FROM friendships f SELECT 1 FROM friendships f
WHERE f.status = 'accepted' WHERE f.status = 'accepted'
@@ -2633,7 +2772,7 @@ def load_chat_partner(cursor, user, username: str):
OR (f.addressee_id = %s AND f.requester_id = u.id)) OR (f.addressee_id = %s AND f.requester_id = u.id))
)) ))
""", """,
(username, user["id"], user["is_admin"], user["id"], user["id"]), (username, user["id"], user["id"], user["id"], user["is_admin"], user["id"], user["id"]),
) )
row = cursor.fetchone() row = cursor.fetchone()
if not row: if not row:
@@ -2686,6 +2825,11 @@ def message_inbox(request: Request):
SELECT u.id, u.username, COALESCE(u.display_name, u.username), u.avatar_path SELECT u.id, u.username, COALESCE(u.display_name, u.username), u.avatar_path
FROM users u FROM users u
WHERE u.id <> %s WHERE u.id <> %s
AND NOT EXISTS (
SELECT 1 FROM user_blocks b
WHERE (b.blocker_id = %s AND b.blocked_id = u.id)
OR (b.blocker_id = u.id AND b.blocked_id = %s)
)
AND (%s OR u.is_admin OR EXISTS ( AND (%s OR u.is_admin OR EXISTS (
SELECT 1 FROM friendships f SELECT 1 FROM friendships f
WHERE f.status = 'accepted' WHERE f.status = 'accepted'
@@ -2694,7 +2838,7 @@ def message_inbox(request: Request):
)) ))
ORDER BY COALESCE(u.display_name, u.username) ORDER BY COALESCE(u.display_name, u.username)
""", """,
(user["id"], user["is_admin"], user["id"], user["id"]), (user["id"], user["id"], user["id"], user["is_admin"], user["id"], user["id"]),
) )
for row in cursor.fetchall(): for row in cursor.fetchall():
cursor.execute( cursor.execute(
@@ -2906,9 +3050,14 @@ def concert_detail(request: Request, concert_id: int):
FROM concert_attendance FROM concert_attendance
JOIN users ON users.id = concert_attendance.user_id JOIN users ON users.id = concert_attendance.user_id
WHERE concert_attendance.concert_id = %s WHERE concert_attendance.concert_id = %s
AND NOT EXISTS (
SELECT 1 FROM user_blocks b
WHERE (b.blocker_id = %s AND b.blocked_id = concert_attendance.user_id)
OR (b.blocker_id = concert_attendance.user_id AND b.blocked_id = %s)
)
ORDER BY users.display_name NULLS LAST, users.username ORDER BY users.display_name NULLS LAST, users.username
""", """,
(concert_id,), (concert_id, user["id"], user["id"]),
) )
attendance_rows = cursor.fetchall() attendance_rows = cursor.fetchall()
+4 -2
View File
@@ -43,6 +43,7 @@ header {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 1000; z-index: 1000;
isolation: isolate;
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
} }
@@ -79,12 +80,13 @@ header {
color: var(--accent); color: var(--accent);
} }
.user-menu { position: relative; margin-left: auto; } .user-menu { position: relative; z-index: 2000; margin-left: auto; }
.header-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; min-width: 0; } .header-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; min-width: 0; }
.user-menu summary { padding: 9px 12px; border: 1px solid var(--border); border-radius: 9px; cursor: pointer; list-style: none; font-weight: 700; } .user-menu summary { padding: 9px 12px; border: 1px solid var(--border); border-radius: 9px; cursor: pointer; list-style: none; font-weight: 700; }
.user-menu summary::-webkit-details-marker { display: none; } .user-menu summary::-webkit-details-marker { display: none; }
.user-menu[open] { z-index: 3000; }
.user-menu[open] summary { border-color: var(--accent); background: var(--surface-hover); } .user-menu[open] summary { border-color: var(--accent); background: var(--surface-hover); }
.user-menu-panel { position: absolute; top: calc(100% + 7px); right: 0; z-index: 1100; min-width: 170px; padding: 7px; background: #090909; border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 14px 35px rgba(0,0,0,.6); } .user-menu-panel { position: fixed; top: 72px; right: max(15px, calc((100vw - 1100px) / 2 + 20px)); z-index: 10000; min-width: 190px; padding: 7px; background: #090909; border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 14px 35px rgba(0,0,0,.75); }
.user-menu-panel a, .user-menu-panel button { display: block; width: 100%; padding: 10px 11px; color: var(--text); background: transparent; border: 0; border-radius: 7px; text-align: left; font: inherit; cursor: pointer; } .user-menu-panel a, .user-menu-panel button { display: block; width: 100%; padding: 10px 11px; color: var(--text); background: transparent; border: 0; border-radius: 7px; text-align: left; font: inherit; cursor: pointer; }
.user-menu-panel a:hover, .user-menu-panel button:hover { background: var(--surface-hover); color: #f87171; } .user-menu-panel a:hover, .user-menu-panel button:hover { background: var(--surface-hover); color: #f87171; }
.user-menu-panel form { max-width: none; margin: 0; } .user-menu-panel form { max-width: none; margin: 0; }
+1 -1
View File
@@ -275,8 +275,8 @@
<label>Land<input type="search" name="country" value="{{ filters.country }}" placeholder="z. B. Deutschland"></label> <label>Land<input type="search" name="country" value="{{ filters.country }}" placeholder="z. B. Deutschland"></label>
<label>Kategorie<select name="category"><option value="">Alle Kategorien</option><option value="concert" {% if filters.category == 'concert' %}selected{% endif %}>Konzert</option><option value="festival" {% if filters.category == 'festival' %}selected{% endif %}>Festival</option><option value="other" {% if filters.category == 'other' %}selected{% endif %}>Sonstiges</option></select></label> <label>Kategorie<select name="category"><option value="">Alle Kategorien</option><option value="concert" {% if filters.category == 'concert' %}selected{% endif %}>Konzert</option><option value="festival" {% if filters.category == 'festival' %}selected{% endif %}>Festival</option><option value="other" {% if filters.category == 'other' %}selected{% endif %}>Sonstiges</option></select></label>
</div> </div>
</details>
{% if filters.date_from or filters.date_to or filters.venue or filters.country or filters.category %}<a class="clear-filters" href="{% if archive %}/events/past{% else %}/{% endif %}{% if search_query %}?q={{ search_query|urlencode }}{% endif %}">Filter löschen</a>{% endif %} {% if filters.date_from or filters.date_to or filters.venue or filters.country or filters.category %}<a class="clear-filters" href="{% if archive %}/events/past{% else %}/{% endif %}{% if search_query %}?q={{ search_query|urlencode }}{% endif %}">Filter löschen</a>{% endif %}
</details>
</form> </form>
</section> </section>
+35 -1
View File
@@ -52,6 +52,10 @@
.visibility-options label { display: flex; align-items: flex-start; gap: 9px; margin: 0; } .visibility-options label { display: flex; align-items: flex-start; gap: 9px; margin: 0; }
.visibility-options input { width: auto; margin: 4px 0 0; } .visibility-options input { width: auto; margin: 4px 0 0; }
.connections { margin-top: 24px; padding: 24px; background: var(--surface); border: 1px solid var(--border); border-radius: 16px; } .connections { margin-top: 24px; padding: 24px; background: var(--surface); border: 1px solid var(--border); border-radius: 16px; }
.connections-details > summary { color:#fca5a5; cursor:pointer; font-size:1.35rem; font-weight:700; }
.connections-content { margin-top:18px; }
.blocked-users { margin-top:18px; padding-top:12px; border-top:1px solid var(--border); }
.blocked-users > summary { width:fit-content; color:var(--muted); cursor:pointer; font-size:.78rem; text-decoration:underline; }
.connection-list { display: grid; gap: 9px; } .connection-list { display: grid; gap: 9px; }
.connection-row { display: flex; align-items: center; gap: 11px; padding: 11px; background: #090909; border: 1px solid var(--border); border-radius: 10px; } .connection-row { display: flex; align-items: center; gap: 11px; padding: 11px; background: #090909; border: 1px solid var(--border); border-radius: 10px; }
.connection-row > a { display: flex; min-width: 0; flex: 1; align-items: center; gap: 10px; } .connection-row > a { display: flex; min-width: 0; flex: 1; align-items: center; gap: 10px; }
@@ -90,6 +94,7 @@
<div class="stat"><strong>{{ attended_count }}</strong>besuchte Konzerte</div> <div class="stat"><strong>{{ attended_count }}</strong>besuchte Konzerte</div>
{% if not is_own_profile %} {% if not is_own_profile %}
<div class="friend-actions"> <div class="friend-actions">
{% if not block_status.blocked_by_viewer and not block_status.blocked_viewer %}
{% if (user.is_admin or profile.is_admin) and (not friendship or friendship.status != 'accepted') %} {% if (user.is_admin or profile.is_admin) and (not friendship or friendship.status != 'accepted') %}
<a class="button" href="/messages/{{ profile.username }}">Nachricht senden</a> <a class="button" href="/messages/{{ profile.username }}">Nachricht senden</a>
{% endif %} {% endif %}
@@ -104,7 +109,14 @@
{% else %} {% else %}
<form method="post" action="/friendships/{{ friendship.id }}/remove"><button class="button button-secondary" type="submit">Anfrage zurückziehen</button></form> <form method="post" action="/friendships/{{ friendship.id }}/remove"><button class="button button-secondary" type="submit">Anfrage zurückziehen</button></form>
{% endif %} {% endif %}
{% endif %}
{% if block_status.blocked_by_viewer %}
<form method="post" action="/users/{{ profile.username }}/unblock"><button class="button button-secondary" type="submit">Blockierung aufheben</button></form>
{% else %}
<form method="post" action="/users/{{ profile.username }}/block" onsubmit="return confirm('Diesen Nutzer wirklich blockieren?');"><button class="button button-secondary" type="submit">Nutzer blockieren</button></form>
{% endif %}
</div> </div>
{% if block_status.blocked_viewer and not block_status.blocked_by_viewer %}<p class="private-note">Interaktionen mit diesem Nutzer sind nicht verfügbar.</p>{% endif %}
{% if not can_view_details %}<p class="private-note">Weitere Profildetails sind nur für Freunde sichtbar.</p>{% endif %} {% if not can_view_details %}<p class="private-note">Weitere Profildetails sind nur für Freunde sichtbar.</p>{% endif %}
{% endif %} {% endif %}
</section> </section>
@@ -134,7 +146,9 @@
</div> </div>
{% if is_own_profile %} {% if is_own_profile %}
<section class="connections"> <section class="connections">
<h2>🤝 Freunde</h2> <details class="connections-details" {% if connections.incoming %}open{% endif %}>
<summary>🤝 Freunde{% if connections.friends %} · {{ connections.friends|length }}{% endif %}</summary>
<div class="connections-content">
{% if connections.incoming %} {% if connections.incoming %}
<h3>Offene Anfragen</h3> <h3>Offene Anfragen</h3>
<div class="connection-list"> <div class="connection-list">
@@ -168,6 +182,26 @@
{% endif %} {% endif %}
{% if connections.outgoing %}<p class="friend-note">{{ connections.outgoing|length }} gesendete Anfrage(n) warten noch auf Antwort.</p>{% endif %} {% if connections.outgoing %}<p class="friend-note">{{ connections.outgoing|length }} gesendete Anfrage(n) warten noch auf Antwort.</p>{% endif %}
{% if not connections.incoming and not connections.friends and not connections.outgoing %}<p class="friend-note">Noch keine Verbindungen. Finde andere User über die Suche auf der Startseite.</p>{% endif %} {% if not connections.incoming and not connections.friends and not connections.outgoing %}<p class="friend-note">Noch keine Verbindungen. Finde andere User über die Suche auf der Startseite.</p>{% endif %}
<details class="blocked-users">
<summary>Blockierte Nutzer{% if connections.blocked %} ({{ connections.blocked|length }}){% endif %}</summary>
{% if connections.blocked %}
<div class="connection-list">
{% for connection in connections.blocked %}
<div class="connection-row">
<a href="/users/{{ connection.username }}">
{% if connection.avatar_path %}<img class="connection-avatar" src="{{ connection.avatar_path }}" alt="">{% else %}<span class="connection-avatar">{{ connection.display_name[:1] }}</span>{% endif %}
<strong>{{ connection.display_name }} <small>@{{ connection.username }}</small></strong>
</a>
<div class="connection-actions"><form method="post" action="/users/{{ connection.username }}/unblock"><button class="button button-secondary" type="submit">Freigeben</button></form></div>
</div>
{% endfor %}
</div>
{% else %}
<p class="friend-note">Keine blockierten Nutzer.</p>
{% endif %}
</details>
</div>
</details>
</section> </section>
{% endif %} {% endif %}
{% if is_own_profile %} {% if is_own_profile %}
+1 -1
View File
@@ -28,7 +28,7 @@ services:
INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME} INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME}
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD} INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD}
INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL} INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL}
COOKIE_SECURE: ${COOKIE_SECURE:-false} COOKIE_SECURE: ${COOKIE_SECURE:-true}
volumes: volumes:
- concert_uploads:/app/static/uploads - concert_uploads:/app/static/uploads
+11
View File
@@ -40,6 +40,17 @@ CREATE TABLE friendships (
CREATE UNIQUE INDEX idx_friendships_pair CREATE UNIQUE INDEX idx_friendships_pair
ON friendships (LEAST(requester_id, addressee_id), GREATEST(requester_id, addressee_id)); ON friendships (LEAST(requester_id, addressee_id), GREATEST(requester_id, addressee_id));
CREATE TABLE user_blocks (
blocker_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
blocked_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (blocker_id, blocked_id),
CHECK (blocker_id <> blocked_id)
);
CREATE INDEX idx_user_blocks_blocked
ON user_blocks (blocked_id, blocker_id);
CREATE TABLE direct_messages ( CREATE TABLE direct_messages (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
sender_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, sender_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+10
View File
@@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS user_blocks (
blocker_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
blocked_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (blocker_id, blocked_id),
CHECK (blocker_id <> blocked_id)
);
CREATE INDEX IF NOT EXISTS idx_user_blocks_blocked
ON user_blocks (blocked_id, blocker_id);