Add band and venue follows with concert diary
This commit is contained in:
+362
-5
@@ -395,6 +395,44 @@ def ensure_schema():
|
|||||||
CREATE INDEX IF NOT EXISTS idx_direct_messages_unread
|
CREATE INDEX IF NOT EXISTS idx_direct_messages_unread
|
||||||
ON direct_messages (recipient_id, read_at)
|
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:
|
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
|
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:
|
try:
|
||||||
concert_date = datetime.strptime(start_date[:10], "%Y-%m-%d").date()
|
concert_date = datetime.strptime(start_date[:10], "%Y-%m-%d").date()
|
||||||
except (TypeError, ValueError):
|
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"]),
|
(concert_date, user["id"], user["is_admin"], user["id"], user["id"], user["id"]),
|
||||||
)
|
)
|
||||||
rows = cursor.fetchall()
|
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 [
|
return [
|
||||||
{
|
{
|
||||||
"id": row[0],
|
"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),
|
"venue": ", ".join(part for part in (row[4], row[5]) if part),
|
||||||
}
|
}
|
||||||
for row in rows
|
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()
|
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(
|
cursor.execute(
|
||||||
"""
|
"""
|
||||||
SELECT badge_code, awarded_at FROM user_badges
|
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")),
|
"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")),
|
"comments": rows_to_dicts(comments, ("id", "concert_id", "body", "created_at")),
|
||||||
"photos": rows_to_dicts(photos, ("id", "concert_id", "path", "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")),
|
"badges": rows_to_dicts(badges, ("badge_code", "awarded_at")),
|
||||||
}
|
}
|
||||||
filename = re.sub(r"[^A-Za-z0-9_-]", "_", user["username"])
|
filename = re.sub(r"[^A-Za-z0-9_-]", "_", user["username"])
|
||||||
@@ -3074,11 +3185,219 @@ def new_concert(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/concerts/duplicates")
|
@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)
|
user = get_current_user(request)
|
||||||
if len(artist.strip()) < 2 or len(artist) > 300:
|
if len(artist.strip()) < 2 or len(artist) > 300:
|
||||||
return JSONResponse({"matches": []})
|
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"]),
|
(concert_id, user["id"], user["id"]),
|
||||||
)
|
)
|
||||||
attendance_rows = cursor.fetchall()
|
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 = [
|
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"]),
|
(item["status"] for item in attendance if item["user_id"] == user["id"]),
|
||||||
None,
|
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")
|
template = templates.get_template("concert_detail.html")
|
||||||
return template.render(
|
return template.render(
|
||||||
@@ -3256,6 +3600,10 @@ def concert_detail(request: Request, concert_id: int):
|
|||||||
ticket_offer_count=len(ticket_offers),
|
ticket_offer_count=len(ticket_offers),
|
||||||
maybe_users=maybe_users,
|
maybe_users=maybe_users,
|
||||||
maybe_count=len(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(
|
async def create_concert(
|
||||||
request: Request,
|
request: Request,
|
||||||
artist: str = Form(...),
|
artist: str = Form(...),
|
||||||
|
band_names: 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"),
|
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)
|
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:
|
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)
|
||||||
|
parsed_bands = parse_band_names(band_names, artist, event_type)
|
||||||
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":
|
||||||
@@ -3306,7 +3656,7 @@ async def create_concert(
|
|||||||
return HTMLResponse("<h1>Ungültige Sichtbarkeit.</h1>", status_code=400)
|
return HTMLResponse("<h1>Ungültige Sichtbarkeit.</h1>", status_code=400)
|
||||||
if event_type != "other":
|
if event_type != "other":
|
||||||
visibility = "public"
|
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:
|
if duplicate_matches and not duplicate_confirmed:
|
||||||
match_items = "".join(
|
match_items = "".join(
|
||||||
f'<li><a href="/concerts/{match["id"]}">{escape(match["artist"])} · {match["date"]} {match["time"]}</a></li>'
|
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]
|
concert_id = cursor.fetchone()[0]
|
||||||
|
replace_concert_bands(cursor, concert_id, parsed_bands)
|
||||||
if visibility == "private" and invited_user_ids:
|
if visibility == "private" and invited_user_ids:
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
"""
|
"""
|
||||||
@@ -3456,6 +3807,7 @@ def edit_concert_page(request: Request, concert_id: int):
|
|||||||
invitable_users=get_invitable_users(user["id"]),
|
invitable_users=get_invitable_users(user["id"]),
|
||||||
invited_user_ids=get_event_invitee_ids(concert_id),
|
invited_user_ids=get_event_invitee_ids(concert_id),
|
||||||
can_manage_access=can_manage_event_access(user, concert),
|
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,
|
request: Request,
|
||||||
concert_id: int,
|
concert_id: int,
|
||||||
artist: str = Form(""),
|
artist: str = Form(""),
|
||||||
|
band_names: 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"),
|
visibility: str = Form("public"),
|
||||||
@@ -3637,6 +3990,10 @@ async def edit_concert(
|
|||||||
)
|
)
|
||||||
elif can_manage_event_access(user, concert):
|
elif can_manage_event_access(user, concert):
|
||||||
cursor.execute("DELETE FROM event_invitations WHERE concert_id = %s", (concert_id,))
|
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()
|
connection.commit()
|
||||||
|
|
||||||
return RedirectResponse(
|
return RedirectResponse(
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
<summary>☠ {{ user.display_name }}{% if user.notification_count %} <span class="metal-notification" title="{{ user.notification_count }} neue Nachricht(en) oder Anfrage(n)" aria-label="Neue Benachrichtigungen">🤘<b>{{ user.notification_count }}</b></span>{% endif %}</summary>
|
<summary>☠ {{ user.display_name }}{% if user.notification_count %} <span class="metal-notification" title="{{ user.notification_count }} neue Nachricht(en) oder Anfrage(n)" aria-label="Neue Benachrichtigungen">🤘<b>{{ user.notification_count }}</b></span>{% endif %}</summary>
|
||||||
<div class="user-menu-panel">
|
<div class="user-menu-panel">
|
||||||
<a href="/profile">Mein Profil</a>
|
<a href="/profile">Mein Profil</a>
|
||||||
|
<a href="/following">Gefolgte Bands & Locations</a>
|
||||||
|
<a href="/diary">Konzerttagebuch</a>
|
||||||
<a href="/messages">Nachrichten{% if user.notification_count %} <span class="metal-notification" title="Neue Nachrichten oder Anfragen" aria-label="Neue Nachrichten oder Anfragen">🤘<b>{{ user.notification_count }}</b></span>{% endif %}</a>
|
<a href="/messages">Nachrichten{% if user.notification_count %} <span class="metal-notification" title="Neue Nachrichten oder Anfragen" aria-label="Neue Nachrichten oder Anfragen">🤘<b>{{ user.notification_count }}</b></span>{% endif %}</a>
|
||||||
{% if user.is_admin %}<a href="/admin">⚙️ Verwaltung</a>{% endif %}
|
{% if user.is_admin %}<a href="/admin">⚙️ Verwaltung</a>{% endif %}
|
||||||
<a href="/datenschutz">Datenschutz</a>
|
<a href="/datenschutz">Datenschutz</a>
|
||||||
|
|||||||
@@ -25,6 +25,14 @@
|
|||||||
.flyer-source { display: block; width: 100%; flex: 0 0 100%; margin: 10px 0 0; color: #a8a29e; font-size: .85rem; text-align: center; }
|
.flyer-source { display: block; width: 100%; flex: 0 0 100%; margin: 10px 0 0; color: #a8a29e; font-size: .85rem; text-align: center; }
|
||||||
.flyer-source a { color: #f87171; text-decoration: underline; }
|
.flyer-source a { color: #f87171; text-decoration: underline; }
|
||||||
.flyer-disclaimer { display: block; margin-top: 4px; font-size: .75rem; }
|
.flyer-disclaimer { display: block; margin-top: 4px; font-size: .75rem; }
|
||||||
|
.follow-actions { display:flex; flex-wrap:wrap; justify-content:center; gap:8px; margin:0 0 20px; }
|
||||||
|
.follow-actions form { margin:0; }
|
||||||
|
.follow-button { padding:8px 11px; color:#fca5a5; background:#171212; border:1px solid var(--border); border-radius:9px; cursor:pointer; }
|
||||||
|
.follow-button.is-following { color:#fff; border-color:#dc2626; background:#7f1d1d; }
|
||||||
|
.diary-section { margin-top:26px; padding-top:22px; border-top:1px solid var(--border); }
|
||||||
|
.diary-form { max-width:none; }
|
||||||
|
.diary-form select, .diary-form input, .diary-form textarea { width:100%; }
|
||||||
|
.diary-form textarea { min-height:130px; resize:vertical; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
</head>
|
</head>
|
||||||
@@ -70,6 +78,19 @@
|
|||||||
{{ concert.artist }}
|
{{ concert.artist }}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
|
<div class="follow-actions" id="following">
|
||||||
|
{% for band in concert_bands %}<form method="post" action="/concerts/{{ concert.id }}/follow-band">
|
||||||
|
<input type="hidden" name="band_key" value="{{ band.key }}">
|
||||||
|
<input type="hidden" name="action" value="{% if band.is_following %}unfollow{% else %}follow{% endif %}">
|
||||||
|
<button class="follow-button {% if band.is_following %}is-following{% endif %}" type="submit">{% if band.is_following %}★ {{ band.name }} gefolgt{% else %}☆ {{ band.name }} folgen{% endif %}</button>
|
||||||
|
</form>{% endfor %}
|
||||||
|
{% if concert.venue.id %}<form method="post" action="/concerts/{{ concert.id }}/follow-venue">
|
||||||
|
<input type="hidden" name="action" value="{% if follows_venue %}unfollow{% else %}follow{% endif %}">
|
||||||
|
<button class="follow-button {% if follows_venue %}is-following{% endif %}" type="submit">{% if follows_venue %}★ Location gefolgt{% else %}☆ Location folgen{% endif %}</button>
|
||||||
|
</form>{% endif %}
|
||||||
|
<a class="follow-button" href="/following">Meine gefolgten Inhalte</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="concert-info">
|
<div class="concert-info">
|
||||||
|
|
||||||
@@ -366,6 +387,29 @@
|
|||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if concert.is_past %}
|
||||||
|
<section class="diary-section" id="diary">
|
||||||
|
<h2>📓 Mein Konzerttagebuch</h2>
|
||||||
|
{% if can_write_diary %}
|
||||||
|
<p>Dieser Eintrag ist privat und nur für dich sichtbar.</p>
|
||||||
|
<form class="diary-form" method="post" action="/concerts/{{ concert.id }}/diary">
|
||||||
|
<label for="diary-rating">Bewertung</label>
|
||||||
|
<select id="diary-rating" name="rating" required>
|
||||||
|
{% for value in range(1, 6) %}<option value="{{ value }}" {% if diary_entry and diary_entry.rating == value %}selected{% endif %}>{{ value }} von 5 Sternen</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
<label for="favorite-song">Lieblingssong <small>(optional)</small></label>
|
||||||
|
<input id="favorite-song" name="favorite_song" maxlength="255" value="{{ diary_entry.favorite_song if diary_entry else '' }}">
|
||||||
|
<label for="diary-notes">Erinnerungen <small>(optional)</small></label>
|
||||||
|
<textarea id="diary-notes" name="notes" maxlength="5000" placeholder="Was ist dir von diesem Abend geblieben?">{{ diary_entry.notes if diary_entry else '' }}</textarea>
|
||||||
|
<button class="attendance-option" type="submit">{% if diary_entry %}Eintrag aktualisieren{% else %}Im Tagebuch speichern{% endif %}</button>
|
||||||
|
</form>
|
||||||
|
{% if diary_entry %}<form method="post" action="/concerts/{{ concert.id }}/diary/delete" onsubmit="return confirm('Tagebucheintrag wirklich löschen?');"><button class="button button-secondary" type="submit">Tagebucheintrag löschen</button></form>{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<p>Das private Tagebuch ist verfügbar, wenn du dieses vergangene Konzert als „Zugesagt“ markiert hattest.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<section class="comments-section" id="comments">
|
<section class="comments-section" id="comments">
|
||||||
|
|
||||||
<h2>💬 Kommentare</h2>
|
<h2>💬 Kommentare</h2>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<h2>Verantwortlicher</h2>
|
<h2>Verantwortlicher</h2>
|
||||||
<p>Kai Piekny, Fleyerstr. 33, 58097 Hagen<br><a href="mailto:konzert@pinguholic.de">konzert@pinguholic.de</a></p>
|
<p>Kai Piekny, Fleyerstr. 33, 58097 Hagen<br><a href="mailto:konzert@pinguholic.de">konzert@pinguholic.de</a></p>
|
||||||
<h2>Welche Daten werden gespeichert?</h2>
|
<h2>Welche Daten werden gespeichert?</h2>
|
||||||
<p>Für die Nutzung werden insbesondere Benutzername, E-Mail-Adresse, Passwort-Hash, Anzeigename, optionale Profil- und Instagram-Angaben, Profilbild, Freundschaften, Nachrichten, Veranstaltungsteilnahmen, Kommentare, Fotos, Patches sowie von dir angelegte Veranstaltungen gespeichert.</p>
|
<p>Für die Nutzung werden insbesondere Benutzername, E-Mail-Adresse, Passwort-Hash, Anzeigename, optionale Profil- und Instagram-Angaben, Profilbild, Freundschaften, Nachrichten, Veranstaltungsteilnahmen, gefolgte Bands und Locations, private Konzerttagebuch-Einträge, Kommentare, Fotos, Patches sowie von dir angelegte Veranstaltungen gespeichert.</p>
|
||||||
<h2>Wofür werden sie verwendet?</h2>
|
<h2>Wofür werden sie verwendet?</h2>
|
||||||
<p>Die Daten werden ausschließlich für Anmeldung, Kontoverwaltung, Veranstaltungsfunktionen, Freundschaften, Nachrichten, Benachrichtigungen und die von dir gewählten Sichtbarkeitseinstellungen verarbeitet.</p>
|
<p>Die Daten werden ausschließlich für Anmeldung, Kontoverwaltung, Veranstaltungsfunktionen, Freundschaften, Nachrichten, Benachrichtigungen und die von dir gewählten Sichtbarkeitseinstellungen verarbeitet.</p>
|
||||||
<h2>Rechtsgrundlage und Speicherdauer</h2>
|
<h2>Rechtsgrundlage und Speicherdauer</h2>
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<!DOCTYPE html><html lang="de"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Konzerttagebuch · MetalCircle</title><link rel="stylesheet" href="/static/css/style.css"><style>
|
||||||
|
.diary-list{display:grid;gap:14px}.diary-entry{padding:18px;background:linear-gradient(145deg,#101010,#211111);border:1px solid var(--border);border-left:3px solid #b91c1c;border-radius:12px}.diary-entry h2{margin:0 0 5px}.diary-meta{color:var(--muted)}.diary-rating{color:#fbbf24;font-size:1.2rem}.diary-notes{white-space:pre-wrap}.diary-song{color:#fca5a5}
|
||||||
|
</style></head><body><header><div class="header-inner"><a href="/" class="logo"><img class="brand-logo" src="/static/images/metalcircle-full.png" alt="MetalCircle"></a>{% include '_user_menu.html' %}</div></header><main>
|
||||||
|
<div class="page-title"><h1>📓 Konzerttagebuch</h1><p>Deine privaten Erinnerungen an besuchte Konzerte.</p></div><div class="diary-list">
|
||||||
|
{% for entry in entries %}<article class="diary-entry"><h2><a href="/concerts/{{ entry.concert_id }}">{{ entry.artist }}</a></h2><p class="diary-meta">{{ entry.date }}{% if entry.venue %} · {{ entry.venue }}{% endif %}</p><div class="diary-rating" aria-label="{{ entry.rating }} von 5 Sternen">{% for _ in range(entry.rating) %}★{% endfor %}{% for _ in range(5-entry.rating) %}☆{% endfor %}</div>{% if entry.favorite_song %}<p class="diary-song">🎵 Lieblingssong: {{ entry.favorite_song }}</p>{% endif %}{% if entry.notes %}<p class="diary-notes">{{ entry.notes }}</p>{% endif %}</article>
|
||||||
|
{% else %}<section class="empty"><p>Noch keine Einträge. Markiere ein vergangenes Konzert als besucht und halte dort deine Erinnerung fest.</p></section>{% endfor %}</div>
|
||||||
|
</main></body></html>
|
||||||
@@ -60,6 +60,12 @@
|
|||||||
<input type="text" name="artist" value="{{ concert.artist }}" {% if not can_edit_title %}disabled{% endif %} required>
|
<input type="text" name="artist" value="{{ concert.artist }}" {% if not can_edit_title %}disabled{% endif %} required>
|
||||||
</label>
|
</label>
|
||||||
</p>
|
</p>
|
||||||
|
<p>
|
||||||
|
<label>Bands / Line-up <small>(eine Band pro Zeile)</small><br>
|
||||||
|
<textarea name="band_names" rows="3" maxlength="4000" {% if not can_edit_title %}disabled{% endif %}>{{ band_names }}</textarea>
|
||||||
|
</label>
|
||||||
|
<small>Diese Namen werden einzeln für die Folgen-Funktion verwendet.</small>
|
||||||
|
</p>
|
||||||
{% if can_edit_details %}
|
{% if can_edit_details %}
|
||||||
<p>
|
<p>
|
||||||
<label>Veranstaltungsort<br>
|
<label>Veranstaltungsort<br>
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<!DOCTYPE html><html lang="de"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Gefolgt · MetalCircle</title><link rel="stylesheet" href="/static/css/style.css"><style>
|
||||||
|
.follow-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:16px}.follow-card{padding:18px;background:var(--surface);border:1px solid var(--border);border-radius:12px}.follow-list{display:flex;flex-wrap:wrap;gap:8px;padding:0;list-style:none}.follow-list li{display:flex;align-items:center;gap:6px;padding:7px 10px;background:#171212;border:1px solid var(--border);border-radius:999px}.follow-list form{margin:0}.follow-remove{padding:0;color:#fca5a5;background:none;border:0;cursor:pointer;font-size:1rem}.follow-events{display:grid;gap:10px}.follow-event{display:block;padding:14px;background:#0b0b0b;border:1px solid var(--border);border-radius:10px}.follow-event:hover{border-color:#ef4444}.follow-reason{color:var(--muted);font-size:.82rem}
|
||||||
|
</style></head><body><header><div class="header-inner"><a href="/" class="logo"><img class="brand-logo" src="/static/images/metalcircle-full.png" alt="MetalCircle"></a>{% include '_user_menu.html' %}</div></header><main>
|
||||||
|
<div class="page-title"><h1>⭐ Gefolgt</h1><p>Bands, Locations und passende kommende Veranstaltungen.</p></div>
|
||||||
|
<div class="follow-grid"><section class="follow-card"><h2>Bands</h2><ul class="follow-list">{% for band in followed_bands %}<li>{{ band.name }}<form method="post" action="/following/bands/remove"><input type="hidden" name="band_key" value="{{ band.key }}"><button class="follow-remove" type="submit" title="Nicht mehr folgen" aria-label="{{ band.name }} nicht mehr folgen">×</button></form></li>{% else %}<li>Noch keine Band gefolgt.</li>{% endfor %}</ul></section>
|
||||||
|
<section class="follow-card"><h2>Locations</h2><ul class="follow-list">{% for venue in followed_venues %}<li>{{ venue.name }}{% if venue.city %}, {{ venue.city }}{% endif %}<form method="post" action="/following/venues/remove"><input type="hidden" name="venue_id" value="{{ venue.id }}"><button class="follow-remove" type="submit" title="Nicht mehr folgen" aria-label="{{ venue.name }} nicht mehr folgen">×</button></form></li>{% else %}<li>Noch keiner Location gefolgt.</li>{% endfor %}</ul></section></div>
|
||||||
|
<section class="follow-card" style="margin-top:18px"><h2>Kommende Treffer</h2><div class="follow-events">{% for event in events %}<a class="follow-event" href="/concerts/{{ event.id }}"><strong>{{ event.artist }}</strong><br>{{ event.date }} · {{ event.time }}{% if event.venue %} · {{ event.venue }}{% endif %}<div class="follow-reason">{% if event.matched_band %}Band{% endif %}{% if event.matched_band and event.matched_venue %} und {% endif %}{% if event.matched_venue %}Location{% endif %} gefolgt</div></a>{% else %}<p>Momentan gibt es keine kommenden Treffer.</p>{% endfor %}</div></section>
|
||||||
|
</main></body></html>
|
||||||
@@ -170,6 +170,13 @@
|
|||||||
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<label for="band-names">Bands / Line-up <small>(eine Band pro Zeile)</small><br>
|
||||||
|
<textarea id="band-names" name="band_names" rows="3" maxlength="4000" placeholder="dArtagnan Supportband"></textarea>
|
||||||
|
</label>
|
||||||
|
<small>Der Veranstaltungstitel bleibt frei. Diese Namen werden einzeln für „Band folgen“ verwendet.</small>
|
||||||
|
</p>
|
||||||
|
|
||||||
<!-- ================================================= -->
|
<!-- ================================================= -->
|
||||||
<!-- Veranstaltungsort -->
|
<!-- Veranstaltungsort -->
|
||||||
<!-- ================================================= -->
|
<!-- ================================================= -->
|
||||||
@@ -895,6 +902,7 @@ flyerInput.addEventListener(
|
|||||||
// Originaldateien verarbeiten.
|
// Originaldateien verarbeiten.
|
||||||
const concertForm = flyerInput.form;
|
const concertForm = flyerInput.form;
|
||||||
const artistInput = document.getElementById("artist");
|
const artistInput = document.getElementById("artist");
|
||||||
|
const bandNamesInput = document.getElementById("band-names");
|
||||||
const startDatetimeInput = document.getElementById("start-datetime");
|
const startDatetimeInput = document.getElementById("start-datetime");
|
||||||
const duplicateWarning = document.getElementById("duplicate-warning");
|
const duplicateWarning = document.getElementById("duplicate-warning");
|
||||||
const duplicateConfirmed = document.getElementById("duplicate-confirmed");
|
const duplicateConfirmed = document.getElementById("duplicate-confirmed");
|
||||||
@@ -934,7 +942,7 @@ async function checkForDuplicates(showWarning = true) {
|
|||||||
if (duplicateRequestController) duplicateRequestController.abort();
|
if (duplicateRequestController) duplicateRequestController.abort();
|
||||||
duplicateRequestController = new AbortController();
|
duplicateRequestController = new AbortController();
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({artist, start_date: startDate});
|
const params = new URLSearchParams({artist, start_date: startDate, band_names: bandNamesInput.value});
|
||||||
const response = await fetch(`/api/concerts/duplicates?${params}`, {
|
const response = await fetch(`/api/concerts/duplicates?${params}`, {
|
||||||
signal: duplicateRequestController.signal
|
signal: duplicateRequestController.signal
|
||||||
});
|
});
|
||||||
@@ -948,7 +956,7 @@ async function checkForDuplicates(showWarning = true) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[artistInput, startDatetimeInput].forEach(input => input.addEventListener("input", () => {
|
[artistInput, bandNamesInput, startDatetimeInput].forEach(input => input.addEventListener("input", () => {
|
||||||
duplicateConfirmed.value = "false";
|
duplicateConfirmed.value = "false";
|
||||||
concertForm.dataset.readyToSubmit = "false";
|
concertForm.dataset.readyToSubmit = "false";
|
||||||
clearTimeout(duplicateTimeout);
|
clearTimeout(duplicateTimeout);
|
||||||
|
|||||||
@@ -193,3 +193,37 @@ CREATE INDEX idx_concert_photos_concert
|
|||||||
|
|
||||||
CREATE INDEX idx_concert_attendance_concert
|
CREATE INDEX idx_concert_attendance_concert
|
||||||
ON concert_attendance(concert_id, status);
|
ON concert_attendance(concert_id, status);
|
||||||
|
|
||||||
|
CREATE TABLE 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 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 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 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)
|
||||||
|
);
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
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)
|
||||||
|
);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
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)
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user