From c4a84ec13547b5abd66da3ad66b035c11b806b3a Mon Sep 17 00:00:00 2001 From: MetalCircle Codex Bot Date: Tue, 15 Sep 2026 09:22:25 +0200 Subject: [PATCH] Add user follows to following feed --- app/feature_schema.py | 11 ++- app/locales/en.json | 10 +++ app/main.py | 118 ++++++++++++++++++++++++---- app/templates/_user_menu.html | 2 +- app/templates/datenschutz.html | 2 +- app/templates/following.html | 6 +- app/templates/profile.html | 6 ++ app/tests/test_feature_api.py | 71 +++++++++++++++-- db/migrations/23_followed_users.sql | 10 +++ docs/wiki/Comments-and-Community.md | 2 +- docs/wiki/Database.md | 1 + docs/wiki/Users-and-Profiles.md | 2 +- 12 files changed, 213 insertions(+), 28 deletions(-) create mode 100644 db/migrations/23_followed_users.sql 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 Hagen
konzert@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 '_user_menu.html' %}
{% include '_language_switch.html' %}
-

{{ _('⭐ Gefolgt') }}

{{ _('Bands, Locations und passende kommende Veranstaltungen.') }}

+

{{ _('⭐ Gefolgt') }}

{{ _('Bands, Locations, Personen und passende kommende Veranstaltungen.') }}

