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