diff --git a/app/main.py b/app/main.py index b25f54f..0a18cd3 100644 --- a/app/main.py +++ b/app/main.py @@ -78,6 +78,7 @@ INITIAL_ADMIN_EMAIL = os.environ.get("INITIAL_ADMIN_EMAIL") BADGE_DEFINITIONS = ( ("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"), ("first_gig", "Erster Gig", "🎸", 1, "Dein erstes besuchtes Konzert", "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 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 ( id SERIAL PRIMARY KEY, 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]), "pending_friend_count": row[4], "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 WHERE addressee_id = users.id AND status = 'pending'), (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 JOIN users ON users.id = sessions.user_id @@ -700,7 +722,8 @@ def load_concert(concert_id: int): concerts.parent_event_id, parent_event.artist, parent_event.event_type, - concerts.flyer_url + concerts.flyer_url, + concerts.visibility FROM concerts LEFT JOIN venues ON concerts.venue_id = venues.id @@ -729,6 +752,7 @@ def load_concert(concert_id: int): "ticket_price": row[6], "flyer_path": row[7], "flyer_url": row[21], + "visibility": row[22], "created_by": row[8], "is_past": is_past, "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"] +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): - 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" if city: venue_text += f", {city}" @@ -802,6 +830,8 @@ def serialize_concert_card(row): "event_type": event_type, "event_type_label": EVENT_TYPES[event_type], "parent_event_id": parent_event_id, + "visibility": visibility, + "is_invited": bool(is_invited), "children": [], "_start_datetime": start_datetime, "_end_datetime": end_datetime, @@ -830,18 +860,38 @@ def build_event_overview(rows): return upcoming, past -def load_event_overview(search_query: str = ""): +def load_event_overview(user, search_query: str = ""): query = """ SELECT concerts.id, concerts.artist, concerts.start_datetime, 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 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 """ with get_db_connection() as connection: 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()) 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): term = search_query.strip() if not term: @@ -1055,10 +1148,18 @@ def load_profile(username: str): return None 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],), ) - 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 { "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, "profile_visibility": row[6], "is_admin": bool(row[7]), + "is_founder": is_founder, "earned_codes": earned_codes, + "badge_awarded_at": badge_awarded_at, } @@ -1567,7 +1670,6 @@ def update_user_role( request: Request, user_id: int, role: str = Form(...), - founder_badge: str = Form(""), ): user = require_admin(request) @@ -1587,20 +1689,6 @@ def update_user_role( "UPDATE users SET is_admin = %s WHERE id = %s", (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() return RedirectResponse("/admin/users", status_code=303) @@ -1981,11 +2069,12 @@ def logout(request: Request): @app.get("/", response_class=HTMLResponse) 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") return template.render( - user=get_current_user(request), + user=user, upcoming_concerts=upcoming_concerts, past_concerts=[], archive=False, @@ -1996,10 +2085,11 @@ def home(request: Request, q: str = ""): @app.get("/events/past", response_class=HTMLResponse) 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") return template.render( - user=get_current_user(request), + user=user, upcoming_concerts=[], past_concerts=past_concerts, archive=True, @@ -2102,9 +2192,11 @@ def render_profile( "category": category, "earned": code in profile["earned_codes"], "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 ] + badges.sort(key=lambda badge: badge["sort_key"]) template = templates.get_template("profile.html") return HTMLResponse( @@ -2416,7 +2508,8 @@ def new_concert(request: Request): return login_redirect("/concerts/new") 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): 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( "

Veranstaltung nicht gefunden

", 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) with get_db_connection() as connection: @@ -2561,6 +2662,8 @@ async def create_concert( artist: str = Form(...), event_type: str = Form("concert"), parent_event_id: str = Form(""), + visibility: str = Form("public"), + invited_user_ids: list[int] = Form(default=[]), venue_id: str = Form(""), venue_name: str = Form(""), city: str = Form(""), @@ -2588,6 +2691,10 @@ async def create_concert( return HTMLResponse("

Bei Festivals ist ein Enddatum erforderlich.

", status_code=400) if event_type != "festival": end_datetime = "" + if visibility not in {"public", "friends", "private"}: + return HTMLResponse("

Ungültige Sichtbarkeit.

", status_code=400) + if event_type != "other": + visibility = "public" if end_datetime and datetime.fromisoformat(end_datetime) < datetime.fromisoformat(start_datetime): return HTMLResponse("

Das Enddatum darf nicht vor dem Beginn liegen.

", status_code=400) try: @@ -2639,10 +2746,11 @@ async def create_concert( ticket_price, flyer_path, flyer_url, + visibility, created_by ) 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 """, @@ -2658,11 +2766,22 @@ async def create_concert( ticket_price or None, flyer_path, normalized_flyer_url, + visibility, user["id"], ), ) 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() @@ -2690,7 +2809,7 @@ def edit_concert_page(request: Request, concert_id: int): concert = load_concert(concert_id) - if not concert: + if not concert or not can_view_event(user, concert): return HTMLResponse( "

Veranstaltung nicht gefunden

", 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_details=can_edit_details(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(""), event_type: str = Form("concert"), parent_event_id: str = Form(""), + visibility: str = Form("public"), + invited_user_ids: list[int] = Form(default=[]), venue_id: str = Form(""), venue_name: str = Form(""), city: str = Form(""), @@ -2743,7 +2867,7 @@ async def edit_concert( concert = load_concert(concert_id) - if not concert: + if not concert or not can_view_event(user, concert): return HTMLResponse( "

Veranstaltung nicht gefunden

", status_code=404 @@ -2768,15 +2892,25 @@ async def edit_concert( next_flyer_url = concert["flyer_url"] next_venue_id = concert["venue"]["id"] next_event_type = concert["event_type"] + next_visibility = concert["visibility"] next_parent_event_id = concert["parent_event"]["id"] if concert["parent_event"] else None 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: return HTMLResponse("

Ungültige Veranstaltungskategorie.

", status_code=400) if event_type == "festival" and not end_datetime: return HTMLResponse("

Bei Festivals ist ein Enddatum erforderlich.

", status_code=400) if event_type != "festival": end_datetime = "" + if can_manage_event_access(user, concert): + if visibility not in {"public", "friends", "private"}: + return HTMLResponse("

Ungültige Sichtbarkeit.

", status_code=400) + if event_type != "other": + visibility = "public" + else: + visibility = concert["visibility"] effective_start = start_datetime or concert["start_local"] if end_datetime and datetime.fromisoformat(end_datetime) < datetime.fromisoformat(effective_start): return HTMLResponse("

Das Enddatum darf nicht vor dem Beginn liegen.

", status_code=400) @@ -2820,6 +2954,7 @@ async def edit_concert( next_start = start_datetime or concert["start_datetime"] next_end = end_datetime or None next_event_type = event_type + next_visibility = visibility next_description = description or None next_ticket_url = ticket_url or None next_ticket_price = ticket_price or None @@ -2843,6 +2978,7 @@ async def edit_concert( ticket_price = %s, flyer_path = %s, flyer_url = %s + , visibility = %s WHERE id = %s """, ( @@ -2857,9 +2993,26 @@ async def edit_concert( next_ticket_price, next_flyer, next_flyer_url, + next_visibility, 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() return RedirectResponse( @@ -2877,7 +3030,7 @@ def delete_concert(request: Request, concert_id: int): concert = load_concert(concert_id) - if not concert: + if not concert or not can_view_event(user, concert): return HTMLResponse( "

Veranstaltung nicht gefunden

", status_code=404 @@ -2918,7 +3071,8 @@ def set_attendance( return login_redirect(f"/concerts/{concert_id}") if status not in {"attending", "maybe", "ticket_search", "ticket_offer"}: return HTMLResponse("

Ungültige Auswahl

", 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("

Veranstaltung nicht gefunden

", status_code=404) with get_db_connection() as connection: @@ -2952,7 +3106,7 @@ def add_comment( concert = load_concert(concert_id) - if not concert: + if not concert or not can_view_event(user, concert): return HTMLResponse( "

Veranstaltung nicht gefunden

", status_code=404 @@ -3006,7 +3160,7 @@ async def add_photo( concert = load_concert(concert_id) - if not concert: + if not concert or not can_view_event(user, concert): return HTMLResponse( "

Veranstaltung nicht gefunden

", status_code=404 diff --git a/app/templates/_user_menu.html b/app/templates/_user_menu.html index ddf4b0e..8f63d0b 100644 --- a/app/templates/_user_menu.html +++ b/app/templates/_user_menu.html @@ -3,6 +3,7 @@
Mein Profil Nachrichten{% if user.unread_message_count %} {{ user.unread_message_count }}{% endif %} + {% if user.event_invitation_count %}Veranstaltungseinladungen {{ user.event_invitation_count }}{% endif %}
diff --git a/app/templates/admin_users.html b/app/templates/admin_users.html index 35584f5..1bcc831 100644 --- a/app/templates/admin_users.html +++ b/app/templates/admin_users.html @@ -34,7 +34,6 @@
{{ managed_user.display_name }}
@{{ managed_user.username }} · {{ managed_user.email }} · seit {{ managed_user.created_at }}
-
diff --git a/app/templates/concert_detail.html b/app/templates/concert_detail.html index 773fc8a..8ad2a01 100644 --- a/app/templates/concert_detail.html +++ b/app/templates/concert_detail.html @@ -63,6 +63,7 @@
{{ concert.event_type_label }} + {% if concert.visibility == 'friends' %}🔒 Nur Freunde{% elif concert.visibility == 'private' %}🔐 Privat / eingeladen{% endif %}

{{ concert.artist }} diff --git a/app/templates/edit_concert.html b/app/templates/edit_concert.html index 19108d8..67e6330 100644 --- a/app/templates/edit_concert.html +++ b/app/templates/edit_concert.html @@ -23,7 +23,7 @@ {% if can_edit_details %}

+ {% if can_manage_access %}
+ Sichtbarkeit +
+
+ +
+
+ Mitglieder einladen + {% for member in invitable_users %} + + {% else %}

Noch keine weiteren Mitglieder vorhanden.

{% endfor %} +
+ {% endif %} {% endif %}

{% if concert.event_type == 'festival' %}🎪{% elif concert.event_type == 'other' %}🥂{% else %}🎸{% endif %} {{ concert.artist }} @@ -290,7 +291,7 @@ {% if upcoming_concerts or past_concerts %} -
+
{% for concert in upcoming_concerts %}
diff --git a/app/templates/new_concert.html b/app/templates/new_concert.html index 42dcc83..de7c35e 100644 --- a/app/templates/new_concert.html +++ b/app/templates/new_concert.html @@ -126,6 +126,21 @@ Zum Beispiel ein gemeinsames Vortrinken vor einem Konzert.

+ + + + @@ -389,6 +404,9 @@ const parentEventField = document.getElementById("parent-event-field"); const parentEventId = document.getElementById("parent-event-id"); const festivalEndField = document.getElementById("festival-end-field"); 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() { const isFestival = eventType.value === "festival"; @@ -396,11 +414,15 @@ function updateEventFields() { festivalEndField.hidden = !isFestival; festivalEndDatetime.required = isFestival; 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 (!isOther) parentEventId.value = ""; } eventType.addEventListener("change", updateEventFields); +visibilityInputs.forEach(input => input.addEventListener("change", updateEventFields)); updateEventFields(); /* ============================================================ diff --git a/db/init/01_initial.sql b/db/init/01_initial.sql index f120475..e0c68e9 100644 --- a/db/init/01_initial.sql +++ b/db/init/01_initial.sql @@ -109,10 +109,23 @@ CREATE TABLE concerts ( ticket_price NUMERIC(10,2), flyer_path 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_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 ( id SERIAL PRIMARY KEY, concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE, diff --git a/db/migrations/11_event_privacy.sql b/db/migrations/11_event_privacy.sql new file mode 100644 index 0000000..050f262 --- /dev/null +++ b/db/migrations/11_event_privacy.sql @@ -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);