- + +
diff --git a/app/templates/profile.html b/app/templates/profile.html index 496308d..4577057 100644 --- a/app/templates/profile.html +++ b/app/templates/profile.html @@ -101,6 +101,12 @@
{{ attended_count }}{{ _('besuchte Konzerte') }}
{% if not is_own_profile %}
+ {% if can_view_details and not block_status.blocked_by_viewer and not block_status.blocked_viewer %} +
+ + +
+ {% endif %} {% if not block_status.blocked_by_viewer and not block_status.blocked_viewer %} {% if (user.is_admin or profile.is_admin) and (not friendship or friendship.status != 'accepted') %} {{ _('Nachricht senden') }} diff --git a/app/tests/test_feature_api.py b/app/tests/test_feature_api.py index 1d3c269..3406ce2 100644 --- a/app/tests/test_feature_api.py +++ b/app/tests/test_feature_api.py @@ -34,15 +34,26 @@ class FeatureApiTests(unittest.TestCase): with main.get_db_connection() as db: db.execute(''' CREATE TABLE users(id SERIAL PRIMARY KEY, username TEXT UNIQUE, email TEXT UNIQUE, - display_name TEXT, password_hash TEXT, is_admin BOOLEAN DEFAULT FALSE, + display_name TEXT, avatar_path TEXT, instagram_url TEXT, profile_visibility TEXT DEFAULT 'public', + password_hash TEXT, is_admin BOOLEAN DEFAULT FALSE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP); CREATE TABLE sessions(id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, token_hash TEXT UNIQUE, expires_at TIMESTAMP); - CREATE TABLE friendships(addressee_id INTEGER, status TEXT); + CREATE TABLE friendships(id SERIAL PRIMARY KEY, requester_id INTEGER, addressee_id INTEGER, status TEXT); CREATE TABLE direct_messages(recipient_id INTEGER, read_at TIMESTAMP); - CREATE TABLE event_invitations(user_id INTEGER, viewed_at TIMESTAMP); + CREATE TABLE event_invitations(concert_id INTEGER, user_id INTEGER, viewed_at TIMESTAMP); + CREATE TABLE user_blocks(blocker_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + blocked_id INTEGER REFERENCES users(id) ON DELETE CASCADE, PRIMARY KEY(blocker_id,blocked_id)); + CREATE TABLE venues(id SERIAL PRIMARY KEY, name TEXT, city TEXT); + CREATE TABLE concerts(id SERIAL PRIMARY KEY, artist TEXT, start_datetime TIMESTAMP, + end_datetime TIMESTAMP, venue_id INTEGER REFERENCES venues(id), visibility TEXT, + created_by INTEGER, event_type TEXT DEFAULT 'concert'); + CREATE TABLE concert_bands(concert_id INTEGER, band_key TEXT, display_name TEXT, position SMALLINT DEFAULT 0); + CREATE TABLE concert_attendance(concert_id INTEGER, user_id INTEGER, status TEXT); + CREATE TABLE followed_bands(user_id INTEGER, band_key TEXT, display_name TEXT); + CREATE TABLE followed_venues(user_id INTEGER, venue_id INTEGER); CREATE TABLE user_badges(user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, - badge_code TEXT, awarded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + badge_code TEXT, awarded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, trigger_concert_id INTEGER, PRIMARY KEY(user_id,badge_code)); ''') for statement in FEATURE_SCHEMA: db.execute(statement) @@ -83,10 +94,60 @@ class FeatureApiTests(unittest.TestCase): def count_devices(self): with main.get_db_connection() as db: return db.execute('SELECT count(*) FROM push_devices').fetchone()[0] + def test_follow_user_and_show_their_attending_events(self): + with main.get_db_connection() as db: + db.execute("INSERT INTO concerts(artist,start_datetime,visibility,created_by,event_type) " + "VALUES ('Followed Friend Band',CURRENT_TIMESTAMP + INTERVAL '3 days','public',2,'concert')") + db.execute("INSERT INTO concert_attendance(concert_id,user_id,status) VALUES (1,2,'attending')") + result = self.client.post('/users/tester_b/follow', follow_redirects=False) + self.assertEqual(result.status_code, 303) + self.client.post('/users/tester_b/follow', follow_redirects=False) + with main.get_db_connection() as db: + self.assertEqual(db.execute('SELECT count(*) FROM followed_users').fetchone()[0], 1) + page = self.client.get('/following') + self.assertEqual(page.status_code, 200) + self.assertIn('tester_b', page.text) + self.assertIn('Followed Friend Band', page.text) + self.assertIn('Gehen hin:', page.text) + self.client.get('/language/en', follow_redirects=False) + english_page = self.client.get('/following') + self.assertIn('People', english_page.text) + self.assertIn('Going:', english_page.text) + with main.get_db_connection() as db: + db.execute("UPDATE users SET profile_visibility='friends' WHERE id=2") + hidden_attendance = self.client.get('/following') + self.assertNotIn('Going: tester_b', hidden_attendance.text) + with main.get_db_connection() as db: + db.execute("UPDATE users SET profile_visibility='public' WHERE id=2") + db.execute("UPDATE concerts SET visibility='private' WHERE id=1") + hidden_event = self.client.get('/following') + self.assertNotIn('Followed Friend Band', hidden_event.text) + + def test_follow_privacy_self_unfollow_and_block_cleanup(self): + with main.get_db_connection() as db: + db.execute("UPDATE users SET profile_visibility='nobody' WHERE username='tester_b'") + denied = self.client.post('/users/tester_b/follow', follow_redirects=False) + self.assertEqual(denied.status_code, 403) + self.assertEqual(self.client.post('/users/tester_a/follow', follow_redirects=False).status_code, 400) + with main.get_db_connection() as db: + db.execute("UPDATE users SET profile_visibility='public' WHERE username='tester_b'") + self.client.post('/users/tester_b/follow', follow_redirects=False) + removed = self.client.post('/following/users/remove', data={'followed_id':2}, follow_redirects=False) + self.assertEqual(removed.status_code, 303) + self.client.post('/users/tester_b/follow', follow_redirects=False) + self.client.post('/users/tester_b/block', follow_redirects=False) + with main.get_db_connection() as db: + self.assertEqual(db.execute('SELECT count(*) FROM followed_users').fetchone()[0], 0) + + def test_following_page_requires_login(self): + self.client.post('/logout', follow_redirects=False) + self.assertEqual(self.client.get('/following', follow_redirects=False).status_code, 303) + def test_migrations_repeat_and_match_startup_schema(self): with main.get_db_connection() as db: for name, runtime in zip(('19_push_devices.sql', '20_bug_report_submissions.sql', - '21_push_notifications.sql', '22_registration_badges.sql'), FEATURE_SCHEMA): + '21_push_notifications.sql', '22_registration_badges.sql', + '23_followed_users.sql'), FEATURE_SCHEMA): source = Path('/test-migrations', name).read_text() normalize = lambda s: re.sub(r'\s+', '', re.sub(r'--[^\n]*', '', s)) self.assertEqual(normalize(source), normalize(runtime)) diff --git a/db/migrations/23_followed_users.sql b/db/migrations/23_followed_users.sql new file mode 100644 index 0000000..d120619 --- /dev/null +++ b/db/migrations/23_followed_users.sql @@ -0,0 +1,10 @@ +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/docs/wiki/Comments-and-Community.md b/docs/wiki/Comments-and-Community.md index 831e7d2..685fa1b 100644 --- a/docs/wiki/Comments-and-Community.md +++ b/docs/wiki/Comments-and-Community.md @@ -2,4 +2,4 @@ Kommentare gehören zu einem Konzert und werden chronologisch auf der Veranstaltungsseite angezeigt. Diese Struktur hält Gespräche beim jeweiligen Konzert; private Direktnachrichten und Freundschaften decken persönliche Kommunikation ab. -Aktuell vorhanden sind Freundschaftsanfragen, Blockierungen, Follow-Beziehungen für Bands und Venues, Einladungen und Direktnachrichten. Erweiterungen der Community bleiben an die bestehenden Sichtbarkeits- und Blockierungsregeln gebunden. +Aktuell vorhanden sind Freundschaftsanfragen, Blockierungen, Follow-Beziehungen für Bands, Venues und Nutzer, Einladungen und Direktnachrichten. In der Gefolgt-Ansicht erscheinen kommende Konzerte, zu denen gefolgte Nutzer ihre Teilnahme bestätigt haben. Es werden nur Veranstaltungen gezeigt, die der angemeldete Nutzer selbst aufrufen darf; blockierte Beziehungen und geschützte Profile werden dabei berücksichtigt. Erweiterungen der Community bleiben an die bestehenden Sichtbarkeits- und Blockierungsregeln gebunden. diff --git a/docs/wiki/Database.md b/docs/wiki/Database.md index c65379a..66234d1 100644 --- a/docs/wiki/Database.md +++ b/docs/wiki/Database.md @@ -5,6 +5,7 @@ MetalCircle verwendet PostgreSQL. Das Initialschema liegt in `db/init/01_initial Wichtige Beziehungen: - `users` ist die Identität für Sessions, Freundschaften, Nachrichten, Kommentare, Attendance, Diary, Badges und Uploads. +- `followed_users` speichert gerichtete Nutzer-Follows (`follower_id`, `followed_id`) und wird beim Löschen eines Kontos kaskadierend bereinigt. - `concerts` verweist optional auf `venues`, einen Parent-Event und den Ersteller. - `concert_comments`, `concert_photos` und `concert_attendance` hängen an einem Konzert. - `concert_diary` und `concert_diary_photos` bilden persönliche Konzertnotizen. diff --git a/docs/wiki/Users-and-Profiles.md b/docs/wiki/Users-and-Profiles.md index 2da37e9..ea26e10 100644 --- a/docs/wiki/Users-and-Profiles.md +++ b/docs/wiki/Users-and-Profiles.md @@ -2,6 +2,6 @@ MetalCircle ist invite-only. Benutzer besitzen Benutzername, E-Mail, Passwort-Hash, Anzeigename, Avatar und Sichtbarkeitseinstellung. Sessions liegen serverseitig und werden bei Logout gelöscht. -Administratoren verwalten Einladungen, Benutzer, Venues, Patch-Bilder und Statistiken. Mitglieder können Profile ansehen, Freundschaftsanfragen senden, blockieren, Bands/Venues folgen und Nachrichten austauschen. Sichtbarkeit und Blockierungen werden bei Profilen, Attendance und Community-Daten berücksichtigt. +Administratoren verwalten Einladungen, Benutzer, Venues, Patch-Bilder und Statistiken. Mitglieder können Profile ansehen, Freundschaftsanfragen senden, blockieren, Bands/Venues/Nutzern folgen und Nachrichten austauschen. Nutzer-Follows sind unabhängig von einer bestätigten Freundschaft; sie geben keinen Zugriff auf private Profile oder nicht sichtbare Veranstaltungen. Die Gefolgt-Ansicht zeigt Teilnahme an kommenden Veranstaltungen nur, wenn das Profil und die Veranstaltung für den Betrachter sichtbar sind. Blockierungen entfernen bestehende Follow-Beziehungen in beide Richtungen. Sichtbarkeit und Blockierungen werden bei Profilen, Attendance und Community-Daten berücksichtigt. Nicht jede geplante Community-Funktion ist vollständig umgesetzt; maßgeblich ist der aktuelle Code in `app/main.py`.