Add private event invitations
This commit is contained in:
+191
-37
@@ -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(
|
||||
"<h1>Veranstaltung nicht gefunden</h1>",
|
||||
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("<h1>Bei Festivals ist ein Enddatum erforderlich.</h1>", status_code=400)
|
||||
if event_type != "festival":
|
||||
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):
|
||||
return HTMLResponse("<h1>Das Enddatum darf nicht vor dem Beginn liegen.</h1>", 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(
|
||||
"<h1>Veranstaltung nicht gefunden</h1>",
|
||||
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(
|
||||
"<h1>Veranstaltung nicht gefunden</h1>",
|
||||
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("<h1>Ungültige Veranstaltungskategorie.</h1>", status_code=400)
|
||||
if event_type == "festival" and not end_datetime:
|
||||
return HTMLResponse("<h1>Bei Festivals ist ein Enddatum erforderlich.</h1>", 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("<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"]
|
||||
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)
|
||||
@@ -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(
|
||||
"<h1>Veranstaltung nicht gefunden</h1>",
|
||||
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("<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)
|
||||
|
||||
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(
|
||||
"<h1>Veranstaltung nicht gefunden</h1>",
|
||||
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(
|
||||
"<h1>Veranstaltung nicht gefunden</h1>",
|
||||
status_code=404
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<div class="user-menu-panel">
|
||||
<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>
|
||||
{% 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">
|
||||
<button type="submit">Abmelden</button>
|
||||
</form>
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
<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>
|
||||
</form>
|
||||
<div class="role-form">
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
<div class="concert-content">
|
||||
|
||||
<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>
|
||||
{{ concert.artist }}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
{% if can_edit_details %}
|
||||
<p>
|
||||
<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="festival" {% if concert.event_type == 'festival' %}selected{% endif %}>Festival</option>
|
||||
<option value="other" {% if concert.event_type == 'other' %}selected{% endif %}>Sonstiges</option>
|
||||
@@ -40,6 +40,19 @@
|
||||
</select>
|
||||
</label>
|
||||
</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 %}
|
||||
<p>
|
||||
<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 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";
|
||||
@@ -132,11 +148,17 @@ function updateEventFields() {
|
||||
festivalEndField.hidden = !isFestival;
|
||||
festivalEndDatetime.required = isFestival;
|
||||
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 (!isOther) parentEventId.value = "";
|
||||
}
|
||||
|
||||
eventType.addEventListener("change", updateEventFields);
|
||||
visibilityInputs.forEach(input => input.addEventListener("change", updateEventFields));
|
||||
</script>
|
||||
{% endif %}
|
||||
</body>
|
||||
|
||||
@@ -205,6 +205,7 @@
|
||||
{% macro event_card(concert, linked=false) %}
|
||||
<a href="/concerts/{{ concert.id }}" class="concert-card{% if linked %} linked-event{% endif %}">
|
||||
<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">
|
||||
{% if concert.event_type == 'festival' %}🎪{% elif concert.event_type == 'other' %}🥂{% else %}🎸{% endif %}
|
||||
{{ concert.artist }}
|
||||
@@ -290,7 +291,7 @@
|
||||
|
||||
{% if upcoming_concerts or past_concerts %}
|
||||
|
||||
<div class="concert-list">
|
||||
<div class="concert-list" id="event-invitations">
|
||||
|
||||
{% for concert in upcoming_concerts %}
|
||||
<div class="event-group">
|
||||
|
||||
@@ -126,6 +126,21 @@
|
||||
<small>Zum Beispiel ein gemeinsames Vortrinken vor einem Konzert.</small>
|
||||
</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 -->
|
||||
<!-- ================================================= -->
|
||||
@@ -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();
|
||||
|
||||
/* ============================================================
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user