feat: expand MetalCircle app and privacy controls
This commit is contained in:
+2
-2
@@ -6,5 +6,5 @@ INITIAL_ADMIN_USERNAME=Kai
|
||||
INITIAL_ADMIN_PASSWORD=CHANGE_ME_TO_A_LONG_RANDOM_PASSWORD
|
||||
INITIAL_ADMIN_EMAIL=admin@example.invalid
|
||||
|
||||
# In Produktion hinter HTTPS auf true setzen.
|
||||
COOKIE_SECURE=false
|
||||
# Produktion ausschließlich hinter HTTPS betreiben.
|
||||
COOKIE_SECURE=true
|
||||
|
||||
+163
-14
@@ -83,17 +83,21 @@ BADGE_DEFINITIONS = (
|
||||
("admin", "Admin", "🏴☠️", None, "Verantwortung für MetalCircle", "special"),
|
||||
("beta_tester", "Beta Tester", "🧪", None, "In der Beta dabei", "beta"),
|
||||
("first_gig", "Erster Gig", "🎸", 1, "Dein erstes besuchtes Konzert", "attendance"),
|
||||
("regular", "Stammgast", "🤘", 5, "5 Konzerte am selben Veranstaltungsort besucht", "attendance"),
|
||||
("ten_gigs", "Stammgast · Level 10", "🔥", 10, "10 Konzerte am selben Veranstaltungsort besucht", "attendance"),
|
||||
("tour_veteran", "Stammgast · Level 25", "⚡", 25, "25 Konzerte am selben Veranstaltungsort besucht", "attendance"),
|
||||
("fifty_gigs", "Stammgast · Level 50", "💀", 50, "50 Konzerte am selben Veranstaltungsort besucht", "attendance"),
|
||||
("hundred_gigs", "Stammgast · Level 100", "👑", 100, "100 Konzerte am selben Veranstaltungsort besucht", "attendance"),
|
||||
("regular", "Stammgast", "🤘", 5, "5 Konzerte am selben Veranstaltungsort besucht", "venue"),
|
||||
("ten_gigs", "10 Gigs", "🔥", 10, "10 besuchte Konzerte", "attendance"),
|
||||
("tour_veteran", "25 Gigs", "⚡", 25, "25 besuchte Konzerte", "attendance"),
|
||||
("fifty_gigs", "50 Gigs", "💀", 50, "50 besuchte Konzerte", "attendance"),
|
||||
("hundred_gigs", "100 Gigs", "👑", 100, "100 besuchte Konzerte", "attendance"),
|
||||
)
|
||||
ATTENDANCE_BADGE_CODES = tuple(
|
||||
badge_code
|
||||
for badge_code, _name, _icon, _threshold, _description, category in BADGE_DEFINITIONS
|
||||
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)
|
||||
BADGE_BY_CODE = {
|
||||
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))
|
||||
""",
|
||||
"""
|
||||
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 (
|
||||
id SERIAL PRIMARY KEY,
|
||||
sender_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
@@ -412,13 +429,22 @@ app = FastAPI(title="MetalCircle", lifespan=lifespan)
|
||||
|
||||
@app.middleware("http")
|
||||
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"}:
|
||||
origin = request.headers.get("origin")
|
||||
fetch_site = request.headers.get("sec-fetch-site")
|
||||
origin_host = urlparse(origin).netloc if origin else None
|
||||
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)
|
||||
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
|
||||
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):
|
||||
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:
|
||||
qualifies = category == "attendance" and attended_count >= threshold
|
||||
if category == "attendance" and threshold >= 5:
|
||||
qualifies = max_same_venue >= threshold
|
||||
qualifies = category == "attendance" and threshold is not None and attended_count >= threshold
|
||||
if qualifies:
|
||||
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).
|
||||
cursor.execute(
|
||||
"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:
|
||||
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),
|
||||
)
|
||||
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()
|
||||
|
||||
|
||||
@@ -2221,6 +2251,7 @@ def render_profile(
|
||||
|
||||
is_own_profile = bool(viewer and viewer["id"] == profile["id"])
|
||||
friendship = None
|
||||
block_status = {"blocked_by_viewer": False, "blocked_viewer": False}
|
||||
if viewer and not is_own_profile:
|
||||
with get_db_connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
@@ -2239,13 +2270,26 @@ def render_profile(
|
||||
"id": row[0], "requester_id": row[1],
|
||||
"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 = (
|
||||
profile["profile_visibility"] == "public"
|
||||
or is_own_profile
|
||||
or bool(viewer and viewer["is_admin"])
|
||||
or bool(friendship and friendship["status"] == "accepted")
|
||||
)
|
||||
connections = {"incoming": [], "outgoing": [], "friends": []}
|
||||
connections = {"incoming": [], "outgoing": [], "friends": [], "blocked": []}
|
||||
if is_own_profile:
|
||||
with get_db_connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
@@ -2276,6 +2320,20 @@ def render_profile(
|
||||
connections["incoming"].append(item)
|
||||
else:
|
||||
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"])
|
||||
grant_earned_badges(
|
||||
profile["id"],
|
||||
@@ -2311,6 +2369,7 @@ def render_profile(
|
||||
is_own_profile=force_own or is_own_profile,
|
||||
can_view_details=can_view_details,
|
||||
friendship=friendship,
|
||||
block_status=block_status,
|
||||
connections=connections,
|
||||
form_error=form_error,
|
||||
form_success=form_success,
|
||||
@@ -2389,6 +2448,16 @@ def export_profile_data(request: Request):
|
||||
)
|
||||
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(
|
||||
"""
|
||||
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")),
|
||||
"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")),
|
||||
"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")),
|
||||
@@ -2476,9 +2546,18 @@ def delete_own_account(request: Request):
|
||||
user_id = user["id"]
|
||||
with get_db_connection() as connection:
|
||||
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 concerts SET flyer_path = NULL WHERE created_by = %s", (user_id,))
|
||||
cursor.execute("DELETE FROM users WHERE id = %s", (user_id,))
|
||||
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.delete_cookie(SESSION_COOKIE, path="/", secure=COOKIE_SECURE, samesite="lax")
|
||||
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)
|
||||
with get_db_connection() as connection:
|
||||
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(
|
||||
"""
|
||||
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)
|
||||
|
||||
|
||||
@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}")
|
||||
def manage_friendship(request: Request, friendship_id: int, action: str, return_to: str = Form("")):
|
||||
user = get_current_user(request)
|
||||
@@ -2626,6 +2760,11 @@ def load_chat_partner(cursor, user, username: str):
|
||||
FROM users u
|
||||
WHERE LOWER(u.username) = LOWER(%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 (
|
||||
SELECT 1 FROM friendships f
|
||||
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))
|
||||
))
|
||||
""",
|
||||
(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()
|
||||
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
|
||||
FROM users u
|
||||
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 (
|
||||
SELECT 1 FROM friendships f
|
||||
WHERE f.status = 'accepted'
|
||||
@@ -2694,7 +2838,7 @@ def message_inbox(request: Request):
|
||||
))
|
||||
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():
|
||||
cursor.execute(
|
||||
@@ -2906,9 +3050,14 @@ def concert_detail(request: Request, concert_id: int):
|
||||
FROM concert_attendance
|
||||
JOIN users ON users.id = concert_attendance.user_id
|
||||
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
|
||||
""",
|
||||
(concert_id,),
|
||||
(concert_id, user["id"], user["id"]),
|
||||
)
|
||||
attendance_rows = cursor.fetchall()
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
isolation: isolate;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
@@ -79,12 +80,13 @@ header {
|
||||
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; }
|
||||
.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[open] { z-index: 3000; }
|
||||
.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:hover, .user-menu-panel button:hover { background: var(--surface-hover); color: #f87171; }
|
||||
.user-menu-panel form { max-width: none; margin: 0; }
|
||||
|
||||
@@ -275,8 +275,8 @@
|
||||
<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>
|
||||
</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 %}
|
||||
</details>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -52,6 +52,10 @@
|
||||
.visibility-options label { display: flex; align-items: flex-start; gap: 9px; margin: 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-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-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; }
|
||||
@@ -90,6 +94,7 @@
|
||||
<div class="stat"><strong>{{ attended_count }}</strong>besuchte Konzerte</div>
|
||||
{% if not is_own_profile %}
|
||||
<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') %}
|
||||
<a class="button" href="/messages/{{ profile.username }}">Nachricht senden</a>
|
||||
{% endif %}
|
||||
@@ -104,7 +109,14 @@
|
||||
{% else %}
|
||||
<form method="post" action="/friendships/{{ friendship.id }}/remove"><button class="button button-secondary" type="submit">Anfrage zurückziehen</button></form>
|
||||
{% 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>
|
||||
{% 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 %}
|
||||
{% endif %}
|
||||
</section>
|
||||
@@ -134,7 +146,9 @@
|
||||
</div>
|
||||
{% if is_own_profile %}
|
||||
<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 %}
|
||||
<h3>Offene Anfragen</h3>
|
||||
<div class="connection-list">
|
||||
@@ -168,6 +182,26 @@
|
||||
{% 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 %}
|
||||
<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>
|
||||
{% endif %}
|
||||
{% if is_own_profile %}
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ services:
|
||||
INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME}
|
||||
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD}
|
||||
INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL}
|
||||
COOKIE_SECURE: ${COOKIE_SECURE:-false}
|
||||
COOKIE_SECURE: ${COOKIE_SECURE:-true}
|
||||
|
||||
volumes:
|
||||
- concert_uploads:/app/static/uploads
|
||||
|
||||
@@ -40,6 +40,17 @@ CREATE TABLE friendships (
|
||||
CREATE UNIQUE INDEX idx_friendships_pair
|
||||
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 (
|
||||
id SERIAL PRIMARY KEY,
|
||||
sender_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user