Add private event invitations

This commit is contained in:
kai
2026-08-26 18:24:22 +02:00
parent a9741b6859
commit c4fa6f077c
9 changed files with 268 additions and 40 deletions
+191 -37
View File
@@ -78,6 +78,7 @@ 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"),
("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"),
@@ -299,6 +300,24 @@ def ensure_schema():
ALTER TABLE concerts ADD COLUMN IF NOT EXISTS flyer_url TEXT ALTER TABLE concerts ADD COLUMN IF NOT EXISTS flyer_url TEXT
""", """,
""" """
ALTER TABLE concerts ADD COLUMN IF NOT EXISTS visibility VARCHAR(20) NOT NULL DEFAULT 'public'
CHECK (visibility IN ('public', 'friends', 'private'))
""",
"""
CREATE TABLE IF NOT EXISTS event_invitations (
concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
invited_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
viewed_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (concert_id, user_id)
)
""",
"""
CREATE INDEX IF NOT EXISTS idx_event_invitations_user
ON event_invitations (user_id, viewed_at)
""",
"""
CREATE TABLE IF NOT EXISTS friendships ( CREATE TABLE IF NOT EXISTS friendships (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
requester_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, requester_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -511,7 +530,8 @@ def user_from_row(row):
"is_admin": bool(row[3]), "is_admin": bool(row[3]),
"pending_friend_count": row[4], "pending_friend_count": row[4],
"unread_message_count": row[5], "unread_message_count": row[5],
"notification_count": row[4] + row[5], "event_invitation_count": row[6],
"notification_count": row[4] + row[5] + row[6],
} }
@@ -533,7 +553,9 @@ def get_current_user(request: Request):
(SELECT COUNT(*) FROM friendships (SELECT COUNT(*) FROM friendships
WHERE addressee_id = users.id AND status = 'pending'), WHERE addressee_id = users.id AND status = 'pending'),
(SELECT COUNT(*) FROM direct_messages (SELECT COUNT(*) FROM direct_messages
WHERE recipient_id = users.id AND read_at IS NULL) WHERE recipient_id = users.id AND read_at IS NULL),
(SELECT COUNT(*) FROM event_invitations
WHERE user_id = users.id AND viewed_at IS NULL)
FROM sessions FROM sessions
JOIN users JOIN users
ON users.id = sessions.user_id ON users.id = sessions.user_id
@@ -700,7 +722,8 @@ def load_concert(concert_id: int):
concerts.parent_event_id, concerts.parent_event_id,
parent_event.artist, parent_event.artist,
parent_event.event_type, parent_event.event_type,
concerts.flyer_url concerts.flyer_url,
concerts.visibility
FROM concerts FROM concerts
LEFT JOIN venues LEFT JOIN venues
ON concerts.venue_id = venues.id ON concerts.venue_id = venues.id
@@ -729,6 +752,7 @@ def load_concert(concert_id: int):
"ticket_price": row[6], "ticket_price": row[6],
"flyer_path": row[7], "flyer_path": row[7],
"flyer_url": row[21], "flyer_url": row[21],
"visibility": row[22],
"created_by": row[8], "created_by": row[8],
"is_past": is_past, "is_past": is_past,
"date": start_datetime.strftime("%d.%m.%Y"), "date": start_datetime.strftime("%d.%m.%Y"),
@@ -786,8 +810,12 @@ def can_delete_concert(user, concert) -> bool:
return user["is_admin"] or user["id"] == concert["created_by"] return user["is_admin"] or user["id"] == concert["created_by"]
def can_manage_event_access(user, concert) -> bool:
return bool(user and (user["is_admin"] or user["id"] == concert["created_by"]))
def serialize_concert_card(row): def serialize_concert_card(row):
concert_id, artist, start_datetime, end_datetime, venue, city, event_type, parent_event_id = row concert_id, artist, start_datetime, end_datetime, venue, city, event_type, parent_event_id, visibility, is_invited = row
venue_text = venue or "Veranstaltungsort unbekannt" venue_text = venue or "Veranstaltungsort unbekannt"
if city: if city:
venue_text += f", {city}" venue_text += f", {city}"
@@ -802,6 +830,8 @@ def serialize_concert_card(row):
"event_type": event_type, "event_type": event_type,
"event_type_label": EVENT_TYPES[event_type], "event_type_label": EVENT_TYPES[event_type],
"parent_event_id": parent_event_id, "parent_event_id": parent_event_id,
"visibility": visibility,
"is_invited": bool(is_invited),
"children": [], "children": [],
"_start_datetime": start_datetime, "_start_datetime": start_datetime,
"_end_datetime": end_datetime, "_end_datetime": end_datetime,
@@ -830,18 +860,38 @@ def build_event_overview(rows):
return upcoming, past return upcoming, past
def load_event_overview(search_query: str = ""): def load_event_overview(user, search_query: str = ""):
query = """ query = """
SELECT concerts.id, concerts.artist, concerts.start_datetime, SELECT concerts.id, concerts.artist, concerts.start_datetime,
concerts.end_datetime, venues.name, venues.city, concerts.end_datetime, venues.name, venues.city,
concerts.event_type, concerts.parent_event_id concerts.event_type, concerts.parent_event_id,
concerts.visibility,
EXISTS (SELECT 1 FROM event_invitations ei
WHERE ei.concert_id = concerts.id AND ei.user_id = %s)
FROM concerts FROM concerts
LEFT JOIN venues ON concerts.venue_id = venues.id LEFT JOIN venues ON concerts.venue_id = venues.id
WHERE %s
OR concerts.visibility = 'public'
OR concerts.created_by = %s
OR (
concerts.visibility = 'friends' AND EXISTS (
SELECT 1 FROM friendships f
WHERE f.status = 'accepted'
AND ((f.requester_id = concerts.created_by AND f.addressee_id = %s)
OR (f.addressee_id = concerts.created_by AND f.requester_id = %s))
)
)
OR (
concerts.visibility = 'private' AND EXISTS (
SELECT 1 FROM event_invitations ei
WHERE ei.concert_id = concerts.id AND ei.user_id = %s
)
)
ORDER BY concerts.start_datetime ASC ORDER BY concerts.start_datetime ASC
""" """
with get_db_connection() as connection: with get_db_connection() as connection:
with connection.cursor() as cursor: with connection.cursor() as cursor:
cursor.execute(query) cursor.execute(query, (user["id"], user["is_admin"], user["id"], user["id"], user["id"], user["id"]))
upcoming, past = build_event_overview(cursor.fetchall()) upcoming, past = build_event_overview(cursor.fetchall())
term = search_query.strip().casefold() term = search_query.strip().casefold()
@@ -858,6 +908,49 @@ def load_event_overview(search_query: str = ""):
) )
def can_view_event(user, concert) -> bool:
if user["is_admin"] or concert["visibility"] == "public" or concert["created_by"] == user["id"]:
return True
with get_db_connection() as connection:
with connection.cursor() as cursor:
if concert["visibility"] == "private":
cursor.execute(
"SELECT 1 FROM event_invitations WHERE concert_id = %s AND user_id = %s",
(concert["id"], user["id"]),
)
else:
cursor.execute(
"""
SELECT 1 FROM friendships WHERE status = 'accepted'
AND ((requester_id = %s AND addressee_id = %s)
OR (addressee_id = %s AND requester_id = %s))
""",
(concert["created_by"], user["id"], concert["created_by"], user["id"]),
)
return cursor.fetchone() is not None
def get_invitable_users(exclude_user_id: int):
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"""
SELECT id, username, COALESCE(display_name, username)
FROM users WHERE id <> %s
ORDER BY COALESCE(display_name, username), username
""",
(exclude_user_id,),
)
return [{"id": row[0], "username": row[1], "display_name": row[2]} for row in cursor.fetchall()]
def get_event_invitee_ids(concert_id: int):
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute("SELECT user_id FROM event_invitations WHERE concert_id = %s", (concert_id,))
return {row[0] for row in cursor.fetchall()}
def search_users(search_query: str): def search_users(search_query: str):
term = search_query.strip() term = search_query.strip()
if not term: if not term:
@@ -1055,10 +1148,18 @@ def load_profile(username: str):
return None return None
cursor.execute( cursor.execute(
"SELECT badge_code FROM user_badges WHERE user_id = %s", "SELECT badge_code, awarded_at FROM user_badges WHERE user_id = %s",
(row[0],), (row[0],),
) )
earned_codes = {badge_row[0] for badge_row in cursor.fetchall()} badge_rows = cursor.fetchall()
earned_codes = {badge_row[0] for badge_row in badge_rows}
badge_awarded_at = {badge_row[0]: badge_row[1] for badge_row in badge_rows}
is_founder = row[1].casefold() == "kai"
if is_founder:
earned_codes.add("founder")
if row[7]:
earned_codes.add("admin")
return { return {
"id": row[0], "id": row[0],
@@ -1071,7 +1172,9 @@ def load_profile(username: str):
"instagram_handle": row[5].rstrip("/").rsplit("/", 1)[-1] if row[5] else None, "instagram_handle": row[5].rstrip("/").rsplit("/", 1)[-1] if row[5] else None,
"profile_visibility": row[6], "profile_visibility": row[6],
"is_admin": bool(row[7]), "is_admin": bool(row[7]),
"is_founder": is_founder,
"earned_codes": earned_codes, "earned_codes": earned_codes,
"badge_awarded_at": badge_awarded_at,
} }
@@ -1567,7 +1670,6 @@ def update_user_role(
request: Request, request: Request,
user_id: int, user_id: int,
role: str = Form(...), role: str = Form(...),
founder_badge: str = Form(""),
): ):
user = require_admin(request) user = require_admin(request)
@@ -1587,20 +1689,6 @@ def update_user_role(
"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),
) )
if founder_badge == "on":
cursor.execute(
"""
INSERT INTO user_badges (user_id, badge_code)
VALUES (%s, 'founder')
ON CONFLICT (user_id, badge_code) DO NOTHING
""",
(user_id,),
)
else:
cursor.execute(
"DELETE FROM user_badges WHERE user_id = %s AND badge_code = 'founder'",
(user_id,),
)
connection.commit() connection.commit()
return RedirectResponse("/admin/users", status_code=303) return RedirectResponse("/admin/users", status_code=303)
@@ -1981,11 +2069,12 @@ def logout(request: Request):
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
def home(request: Request, q: str = ""): def home(request: Request, q: str = ""):
upcoming_concerts, _past_concerts = load_event_overview(q) user = get_current_user(request)
upcoming_concerts, _past_concerts = load_event_overview(user, q)
template = templates.get_template("index.html") template = templates.get_template("index.html")
return template.render( return template.render(
user=get_current_user(request), user=user,
upcoming_concerts=upcoming_concerts, upcoming_concerts=upcoming_concerts,
past_concerts=[], past_concerts=[],
archive=False, archive=False,
@@ -1996,10 +2085,11 @@ def home(request: Request, q: str = ""):
@app.get("/events/past", response_class=HTMLResponse) @app.get("/events/past", response_class=HTMLResponse)
def past_events(request: Request, q: str = ""): def past_events(request: Request, q: str = ""):
_upcoming_concerts, past_concerts = load_event_overview(q) user = get_current_user(request)
_upcoming_concerts, past_concerts = load_event_overview(user, q)
template = templates.get_template("index.html") template = templates.get_template("index.html")
return template.render( return template.render(
user=get_current_user(request), user=user,
upcoming_concerts=[], upcoming_concerts=[],
past_concerts=past_concerts, past_concerts=past_concerts,
archive=True, archive=True,
@@ -2102,9 +2192,11 @@ def render_profile(
"category": category, "category": category,
"earned": code in profile["earned_codes"], "earned": code in profile["earned_codes"],
"image_path": badge_assets.get(code), "image_path": badge_assets.get(code),
"sort_key": (0, datetime.min) if code == "founder" else (1, datetime.min) if code == "admin" else (2, profile["badge_awarded_at"].get(code) or datetime.max),
} }
for code, name, icon, threshold, description, category in BADGE_DEFINITIONS for code, name, icon, threshold, description, category in BADGE_DEFINITIONS
] ]
badges.sort(key=lambda badge: badge["sort_key"])
template = templates.get_template("profile.html") template = templates.get_template("profile.html")
return HTMLResponse( return HTMLResponse(
@@ -2416,7 +2508,8 @@ def new_concert(request: Request):
return login_redirect("/concerts/new") return login_redirect("/concerts/new")
template = templates.get_template("new_concert.html") template = templates.get_template("new_concert.html")
return template.render(user=user, linkable_events=get_linkable_events()) return template.render(user=user, linkable_events=get_linkable_events(),
invitable_users=get_invitable_users(user["id"]))
# ============================================================ # ============================================================
@@ -2429,13 +2522,21 @@ def new_concert(request: Request):
) )
def concert_detail(request: Request, concert_id: int): def concert_detail(request: Request, concert_id: int):
concert = load_concert(concert_id) concert = load_concert(concert_id)
user = get_current_user(request)
if not concert: if not concert or not can_view_event(user, concert):
return HTMLResponse( return HTMLResponse(
"<h1>Veranstaltung nicht gefunden</h1>", "<h1>Veranstaltung nicht gefunden</h1>",
status_code=404 status_code=404
) )
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"UPDATE event_invitations SET viewed_at = CURRENT_TIMESTAMP WHERE concert_id = %s AND user_id = %s AND viewed_at IS NULL",
(concert_id, user["id"]),
)
connection.commit()
user = get_current_user(request) user = get_current_user(request)
with get_db_connection() as connection: with get_db_connection() as connection:
@@ -2561,6 +2662,8 @@ async def create_concert(
artist: str = Form(...), artist: str = Form(...),
event_type: str = Form("concert"), event_type: str = Form("concert"),
parent_event_id: str = Form(""), parent_event_id: str = Form(""),
visibility: str = Form("public"),
invited_user_ids: list[int] = Form(default=[]),
venue_id: str = Form(""), venue_id: str = Form(""),
venue_name: str = Form(""), venue_name: str = Form(""),
city: str = Form(""), city: str = Form(""),
@@ -2588,6 +2691,10 @@ async def create_concert(
return HTMLResponse("<h1>Bei Festivals ist ein Enddatum erforderlich.</h1>", status_code=400) return HTMLResponse("<h1>Bei Festivals ist ein Enddatum erforderlich.</h1>", status_code=400)
if event_type != "festival": if event_type != "festival":
end_datetime = "" end_datetime = ""
if visibility not in {"public", "friends", "private"}:
return HTMLResponse("<h1>Ungültige Sichtbarkeit.</h1>", status_code=400)
if event_type != "other":
visibility = "public"
if end_datetime and datetime.fromisoformat(end_datetime) < datetime.fromisoformat(start_datetime): if end_datetime and datetime.fromisoformat(end_datetime) < datetime.fromisoformat(start_datetime):
return HTMLResponse("<h1>Das Enddatum darf nicht vor dem Beginn liegen.</h1>", status_code=400) return HTMLResponse("<h1>Das Enddatum darf nicht vor dem Beginn liegen.</h1>", status_code=400)
try: try:
@@ -2639,10 +2746,11 @@ async def create_concert(
ticket_price, ticket_price,
flyer_path, flyer_path,
flyer_url, flyer_url,
visibility,
created_by created_by
) )
VALUES ( VALUES (
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s
) )
RETURNING id RETURNING id
""", """,
@@ -2658,11 +2766,22 @@ async def create_concert(
ticket_price or None, ticket_price or None,
flyer_path, flyer_path,
normalized_flyer_url, normalized_flyer_url,
visibility,
user["id"], user["id"],
), ),
) )
concert_id = cursor.fetchone()[0] concert_id = cursor.fetchone()[0]
if visibility == "private" and invited_user_ids:
cursor.execute(
"""
INSERT INTO event_invitations (concert_id, user_id, invited_by)
SELECT %s, id, %s FROM users
WHERE id = ANY(%s) AND id <> %s
ON CONFLICT (concert_id, user_id) DO NOTHING
""",
(concert_id, user["id"], invited_user_ids, user["id"]),
)
connection.commit() connection.commit()
@@ -2690,7 +2809,7 @@ def edit_concert_page(request: Request, concert_id: int):
concert = load_concert(concert_id) concert = load_concert(concert_id)
if not concert: if not concert or not can_view_event(user, concert):
return HTMLResponse( return HTMLResponse(
"<h1>Veranstaltung nicht gefunden</h1>", "<h1>Veranstaltung nicht gefunden</h1>",
status_code=404 status_code=404
@@ -2710,6 +2829,9 @@ def edit_concert_page(request: Request, concert_id: int):
can_edit_title=can_edit_title(user, concert), can_edit_title=can_edit_title(user, concert),
can_edit_details=can_edit_details(user, concert), can_edit_details=can_edit_details(user, concert),
can_delete=can_delete_concert(user, concert), can_delete=can_delete_concert(user, concert),
invitable_users=get_invitable_users(user["id"]),
invited_user_ids=get_event_invitee_ids(concert_id),
can_manage_access=can_manage_event_access(user, concert),
) )
@@ -2720,6 +2842,8 @@ async def edit_concert(
artist: str = Form(""), artist: str = Form(""),
event_type: str = Form("concert"), event_type: str = Form("concert"),
parent_event_id: str = Form(""), parent_event_id: str = Form(""),
visibility: str = Form("public"),
invited_user_ids: list[int] = Form(default=[]),
venue_id: str = Form(""), venue_id: str = Form(""),
venue_name: str = Form(""), venue_name: str = Form(""),
city: str = Form(""), city: str = Form(""),
@@ -2743,7 +2867,7 @@ async def edit_concert(
concert = load_concert(concert_id) concert = load_concert(concert_id)
if not concert: if not concert or not can_view_event(user, concert):
return HTMLResponse( return HTMLResponse(
"<h1>Veranstaltung nicht gefunden</h1>", "<h1>Veranstaltung nicht gefunden</h1>",
status_code=404 status_code=404
@@ -2768,15 +2892,25 @@ async def edit_concert(
next_flyer_url = concert["flyer_url"] next_flyer_url = concert["flyer_url"]
next_venue_id = concert["venue"]["id"] next_venue_id = concert["venue"]["id"]
next_event_type = concert["event_type"] next_event_type = concert["event_type"]
next_visibility = concert["visibility"]
next_parent_event_id = concert["parent_event"]["id"] if concert["parent_event"] else None next_parent_event_id = concert["parent_event"]["id"] if concert["parent_event"] else None
if can_edit_details(user, concert): if can_edit_details(user, concert):
if not can_manage_event_access(user, concert):
event_type = concert["event_type"]
if event_type not in EVENT_TYPES: if event_type not in EVENT_TYPES:
return HTMLResponse("<h1>Ungültige Veranstaltungskategorie.</h1>", status_code=400) return HTMLResponse("<h1>Ungültige Veranstaltungskategorie.</h1>", status_code=400)
if event_type == "festival" and not end_datetime: if event_type == "festival" and not end_datetime:
return HTMLResponse("<h1>Bei Festivals ist ein Enddatum erforderlich.</h1>", status_code=400) return HTMLResponse("<h1>Bei Festivals ist ein Enddatum erforderlich.</h1>", status_code=400)
if event_type != "festival": if event_type != "festival":
end_datetime = "" end_datetime = ""
if can_manage_event_access(user, concert):
if visibility not in {"public", "friends", "private"}:
return HTMLResponse("<h1>Ungültige Sichtbarkeit.</h1>", status_code=400)
if event_type != "other":
visibility = "public"
else:
visibility = concert["visibility"]
effective_start = start_datetime or concert["start_local"] effective_start = start_datetime or concert["start_local"]
if end_datetime and datetime.fromisoformat(end_datetime) < datetime.fromisoformat(effective_start): if end_datetime and datetime.fromisoformat(end_datetime) < datetime.fromisoformat(effective_start):
return HTMLResponse("<h1>Das Enddatum darf nicht vor dem Beginn liegen.</h1>", status_code=400) return HTMLResponse("<h1>Das Enddatum darf nicht vor dem Beginn liegen.</h1>", status_code=400)
@@ -2820,6 +2954,7 @@ async def edit_concert(
next_start = start_datetime or concert["start_datetime"] next_start = start_datetime or concert["start_datetime"]
next_end = end_datetime or None next_end = end_datetime or None
next_event_type = event_type next_event_type = event_type
next_visibility = visibility
next_description = description or None next_description = description or None
next_ticket_url = ticket_url or None next_ticket_url = ticket_url or None
next_ticket_price = ticket_price or None next_ticket_price = ticket_price or None
@@ -2843,6 +2978,7 @@ async def edit_concert(
ticket_price = %s, ticket_price = %s,
flyer_path = %s, flyer_path = %s,
flyer_url = %s flyer_url = %s
, visibility = %s
WHERE id = %s WHERE id = %s
""", """,
( (
@@ -2857,9 +2993,26 @@ async def edit_concert(
next_ticket_price, next_ticket_price,
next_flyer, next_flyer,
next_flyer_url, next_flyer_url,
next_visibility,
concert_id, concert_id,
), ),
) )
if can_manage_event_access(user, concert) and next_visibility == "private":
cursor.execute(
"DELETE FROM event_invitations WHERE concert_id = %s AND NOT (user_id = ANY(%s))",
(concert_id, invited_user_ids or [0]),
)
if invited_user_ids:
cursor.execute(
"""
INSERT INTO event_invitations (concert_id, user_id, invited_by)
SELECT %s, id, %s FROM users WHERE id = ANY(%s) AND id <> %s
ON CONFLICT (concert_id, user_id) DO NOTHING
""",
(concert_id, user["id"], invited_user_ids, user["id"]),
)
elif can_manage_event_access(user, concert):
cursor.execute("DELETE FROM event_invitations WHERE concert_id = %s", (concert_id,))
connection.commit() connection.commit()
return RedirectResponse( return RedirectResponse(
@@ -2877,7 +3030,7 @@ def delete_concert(request: Request, concert_id: int):
concert = load_concert(concert_id) concert = load_concert(concert_id)
if not concert: if not concert or not can_view_event(user, concert):
return HTMLResponse( return HTMLResponse(
"<h1>Veranstaltung nicht gefunden</h1>", "<h1>Veranstaltung nicht gefunden</h1>",
status_code=404 status_code=404
@@ -2918,7 +3071,8 @@ def set_attendance(
return login_redirect(f"/concerts/{concert_id}") return login_redirect(f"/concerts/{concert_id}")
if status not in {"attending", "maybe", "ticket_search", "ticket_offer"}: if status not in {"attending", "maybe", "ticket_search", "ticket_offer"}:
return HTMLResponse("<h1>Ungültige Auswahl</h1>", status_code=400) return HTMLResponse("<h1>Ungültige Auswahl</h1>", status_code=400)
if not load_concert(concert_id): concert = load_concert(concert_id)
if not concert or not can_view_event(user, concert):
return HTMLResponse("<h1>Veranstaltung nicht gefunden</h1>", status_code=404) return HTMLResponse("<h1>Veranstaltung nicht gefunden</h1>", status_code=404)
with get_db_connection() as connection: with get_db_connection() as connection:
@@ -2952,7 +3106,7 @@ def add_comment(
concert = load_concert(concert_id) concert = load_concert(concert_id)
if not concert: if not concert or not can_view_event(user, concert):
return HTMLResponse( return HTMLResponse(
"<h1>Veranstaltung nicht gefunden</h1>", "<h1>Veranstaltung nicht gefunden</h1>",
status_code=404 status_code=404
@@ -3006,7 +3160,7 @@ async def add_photo(
concert = load_concert(concert_id) concert = load_concert(concert_id)
if not concert: if not concert or not can_view_event(user, concert):
return HTMLResponse( return HTMLResponse(
"<h1>Veranstaltung nicht gefunden</h1>", "<h1>Veranstaltung nicht gefunden</h1>",
status_code=404 status_code=404
+1
View File
@@ -3,6 +3,7 @@
<div class="user-menu-panel"> <div class="user-menu-panel">
<a href="/profile">Mein Profil</a> <a href="/profile">Mein Profil</a>
<a href="/messages">Nachrichten{% if user.unread_message_count %} <span class="menu-count">{{ user.unread_message_count }}</span>{% endif %}</a> <a href="/messages">Nachrichten{% if user.unread_message_count %} <span class="menu-count">{{ user.unread_message_count }}</span>{% endif %}</a>
{% if user.event_invitation_count %}<a href="/#event-invitations">Veranstaltungseinladungen <span class="menu-count">{{ user.event_invitation_count }}</span></a>{% endif %}
<form method="post" action="/logout"> <form method="post" action="/logout">
<button type="submit">Abmelden</button> <button type="submit">Abmelden</button>
</form> </form>
-1
View File
@@ -34,7 +34,6 @@
<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>{{ managed_user.display_name }}</strong><div class="admin-meta">@{{ managed_user.username }} · {{ managed_user.email }} · seit {{ managed_user.created_at }}</div></div>
<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>
<label><input type="checkbox" name="founder_badge" {% if managed_user.has_founder_badge %}checked{% endif %}> Gründer-Patch</label>
<button class="button" type="submit">Speichern</button> <button class="button" type="submit">Speichern</button>
</form> </form>
<div class="role-form"> <div class="role-form">
+1
View File
@@ -63,6 +63,7 @@
<div class="concert-content"> <div class="concert-content">
<span class="event-type-badge">{{ concert.event_type_label }}</span> <span class="event-type-badge">{{ concert.event_type_label }}</span>
{% if concert.visibility == 'friends' %}<span class="event-type-badge">🔒 Nur Freunde</span>{% elif concert.visibility == 'private' %}<span class="event-type-badge">🔐 Privat / eingeladen</span>{% endif %}
<h1> <h1>
{{ concert.artist }} {{ concert.artist }}
+23 -1
View File
@@ -23,7 +23,7 @@
{% if can_edit_details %} {% if can_edit_details %}
<p> <p>
<label>Kategorie<br> <label>Kategorie<br>
<select name="event_type" id="event-type" required> <select name="event_type" id="event-type" required {% if not can_manage_access %}disabled{% endif %}>
<option value="concert" {% if concert.event_type == 'concert' %}selected{% endif %}>Konzert</option> <option value="concert" {% if concert.event_type == 'concert' %}selected{% endif %}>Konzert</option>
<option value="festival" {% if concert.event_type == 'festival' %}selected{% endif %}>Festival</option> <option value="festival" {% if concert.event_type == 'festival' %}selected{% endif %}>Festival</option>
<option value="other" {% if concert.event_type == 'other' %}selected{% endif %}>Sonstiges</option> <option value="other" {% if concert.event_type == 'other' %}selected{% endif %}>Sonstiges</option>
@@ -40,6 +40,19 @@
</select> </select>
</label> </label>
</p> </p>
{% if can_manage_access %}<fieldset id="event-visibility-field" {% if concert.event_type != 'other' %}hidden{% endif %}>
<legend>Sichtbarkeit</legend>
<label><input type="radio" name="visibility" value="public" {% if concert.visibility == 'public' %}checked{% endif %}> Öffentlich</label><br>
<label><input type="radio" name="visibility" value="friends" {% if concert.visibility == 'friends' %}checked{% endif %}> Nur Freunde</label><br>
<label><input type="radio" name="visibility" value="private" {% if concert.visibility == 'private' %}checked{% endif %}> Privat nur eingeladene Mitglieder</label>
</fieldset>
<fieldset id="event-invitations-field" {% if concert.event_type != 'other' or concert.visibility != 'private' %}hidden{% endif %}>
<legend>Mitglieder einladen</legend>
{% for member in invitable_users %}
<label style="display:block;margin:7px 0"><input type="checkbox" name="invited_user_ids" value="{{ member.id }}" {% if member.id in invited_user_ids %}checked{% endif %}> {{ member.display_name }} <small>@{{ member.username }}</small></label>
{% else %}<p>Noch keine weiteren Mitglieder vorhanden.</p>{% endfor %}
</fieldset>
{% endif %}
{% endif %} {% endif %}
<p> <p>
<label>Titel / Künstler / Band<br> <label>Titel / Künstler / Band<br>
@@ -125,6 +138,9 @@ const parentEventField = document.getElementById("parent-event-field");
const parentEventId = document.getElementById("parent-event-id"); const parentEventId = document.getElementById("parent-event-id");
const festivalEndField = document.getElementById("festival-end-field"); const festivalEndField = document.getElementById("festival-end-field");
const festivalEndDatetime = document.getElementById("festival-end-datetime"); const festivalEndDatetime = document.getElementById("festival-end-datetime");
const eventVisibilityField = document.getElementById("event-visibility-field");
const eventInvitationsField = document.getElementById("event-invitations-field");
const visibilityInputs = [...document.querySelectorAll('input[name="visibility"]')];
function updateEventFields() { function updateEventFields() {
const isFestival = eventType.value === "festival"; const isFestival = eventType.value === "festival";
@@ -132,11 +148,17 @@ function updateEventFields() {
festivalEndField.hidden = !isFestival; festivalEndField.hidden = !isFestival;
festivalEndDatetime.required = isFestival; festivalEndDatetime.required = isFestival;
parentEventField.hidden = !isOther; parentEventField.hidden = !isOther;
if (eventVisibilityField) {
eventVisibilityField.hidden = !isOther;
const isPrivate = isOther && document.querySelector('input[name="visibility"]:checked').value === "private";
eventInvitationsField.hidden = !isPrivate;
}
if (!isFestival) festivalEndDatetime.value = ""; if (!isFestival) festivalEndDatetime.value = "";
if (!isOther) parentEventId.value = ""; if (!isOther) parentEventId.value = "";
} }
eventType.addEventListener("change", updateEventFields); eventType.addEventListener("change", updateEventFields);
visibilityInputs.forEach(input => input.addEventListener("change", updateEventFields));
</script> </script>
{% endif %} {% endif %}
</body> </body>
+2 -1
View File
@@ -205,6 +205,7 @@
{% macro event_card(concert, linked=false) %} {% macro event_card(concert, linked=false) %}
<a href="/concerts/{{ concert.id }}" class="concert-card{% if linked %} linked-event{% endif %}"> <a href="/concerts/{{ concert.id }}" class="concert-card{% if linked %} linked-event{% endif %}">
<span class="event-category">{{ concert.event_type_label }}</span> <span class="event-category">{{ concert.event_type_label }}</span>
{% if concert.is_invited %}<span class="event-category"> · ✉ Eingeladen</span>{% elif concert.visibility == 'friends' %}<span class="event-category"> · 🔒 Nur Freunde</span>{% elif concert.visibility == 'private' %}<span class="event-category"> · 🔐 Privat</span>{% endif %}
<div class="concert-artist"> <div class="concert-artist">
{% if concert.event_type == 'festival' %}🎪{% elif concert.event_type == 'other' %}🥂{% else %}🎸{% endif %} {% if concert.event_type == 'festival' %}🎪{% elif concert.event_type == 'other' %}🥂{% else %}🎸{% endif %}
{{ concert.artist }} {{ concert.artist }}
@@ -290,7 +291,7 @@
{% if upcoming_concerts or past_concerts %} {% if upcoming_concerts or past_concerts %}
<div class="concert-list"> <div class="concert-list" id="event-invitations">
{% for concert in upcoming_concerts %} {% for concert in upcoming_concerts %}
<div class="event-group"> <div class="event-group">
+22
View File
@@ -126,6 +126,21 @@
<small>Zum Beispiel ein gemeinsames Vortrinken vor einem Konzert.</small> <small>Zum Beispiel ein gemeinsames Vortrinken vor einem Konzert.</small>
</p> </p>
<fieldset id="event-visibility-field" hidden>
<legend>Sichtbarkeit</legend>
<label><input type="radio" name="visibility" value="public" checked> Öffentlich alle können die Veranstaltung sehen und teilnehmen</label><br>
<label><input type="radio" name="visibility" value="friends"> Nur Freunde nur bestätigte Freunde können sie sehen und teilnehmen</label><br>
<label><input type="radio" name="visibility" value="private"> Privat nur gezielt eingeladene Mitglieder</label>
</fieldset>
<fieldset id="event-invitations-field" hidden>
<legend>Mitglieder einladen</legend>
<p class="flyer-help">Eingeladene erhalten eine Benachrichtigung und sehen die Veranstaltung in ihrer Übersicht.</p>
{% for member in invitable_users %}
<label style="display:block;margin:7px 0"><input type="checkbox" name="invited_user_ids" value="{{ member.id }}"> {{ member.display_name }} <small>@{{ member.username }}</small></label>
{% else %}<p>Noch keine weiteren Mitglieder vorhanden.</p>{% endfor %}
</fieldset>
<!-- ================================================= --> <!-- ================================================= -->
<!-- Künstler --> <!-- Künstler -->
<!-- ================================================= --> <!-- ================================================= -->
@@ -389,6 +404,9 @@ const parentEventField = document.getElementById("parent-event-field");
const parentEventId = document.getElementById("parent-event-id"); const parentEventId = document.getElementById("parent-event-id");
const festivalEndField = document.getElementById("festival-end-field"); const festivalEndField = document.getElementById("festival-end-field");
const festivalEndDatetime = document.getElementById("festival-end-datetime"); const festivalEndDatetime = document.getElementById("festival-end-datetime");
const eventVisibilityField = document.getElementById("event-visibility-field");
const eventInvitationsField = document.getElementById("event-invitations-field");
const visibilityInputs = [...document.querySelectorAll('input[name="visibility"]')];
function updateEventFields() { function updateEventFields() {
const isFestival = eventType.value === "festival"; const isFestival = eventType.value === "festival";
@@ -396,11 +414,15 @@ function updateEventFields() {
festivalEndField.hidden = !isFestival; festivalEndField.hidden = !isFestival;
festivalEndDatetime.required = isFestival; festivalEndDatetime.required = isFestival;
parentEventField.hidden = !isOther; parentEventField.hidden = !isOther;
eventVisibilityField.hidden = !isOther;
const isPrivate = isOther && document.querySelector('input[name="visibility"]:checked').value === "private";
eventInvitationsField.hidden = !isPrivate;
if (!isFestival) festivalEndDatetime.value = ""; if (!isFestival) festivalEndDatetime.value = "";
if (!isOther) parentEventId.value = ""; if (!isOther) parentEventId.value = "";
} }
eventType.addEventListener("change", updateEventFields); eventType.addEventListener("change", updateEventFields);
visibilityInputs.forEach(input => input.addEventListener("change", updateEventFields));
updateEventFields(); updateEventFields();
/* ============================================================ /* ============================================================
+13
View File
@@ -109,10 +109,23 @@ CREATE TABLE concerts (
ticket_price NUMERIC(10,2), ticket_price NUMERIC(10,2),
flyer_path TEXT, flyer_path TEXT,
flyer_url TEXT, flyer_url TEXT,
visibility VARCHAR(20) NOT NULL DEFAULT 'public'
CHECK (visibility IN ('public', 'friends', 'private')),
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
CREATE TABLE event_invitations (
concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
invited_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
viewed_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (concert_id, user_id)
);
CREATE INDEX idx_event_invitations_user ON event_invitations (user_id, viewed_at);
CREATE TABLE concert_comments ( CREATE TABLE concert_comments (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE, concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE,
+15
View File
@@ -0,0 +1,15 @@
ALTER TABLE concerts
ADD COLUMN IF NOT EXISTS visibility VARCHAR(20) NOT NULL DEFAULT 'public'
CHECK (visibility IN ('public', 'friends', 'private'));
CREATE TABLE IF NOT EXISTS event_invitations (
concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
invited_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
viewed_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (concert_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_event_invitations_user
ON event_invitations (user_id, viewed_at);