Add band and venue follows with concert diary

This commit is contained in:
kai
2026-08-29 09:25:39 +02:00
parent d79e303396
commit 159515b0eb
11 changed files with 506 additions and 8 deletions
+362 -5
View File
@@ -395,6 +395,44 @@ def ensure_schema():
CREATE INDEX IF NOT EXISTS idx_direct_messages_unread
ON direct_messages (recipient_id, read_at)
""",
"""
CREATE TABLE IF NOT EXISTS followed_bands (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
band_key VARCHAR(255) NOT NULL,
display_name VARCHAR(255) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, band_key)
)
""",
"""
CREATE TABLE IF NOT EXISTS followed_venues (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
venue_id INTEGER NOT NULL REFERENCES venues(id) ON DELETE CASCADE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, venue_id)
)
""",
"""
CREATE TABLE IF NOT EXISTS concert_diary (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE,
rating SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 5),
favorite_song VARCHAR(255),
notes TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, concert_id)
)
""",
"""
CREATE TABLE IF NOT EXISTS concert_bands (
concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE,
band_key VARCHAR(255) NOT NULL,
display_name VARCHAR(255) NOT NULL,
position SMALLINT NOT NULL DEFAULT 0,
PRIMARY KEY (concert_id, band_key)
)
""",
]
with get_db_connection() as connection:
@@ -1088,7 +1126,48 @@ def artist_names_similar(first: str, second: str) -> bool:
return SequenceMatcher(None, left, right).ratio() >= 0.78
def find_duplicate_concerts(user, artist: str, start_date: str):
def inferred_band_names(title: str, event_type: str = "concert") -> list[str]:
if event_type != "concert":
return []
primary = re.split(r"\s+(?:-||—)\s+|:\s+", title.strip(), maxsplit=1)[0]
return [part.strip() for part in re.split(r"\s+(?:\+|/|&|and|und)\s+", primary) if part.strip()]
def parse_band_names(value: str, fallback_title: str = "", event_type: str = "concert") -> list[dict]:
names = [line.strip() for line in (value or "").splitlines() if line.strip()]
if not names:
names = inferred_band_names(fallback_title, event_type)
bands = []
seen = set()
for name in names[:30]:
key = normalize_artist_name(name)[:255]
if key and key not in seen:
seen.add(key)
bands.append({"key": key, "name": name[:255]})
return bands
def load_concert_bands(concert_id: int, title: str = "", event_type: str = "concert") -> list[dict]:
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"SELECT band_key, display_name FROM concert_bands WHERE concert_id = %s ORDER BY position, display_name",
(concert_id,),
)
bands = [{"key": row[0], "name": row[1]} for row in cursor.fetchall()]
return bands or parse_band_names("", title, event_type)
def replace_concert_bands(cursor, concert_id: int, bands: list[dict]):
cursor.execute("DELETE FROM concert_bands WHERE concert_id = %s", (concert_id,))
for position, band in enumerate(bands):
cursor.execute(
"INSERT INTO concert_bands (concert_id, band_key, display_name, position) VALUES (%s, %s, %s, %s)",
(concert_id, band["key"], band["name"], position),
)
def find_duplicate_concerts(user, artist: str, start_date: str, band_names: str = ""):
try:
concert_date = datetime.strptime(start_date[:10], "%Y-%m-%d").date()
except (TypeError, ValueError):
@@ -1120,6 +1199,15 @@ def find_duplicate_concerts(user, artist: str, start_date: str):
(concert_date, user["id"], user["is_admin"], user["id"], user["id"], user["id"]),
)
rows = cursor.fetchall()
row_ids = [row[0] for row in rows]
cursor.execute(
"SELECT concert_id, band_key, display_name FROM concert_bands WHERE concert_id = ANY(%s) ORDER BY position",
(row_ids or [0],),
)
stored_bands = {}
for concert_id, band_key, display_name in cursor.fetchall():
stored_bands.setdefault(concert_id, []).append({"key": band_key, "name": display_name})
submitted_bands = parse_band_names(band_names, artist, "concert")
return [
{
"id": row[0],
@@ -1130,7 +1218,11 @@ def find_duplicate_concerts(user, artist: str, start_date: str):
"venue": ", ".join(part for part in (row[4], row[5]) if part),
}
for row in rows
if artist_names_similar(artist, row[1])
if any(
artist_names_similar(submitted["name"], existing["name"])
for submitted in submitted_bands
for existing in (stored_bands.get(row[0]) or parse_band_names("", row[1], row[3]))
) or artist_names_similar(artist, row[1])
]
@@ -2626,6 +2718,22 @@ def export_profile_data(request: Request):
)
photos = cursor.fetchall()
cursor.execute(
"SELECT band_key, display_name, created_at FROM followed_bands WHERE user_id = %s ORDER BY created_at",
(user_id,),
)
followed_bands = cursor.fetchall()
cursor.execute(
"SELECT venue_id, created_at FROM followed_venues WHERE user_id = %s ORDER BY created_at",
(user_id,),
)
followed_venues = cursor.fetchall()
cursor.execute(
"SELECT concert_id, rating, favorite_song, notes, created_at, updated_at FROM concert_diary WHERE user_id = %s ORDER BY updated_at",
(user_id,),
)
diary_entries = cursor.fetchall()
cursor.execute(
"""
SELECT badge_code, awarded_at FROM user_badges
@@ -2659,6 +2767,9 @@ def export_profile_data(request: Request):
"event_invitations": rows_to_dicts(invitations, ("concert_id", "invited_by", "viewed_at", "created_at")),
"comments": rows_to_dicts(comments, ("id", "concert_id", "body", "created_at")),
"photos": rows_to_dicts(photos, ("id", "concert_id", "path", "created_at")),
"followed_bands": rows_to_dicts(followed_bands, ("band_key", "display_name", "created_at")),
"followed_venues": rows_to_dicts(followed_venues, ("venue_id", "created_at")),
"concert_diary": rows_to_dicts(diary_entries, ("concert_id", "rating", "favorite_song", "notes", "created_at", "updated_at")),
"badges": rows_to_dicts(badges, ("badge_code", "awarded_at")),
}
filename = re.sub(r"[^A-Za-z0-9_-]", "_", user["username"])
@@ -3074,11 +3185,219 @@ def new_concert(request: Request):
@app.get("/api/concerts/duplicates")
def duplicate_concerts(request: Request, artist: str = "", start_date: str = ""):
def duplicate_concerts(request: Request, artist: str = "", start_date: str = "", band_names: str = ""):
user = get_current_user(request)
if len(artist.strip()) < 2 or len(artist) > 300:
return JSONResponse({"matches": []})
return JSONResponse({"matches": find_duplicate_concerts(user, artist, start_date)})
return JSONResponse({"matches": find_duplicate_concerts(user, artist, start_date, band_names)})
@app.get("/following", response_class=HTMLResponse)
def following_page(request: Request):
user = get_current_user(request)
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"SELECT band_key, display_name FROM followed_bands WHERE user_id = %s ORDER BY display_name",
(user["id"],),
)
followed_bands = [{"key": row[0], "name": row[1]} for row in cursor.fetchall()]
cursor.execute(
"""
SELECT v.id, v.name, COALESCE(v.city, '') FROM followed_venues fv
JOIN venues v ON v.id = fv.venue_id
WHERE fv.user_id = %s ORDER BY v.name, v.city
""",
(user["id"],),
)
followed_venues = [{"id": row[0], "name": row[1], "city": row[2]} for row in cursor.fetchall()]
cursor.execute(
"""
SELECT c.id, c.artist, c.start_datetime, c.venue_id,
COALESCE(v.name, ''), COALESCE(v.city, ''), c.visibility, c.created_by,
c.event_type
FROM concerts c LEFT JOIN venues v ON v.id = c.venue_id
WHERE COALESCE(c.end_datetime, c.start_datetime) >= CURRENT_TIMESTAMP
AND (c.visibility = 'public' OR c.created_by = %s OR %s
OR EXISTS (SELECT 1 FROM event_invitations ei WHERE ei.concert_id = c.id AND ei.user_id = %s)
OR (c.visibility = 'friends' AND EXISTS (
SELECT 1 FROM friendships f WHERE f.status = 'accepted'
AND ((f.requester_id = c.created_by AND f.addressee_id = %s)
OR (f.addressee_id = c.created_by AND f.requester_id = %s)))))
ORDER BY c.start_datetime
""",
(user["id"], user["is_admin"], user["id"], user["id"], user["id"]),
)
candidates = cursor.fetchall()
candidate_ids = [row[0] for row in candidates]
cursor.execute(
"SELECT concert_id, band_key, display_name FROM concert_bands WHERE concert_id = ANY(%s) ORDER BY position",
(candidate_ids or [0],),
)
candidate_bands = {}
for concert_id, band_key, display_name in cursor.fetchall():
candidate_bands.setdefault(concert_id, []).append({"key": band_key, "name": display_name})
band_names = [band["name"] for band in followed_bands]
venue_ids = {venue["id"] for venue in followed_venues}
events = [
{
"id": row[0], "artist": row[1], "date": row[2].strftime("%d.%m.%Y"),
"time": row[2].strftime("%H:%M"), "venue": ", ".join(filter(None, (row[4], row[5]))),
"matched_band": any(
artist_names_similar(event_band["name"], followed_band)
for event_band in (candidate_bands.get(row[0]) or parse_band_names("", row[1], row[8]))
for followed_band in band_names
),
"matched_venue": row[3] in venue_ids,
}
for row in candidates
if row[3] in venue_ids or any(
artist_names_similar(event_band["name"], followed_band)
for event_band in (candidate_bands.get(row[0]) or parse_band_names("", row[1], row[8]))
for followed_band in band_names
)
]
return templates.get_template("following.html").render(
user=user, followed_bands=followed_bands, followed_venues=followed_venues, events=events
)
@app.post("/following/bands/remove")
def remove_followed_band(request: Request, band_key: str = Form(...)):
user = get_current_user(request)
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute("DELETE FROM followed_bands WHERE user_id = %s AND band_key = %s", (user["id"], band_key[:255]))
connection.commit()
return RedirectResponse("/following", status_code=303)
@app.post("/following/venues/remove")
def remove_followed_venue(request: Request, venue_id: int = Form(...)):
user = get_current_user(request)
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute("DELETE FROM followed_venues WHERE user_id = %s AND venue_id = %s", (user["id"], venue_id))
connection.commit()
return RedirectResponse("/following", status_code=303)
@app.post("/concerts/{concert_id}/follow-band")
def follow_band(request: Request, concert_id: int, band_key: str = Form(...), action: str = Form("follow")):
user = get_current_user(request)
concert = load_concert(concert_id)
if not concert or not can_view_event(user, concert):
return HTMLResponse("Veranstaltung nicht gefunden.", status_code=404)
bands = load_concert_bands(concert_id, concert["artist"], concert["event_type"])
selected_band = next((band for band in bands if band["key"] == band_key), None)
if not selected_band:
return HTMLResponse("Band nicht gefunden.", status_code=404)
with get_db_connection() as connection:
with connection.cursor() as cursor:
if action == "unfollow":
cursor.execute("DELETE FROM followed_bands WHERE user_id = %s AND band_key = %s", (user["id"], band_key))
elif action == "follow":
cursor.execute(
"INSERT INTO followed_bands (user_id, band_key, display_name) VALUES (%s, %s, %s) ON CONFLICT (user_id, band_key) DO UPDATE SET display_name = EXCLUDED.display_name",
(user["id"], band_key, selected_band["name"]),
)
else:
return HTMLResponse("Ungültige Aktion.", status_code=400)
connection.commit()
return RedirectResponse(f"/concerts/{concert_id}#following", status_code=303)
@app.post("/concerts/{concert_id}/follow-venue")
def follow_venue(request: Request, concert_id: int, action: str = Form("follow")):
user = get_current_user(request)
concert = load_concert(concert_id)
if not concert or not can_view_event(user, concert):
return HTMLResponse("Veranstaltung nicht gefunden.", status_code=404)
venue_id = concert["venue"]["id"]
if not venue_id:
return HTMLResponse("Diese Veranstaltung hat keine zugeordnete Location.", status_code=400)
with get_db_connection() as connection:
with connection.cursor() as cursor:
if action == "unfollow":
cursor.execute("DELETE FROM followed_venues WHERE user_id = %s AND venue_id = %s", (user["id"], venue_id))
elif action == "follow":
cursor.execute("INSERT INTO followed_venues (user_id, venue_id) VALUES (%s, %s) ON CONFLICT DO NOTHING", (user["id"], venue_id))
else:
return HTMLResponse("Ungültige Aktion.", status_code=400)
connection.commit()
return RedirectResponse(f"/concerts/{concert_id}#following", status_code=303)
@app.get("/diary", response_class=HTMLResponse)
def diary_page(request: Request):
user = get_current_user(request)
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"""
SELECT d.concert_id, c.artist, c.start_datetime, d.rating,
COALESCE(d.favorite_song, ''), COALESCE(d.notes, ''),
COALESCE(v.name, ''), COALESCE(v.city, '')
FROM concert_diary d JOIN concerts c ON c.id = d.concert_id
LEFT JOIN venues v ON v.id = c.venue_id
WHERE d.user_id = %s ORDER BY c.start_datetime DESC
""",
(user["id"],),
)
entries = [
{"concert_id": row[0], "artist": row[1], "date": row[2].strftime("%d.%m.%Y"),
"rating": row[3], "favorite_song": row[4], "notes": row[5],
"venue": ", ".join(filter(None, (row[6], row[7])))}
for row in cursor.fetchall()
]
return templates.get_template("diary.html").render(user=user, entries=entries)
@app.post("/concerts/{concert_id}/diary")
def save_diary_entry(
request: Request, concert_id: int, rating: int = Form(...),
favorite_song: str = Form(""), notes: str = Form("")
):
user = get_current_user(request)
concert = load_concert(concert_id)
if not concert or not can_view_event(user, concert):
return HTMLResponse("Veranstaltung nicht gefunden.", status_code=404)
if not concert["is_past"] or rating not in range(1, 6):
return HTMLResponse("Das Tagebuch ist nur für vergangene Konzerte mit einer Bewertung von 1 bis 5 verfügbar.", status_code=400)
favorite_song = favorite_song.strip()
notes = notes.strip()
if len(favorite_song) > 255 or len(notes) > 5000:
return HTMLResponse("Tagebucheintrag ist zu lang.", status_code=400)
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"SELECT 1 FROM concert_attendance WHERE concert_id = %s AND user_id = %s AND status = 'attending'",
(concert_id, user["id"]),
)
if not cursor.fetchone():
return HTMLResponse("Ein Tagebucheintrag ist nur für besuchte Konzerte möglich.", status_code=403)
cursor.execute(
"""
INSERT INTO concert_diary (user_id, concert_id, rating, favorite_song, notes)
VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (user_id, concert_id) DO UPDATE SET
rating = EXCLUDED.rating, favorite_song = EXCLUDED.favorite_song,
notes = EXCLUDED.notes, updated_at = CURRENT_TIMESTAMP
""",
(user["id"], concert_id, rating, favorite_song or None, notes or None),
)
connection.commit()
return RedirectResponse(f"/concerts/{concert_id}#diary", status_code=303)
@app.post("/concerts/{concert_id}/diary/delete")
def delete_diary_entry(request: Request, concert_id: int):
user = get_current_user(request)
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute("DELETE FROM concert_diary WHERE user_id = %s AND concert_id = %s", (user["id"], concert_id))
connection.commit()
return RedirectResponse(f"/concerts/{concert_id}#diary", status_code=303)
# ============================================================
@@ -3199,6 +3518,25 @@ def concert_detail(request: Request, concert_id: int):
(concert_id, user["id"], user["id"]),
)
attendance_rows = cursor.fetchall()
cursor.execute(
"SELECT band_key FROM followed_bands WHERE user_id = %s",
(user["id"],),
)
followed_band_keys = {row[0] for row in cursor.fetchall()}
venue_id = concert["venue"]["id"]
if venue_id:
cursor.execute(
"SELECT EXISTS (SELECT 1 FROM followed_venues WHERE user_id = %s AND venue_id = %s)",
(user["id"], venue_id),
)
follows_venue = cursor.fetchone()[0]
else:
follows_venue = False
cursor.execute(
"SELECT rating, COALESCE(favorite_song, ''), COALESCE(notes, '') FROM concert_diary WHERE user_id = %s AND concert_id = %s",
(user["id"], concert_id),
)
diary_row = cursor.fetchone()
comments = [
{
@@ -3238,6 +3576,12 @@ def concert_detail(request: Request, concert_id: int):
(item["status"] for item in attendance if item["user_id"] == user["id"]),
None,
)
diary_entry = {
"rating": diary_row[0], "favorite_song": diary_row[1], "notes": diary_row[2]
} if diary_row else None
concert_bands = load_concert_bands(concert_id, concert["artist"], concert["event_type"])
for band in concert_bands:
band["is_following"] = band["key"] in followed_band_keys
template = templates.get_template("concert_detail.html")
return template.render(
@@ -3256,6 +3600,10 @@ def concert_detail(request: Request, concert_id: int):
ticket_offer_count=len(ticket_offers),
maybe_users=maybe_users,
maybe_count=len(maybe_users),
concert_bands=concert_bands,
follows_venue=follows_venue,
diary_entry=diary_entry,
can_write_diary=concert["is_past"] and current_attendance == "attending",
)
@@ -3267,6 +3615,7 @@ def concert_detail(request: Request, concert_id: int):
async def create_concert(
request: Request,
artist: str = Form(...),
band_names: str = Form(""),
event_type: str = Form("concert"),
parent_event_id: str = Form(""),
visibility: str = Form("public"),
@@ -3298,6 +3647,7 @@ async def create_concert(
return HTMLResponse("<h1>Der Titel oder Künstlername muss zwischen 2 und 255 Zeichen lang sein.</h1>", status_code=400)
if event_type not in EVENT_TYPES:
return HTMLResponse("<h1>Ungültige Veranstaltungskategorie.</h1>", status_code=400)
parsed_bands = parse_band_names(band_names, artist, event_type)
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":
@@ -3306,7 +3656,7 @@ async def create_concert(
return HTMLResponse("<h1>Ungültige Sichtbarkeit.</h1>", status_code=400)
if event_type != "other":
visibility = "public"
duplicate_matches = find_duplicate_concerts(user, artist, start_datetime)
duplicate_matches = find_duplicate_concerts(user, artist, start_datetime, band_names)
if duplicate_matches and not duplicate_confirmed:
match_items = "".join(
f'<li><a href="/concerts/{match["id"]}">{escape(match["artist"])} · {match["date"]} {match["time"]}</a></li>'
@@ -3396,6 +3746,7 @@ async def create_concert(
)
concert_id = cursor.fetchone()[0]
replace_concert_bands(cursor, concert_id, parsed_bands)
if visibility == "private" and invited_user_ids:
cursor.execute(
"""
@@ -3456,6 +3807,7 @@ def edit_concert_page(request: Request, concert_id: int):
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),
band_names="\n".join(band["name"] for band in load_concert_bands(concert_id, concert["artist"], concert["event_type"])),
)
@@ -3464,6 +3816,7 @@ async def edit_concert(
request: Request,
concert_id: int,
artist: str = Form(""),
band_names: str = Form(""),
event_type: str = Form("concert"),
parent_event_id: str = Form(""),
visibility: str = Form("public"),
@@ -3637,6 +3990,10 @@ async def edit_concert(
)
elif can_manage_event_access(user, concert):
cursor.execute("DELETE FROM event_invitations WHERE concert_id = %s", (concert_id,))
if can_edit_title(user, concert):
replace_concert_bands(
cursor, concert_id, parse_band_names(band_names, next_artist, next_event_type)
)
connection.commit()
return RedirectResponse(