diff --git a/app/feature_schema.py b/app/feature_schema.py
index 6385057..4913213 100644
--- a/app/feature_schema.py
+++ b/app/feature_schema.py
@@ -1,4 +1,4 @@
-"""Startup equivalents of migrations 19–22 (kept in sync by tests)."""
+"""Startup equivalents of migrations 19–23 (kept in sync by tests)."""
FEATURE_SCHEMA = (
'''CREATE TABLE IF NOT EXISTS push_devices (
@@ -55,4 +55,13 @@ FEATURE_SCHEMA = (
ON push_notifications(available_at) WHERE state='pending';''',
'''CREATE UNIQUE INDEX IF NOT EXISTS idx_user_badges_registration_cohort
ON user_badges(user_id) WHERE badge_code IN ('alpha_tester', 'beta_tester', 'early_bird');''',
+ '''CREATE TABLE IF NOT EXISTS followed_users (
+ follower_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ followed_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (follower_id, followed_id),
+ CHECK (follower_id <> followed_id)
+ );
+ CREATE INDEX IF NOT EXISTS idx_followed_users_followed
+ ON followed_users(followed_id, follower_id);''',
)
diff --git a/app/locales/en.json b/app/locales/en.json
index ea4e2e5..e6a634e 100644
--- a/app/locales/en.json
+++ b/app/locales/en.json
@@ -11,6 +11,7 @@
"Neue Nachrichten oder Anfragen": "New messages or requests",
"Konzerttagebuch": "Concert diary",
"Gefolgte Bands & Locations": "Followed bands & venues",
+ "Gefolgte Inhalte": "Followed content",
"⚙️ Verwaltung": "⚙️ Administration",
"Datenschutz": "Privacy policy",
"Impressum": "Legal notice",
@@ -114,6 +115,7 @@
"Verantwortlicher": "Data controller",
"Welche Daten werden gespeichert?": "What data is stored?",
"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.": "Using the platform involves storing your username, email address, password hash, display name, optional profile and Instagram details, profile picture, friendships, messages, event attendance, followed bands and venues, private concert diary entries, comments, photos, patches and events you create.",
+ "Für die Nutzung werden insbesondere Benutzername, E-Mail-Adresse, Passwort-Hash, Anzeigename, optionale Profil- und Instagram-Angaben, Profilbild, Freundschaften, Nachrichten, Veranstaltungsteilnahmen, gefolgte Bands, Locations und Nutzer, private Konzerttagebuch-Einträge, Kommentare, Fotos, Patches sowie von dir angelegte Veranstaltungen gespeichert.": "Using the platform involves storing your username, email address, password hash, display name, optional profile and Instagram details, profile picture, friendships, messages, event attendance, followed bands, venues and users, private concert diary entries, comments, photos, patches and events you create.",
"Wofür werden sie verwendet?": "What is it used for?",
"Die Daten werden ausschließlich für Anmeldung, Kontoverwaltung, Veranstaltungsfunktionen, Freundschaften, Nachrichten, Benachrichtigungen und die von dir gewählten Sichtbarkeitseinstellungen verarbeitet.": "The data is processed exclusively for login, account management, event features, friendships, messages, notifications and your chosen visibility settings.",
"Rechtsgrundlage und Speicherdauer": "Legal basis and retention period",
@@ -184,6 +186,14 @@
"Gefolgt · MetalCircle": "Following · MetalCircle",
"⭐ Gefolgt": "⭐ Following",
"Bands, Locations und passende kommende Veranstaltungen.": "Bands, venues and matching upcoming events.",
+ "Bands, Locations, Personen und passende kommende Veranstaltungen.": "Bands, venues, people and matching upcoming events.",
+ "Personen": "People",
+ "Nutzer folgen": "Follow user",
+ "Nutzer nicht mehr folgen": "Unfollow user",
+ "Du folgst noch keinen Nutzern.": "You are not following any users yet.",
+ "Gehen hin:": "Going:",
+ "Du kannst dir nicht selbst folgen.": "You cannot follow yourself.",
+ "Diesem Profil kannst du nicht folgen.": "You cannot follow this profile.",
"Nicht mehr folgen": "Unfollow",
"nicht mehr folgen": "unfollow",
"Noch keine Band gefolgt.": "No bands followed yet.",
diff --git a/app/main.py b/app/main.py
index 0fd173e..de8caa2 100644
--- a/app/main.py
+++ b/app/main.py
@@ -2664,6 +2664,13 @@ def render_profile(
or bool(viewer and viewer["is_admin"])
or bool(friendship and friendship["status"] == "accepted")
)
+ is_following_user = False
+ if viewer and not is_own_profile and can_view_details and not any(block_status.values()):
+ with get_db_connection() as connection:
+ is_following_user = connection.execute(
+ "SELECT EXISTS (SELECT 1 FROM followed_users WHERE follower_id=%s AND followed_id=%s)",
+ (viewer["id"], profile["id"]),
+ ).fetchone()[0]
connections = {"incoming": [], "outgoing": [], "friends": [], "blocked": []}
if is_own_profile:
with get_db_connection() as connection:
@@ -2760,6 +2767,7 @@ def render_profile(
badges=badges,
is_own_profile=force_own or is_own_profile,
can_view_details=can_view_details,
+ is_following_user=is_following_user,
friendship=friendship,
block_status=block_status,
connections=connections,
@@ -3095,6 +3103,44 @@ def send_friend_request(request: Request, username: str):
return RedirectResponse(f"/users/{profile['username']}", status_code=303)
+@app.post("/users/{username}/follow")
+def follow_user(request: Request, username: str, action: str = Form("follow")):
+ user = get_current_user(request)
+ if action not in {"follow", "unfollow"}:
+ return HTMLResponse(_("Ungültige Aktion."), status_code=400)
+ with get_db_connection() as connection:
+ with connection.cursor() as cursor:
+ cursor.execute("SELECT id, username, profile_visibility, is_admin FROM users WHERE LOWER(username)=LOWER(%s)",
+ (username,))
+ profile = cursor.fetchone()
+ if not profile:
+ return HTMLResponse(_("Benutzer nicht gefunden."), status_code=404)
+ if profile[0] == user["id"]:
+ return HTMLResponse(_("Du kannst dir nicht selbst folgen."), status_code=400)
+ if action == "unfollow":
+ cursor.execute("DELETE FROM followed_users WHERE follower_id=%s AND followed_id=%s",
+ (user["id"], profile[0]))
+ else:
+ cursor.execute("""
+ SELECT EXISTS (SELECT 1 FROM friendships f WHERE f.status='accepted' AND
+ ((f.requester_id=%s AND f.addressee_id=%s) OR
+ (f.requester_id=%s AND f.addressee_id=%s))) AS is_friend,
+ EXISTS (SELECT 1 FROM user_blocks b WHERE
+ (b.blocker_id=%s AND b.blocked_id=%s) OR
+ (b.blocker_id=%s AND b.blocked_id=%s)) AS is_blocked
+ """, (user["id"], profile[0], profile[0], user["id"],
+ user["id"], profile[0], profile[0], user["id"]))
+ row = cursor.fetchone()
+ if row[1] or not (profile[2] == "public" or row[0] or user["is_admin"]):
+ return HTMLResponse(_("Diesem Profil kannst du nicht folgen."), status_code=403)
+ cursor.execute("""
+ INSERT INTO followed_users (follower_id, followed_id) VALUES (%s,%s)
+ ON CONFLICT (follower_id, followed_id) DO NOTHING
+ """, (user["id"], profile[0]))
+ connection.commit()
+ return RedirectResponse(f"/users/{profile[1]}", status_code=303)
+
+
@app.post("/users/{username}/block")
def block_user(request: Request, username: str):
user = get_current_user(request)
@@ -3120,6 +3166,10 @@ def block_user(request: Request, username: str):
""",
(user["id"], profile["id"], profile["id"], user["id"]),
)
+ cursor.execute("""
+ DELETE FROM followed_users WHERE
+ (follower_id=%s AND followed_id=%s) OR (follower_id=%s AND followed_id=%s)
+ """, (user["id"], profile["id"], profile["id"], user["id"]))
connection.commit()
return RedirectResponse(f"/users/{profile['username']}", status_code=303)
@@ -3398,6 +3448,14 @@ def following_page(request: Request):
(user["id"],),
)
followed_venues = [{"id": row[0], "name": row[1], "city": row[2]} for row in cursor.fetchall()]
+ cursor.execute("""
+ SELECT u.id, u.username, COALESCE(u.display_name, u.username), u.avatar_path
+ FROM followed_users fu JOIN users u ON u.id=fu.followed_id
+ WHERE fu.follower_id=%s
+ ORDER BY COALESCE(u.display_name, u.username), u.username
+ """, (user["id"],))
+ followed_users = [{"id": row[0], "username": row[1], "name": row[2], "avatar_path": row[3]}
+ for row in cursor.fetchall()]
cursor.execute(
"""
SELECT c.id, c.artist, c.start_datetime, c.venue_id,
@@ -3424,28 +3482,47 @@ def following_page(request: Request):
candidate_bands = {}
for concert_id, band_key, display_name in cursor.fetchall():
candidate_bands.setdefault(concert_id, []).append({"key": band_key, "name": display_name})
+ cursor.execute("""
+ SELECT ca.concert_id, u.username, COALESCE(u.display_name, u.username)
+ FROM concert_attendance ca
+ JOIN followed_users fu ON fu.followed_id=ca.user_id AND fu.follower_id=%s
+ JOIN users u ON u.id=ca.user_id
+ WHERE ca.concert_id=ANY(%s) AND ca.status='attending'
+ AND (u.profile_visibility='public' OR %s OR EXISTS (
+ SELECT 1 FROM friendships f WHERE f.status='accepted' AND
+ ((f.requester_id=%s AND f.addressee_id=u.id) OR
+ (f.requester_id=u.id AND f.addressee_id=%s))))
+ AND NOT EXISTS (SELECT 1 FROM user_blocks b WHERE
+ (b.blocker_id=%s AND b.blocked_id=u.id) OR
+ (b.blocker_id=u.id AND b.blocked_id=%s))
+ ORDER BY ca.concert_id, COALESCE(u.display_name, u.username), u.username
+ """, (user["id"], candidate_ids or [0], user["is_admin"], user["id"], user["id"],
+ user["id"], user["id"]))
+ attending_followed_users = {}
+ for concert_id, username, display_name in cursor.fetchall():
+ attending_followed_users.setdefault(concert_id, []).append(
+ {"username": username, "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": format_time(row[2]), "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(
+ events = []
+ for row in candidates:
+ 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
+ going_users = attending_followed_users.get(row[0], [])
+ if matched_band or matched_venue or going_users:
+ events.append({
+ "id": row[0], "artist": row[1], "date": row[2].strftime("%d.%m.%Y"),
+ "time": format_time(row[2]), "venue": ", ".join(filter(None, (row[4], row[5]))),
+ "matched_band": matched_band, "matched_venue": matched_venue,
+ "going_users": going_users,
+ })
return templates.get_template("following.html").render(
- user=user, followed_bands=followed_bands, followed_venues=followed_venues, events=events
+ user=user, followed_bands=followed_bands, followed_venues=followed_venues,
+ followed_users=followed_users, events=events
)
@@ -3469,6 +3546,17 @@ def remove_followed_venue(request: Request, venue_id: int = Form(...)):
return RedirectResponse("/following", status_code=303)
+@app.post("/following/users/remove")
+def remove_followed_user(request: Request, followed_id: int = Form(...)):
+ user = get_current_user(request)
+ with get_db_connection() as connection:
+ with connection.cursor() as cursor:
+ cursor.execute("DELETE FROM followed_users WHERE follower_id=%s AND followed_id=%s",
+ (user["id"], followed_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)
diff --git a/app/templates/_user_menu.html b/app/templates/_user_menu.html
index 41529e0..9295aae 100644
--- a/app/templates/_user_menu.html
+++ b/app/templates/_user_menu.html
@@ -4,7 +4,7 @@
{{ _('Mein Profil') }}
{{ _('Nachrichten') }}{% if user.notification_count %} 🤘{{ user.notification_count }} {% endif %}
{{ _('Konzerttagebuch') }}
- {{ _('Gefolgte Bands & Locations') }}
+ {{ _('Gefolgte Inhalte') }}
{% if user.is_admin %}{{ _('⚙️ Verwaltung') }} {% endif %}
{{ _('Datenschutz') }}
{{ _('Impressum') }}
diff --git a/app/templates/datenschutz.html b/app/templates/datenschutz.html
index fbcf7a4..0415784 100644
--- a/app/templates/datenschutz.html
+++ b/app/templates/datenschutz.html
@@ -11,7 +11,7 @@
{{ _('Verantwortlicher') }}
Kai Piekny, Fleyerstr. 33, 58097 Hagenkonzert@pinguholic.de
{{ _('Welche Daten werden gespeichert?') }}
- {{ _('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.') }}
+ {{ _('Für die Nutzung werden insbesondere Benutzername, E-Mail-Adresse, Passwort-Hash, Anzeigename, optionale Profil- und Instagram-Angaben, Profilbild, Freundschaften, Nachrichten, Veranstaltungsteilnahmen, gefolgte Bands, Locations und Nutzer, private Konzerttagebuch-Einträge, Kommentare, Fotos, Patches sowie von dir angelegte Veranstaltungen gespeichert.') }}
{{ _('Wofür werden sie verwendet?') }}
{{ _('Die Daten werden ausschließlich für Anmeldung, Kontoverwaltung, Veranstaltungsfunktionen, Freundschaften, Nachrichten, Benachrichtigungen und die von dir gewählten Sichtbarkeitseinstellungen verarbeitet.') }}
{{ _('Rechtsgrundlage und Speicherdauer') }}
diff --git a/app/templates/following.html b/app/templates/following.html
index 9ede1d3..75eaca4 100644
--- a/app/templates/following.html
+++ b/app/templates/following.html
@@ -3,8 +3,8 @@
{% include '_language_switch.html' %}
-{{ _('⭐ Gefolgt') }} {{ _('Bands, Locations und passende kommende Veranstaltungen.') }}
+{{ _('⭐ Gefolgt') }} {{ _('Bands, Locations, Personen und passende kommende Veranstaltungen.') }}
Bands {% for band in followed_bands %}{{ band.name }} {% else %}{{ _('Noch keine Band gefolgt.') }} {% endfor %}
-
{{ _('Locations') }} {% for venue in followed_venues %}{{ venue.name }}{% if venue.city %}, {{ venue.city }}{% endif %} {% else %}{{ _('Noch keiner Location gefolgt.') }} {% endfor %}
-{{ _('Kommende Treffer') }}
+{{ _('Personen') }} {% for person in followed_users %}{{ person.name }} {% else %}{{ _('Du folgst noch keinen Nutzern.') }} {% endfor %}
+{{ _('Kommende Treffer') }}