Add user follows to following feed
This commit is contained in:
+10
-1
@@ -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 = (
|
FEATURE_SCHEMA = (
|
||||||
'''CREATE TABLE IF NOT EXISTS push_devices (
|
'''CREATE TABLE IF NOT EXISTS push_devices (
|
||||||
@@ -55,4 +55,13 @@ FEATURE_SCHEMA = (
|
|||||||
ON push_notifications(available_at) WHERE state='pending';''',
|
ON push_notifications(available_at) WHERE state='pending';''',
|
||||||
'''CREATE UNIQUE INDEX IF NOT EXISTS idx_user_badges_registration_cohort
|
'''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');''',
|
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);''',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"Neue Nachrichten oder Anfragen": "New messages or requests",
|
"Neue Nachrichten oder Anfragen": "New messages or requests",
|
||||||
"Konzerttagebuch": "Concert diary",
|
"Konzerttagebuch": "Concert diary",
|
||||||
"Gefolgte Bands & Locations": "Followed bands & venues",
|
"Gefolgte Bands & Locations": "Followed bands & venues",
|
||||||
|
"Gefolgte Inhalte": "Followed content",
|
||||||
"⚙️ Verwaltung": "⚙️ Administration",
|
"⚙️ Verwaltung": "⚙️ Administration",
|
||||||
"Datenschutz": "Privacy policy",
|
"Datenschutz": "Privacy policy",
|
||||||
"Impressum": "Legal notice",
|
"Impressum": "Legal notice",
|
||||||
@@ -114,6 +115,7 @@
|
|||||||
"Verantwortlicher": "Data controller",
|
"Verantwortlicher": "Data controller",
|
||||||
"Welche Daten werden gespeichert?": "What data is stored?",
|
"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 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?",
|
"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.",
|
"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",
|
"Rechtsgrundlage und Speicherdauer": "Legal basis and retention period",
|
||||||
@@ -184,6 +186,14 @@
|
|||||||
"Gefolgt · MetalCircle": "Following · MetalCircle",
|
"Gefolgt · MetalCircle": "Following · MetalCircle",
|
||||||
"⭐ Gefolgt": "⭐ Following",
|
"⭐ Gefolgt": "⭐ Following",
|
||||||
"Bands, Locations und passende kommende Veranstaltungen.": "Bands, venues and matching upcoming events.",
|
"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",
|
||||||
"nicht mehr folgen": "unfollow",
|
"nicht mehr folgen": "unfollow",
|
||||||
"Noch keine Band gefolgt.": "No bands followed yet.",
|
"Noch keine Band gefolgt.": "No bands followed yet.",
|
||||||
|
|||||||
+103
-15
@@ -2664,6 +2664,13 @@ def render_profile(
|
|||||||
or bool(viewer and viewer["is_admin"])
|
or bool(viewer and viewer["is_admin"])
|
||||||
or bool(friendship and friendship["status"] == "accepted")
|
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": []}
|
connections = {"incoming": [], "outgoing": [], "friends": [], "blocked": []}
|
||||||
if is_own_profile:
|
if is_own_profile:
|
||||||
with get_db_connection() as connection:
|
with get_db_connection() as connection:
|
||||||
@@ -2760,6 +2767,7 @@ def render_profile(
|
|||||||
badges=badges,
|
badges=badges,
|
||||||
is_own_profile=force_own or is_own_profile,
|
is_own_profile=force_own or is_own_profile,
|
||||||
can_view_details=can_view_details,
|
can_view_details=can_view_details,
|
||||||
|
is_following_user=is_following_user,
|
||||||
friendship=friendship,
|
friendship=friendship,
|
||||||
block_status=block_status,
|
block_status=block_status,
|
||||||
connections=connections,
|
connections=connections,
|
||||||
@@ -3095,6 +3103,44 @@ def send_friend_request(request: Request, username: str):
|
|||||||
return RedirectResponse(f"/users/{profile['username']}", status_code=303)
|
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")
|
@app.post("/users/{username}/block")
|
||||||
def block_user(request: Request, username: str):
|
def block_user(request: Request, username: str):
|
||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
@@ -3120,6 +3166,10 @@ def block_user(request: Request, username: str):
|
|||||||
""",
|
""",
|
||||||
(user["id"], profile["id"], profile["id"], user["id"]),
|
(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()
|
connection.commit()
|
||||||
return RedirectResponse(f"/users/{profile['username']}", status_code=303)
|
return RedirectResponse(f"/users/{profile['username']}", status_code=303)
|
||||||
|
|
||||||
@@ -3398,6 +3448,14 @@ def following_page(request: Request):
|
|||||||
(user["id"],),
|
(user["id"],),
|
||||||
)
|
)
|
||||||
followed_venues = [{"id": row[0], "name": row[1], "city": row[2]} for row in cursor.fetchall()]
|
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(
|
cursor.execute(
|
||||||
"""
|
"""
|
||||||
SELECT c.id, c.artist, c.start_datetime, c.venue_id,
|
SELECT c.id, c.artist, c.start_datetime, c.venue_id,
|
||||||
@@ -3424,28 +3482,47 @@ def following_page(request: Request):
|
|||||||
candidate_bands = {}
|
candidate_bands = {}
|
||||||
for concert_id, band_key, display_name in cursor.fetchall():
|
for concert_id, band_key, display_name in cursor.fetchall():
|
||||||
candidate_bands.setdefault(concert_id, []).append({"key": band_key, "name": display_name})
|
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]
|
band_names = [band["name"] for band in followed_bands]
|
||||||
venue_ids = {venue["id"] for venue in followed_venues}
|
venue_ids = {venue["id"] for venue in followed_venues}
|
||||||
events = [
|
events = []
|
||||||
{
|
for row in candidates:
|
||||||
"id": row[0], "artist": row[1], "date": row[2].strftime("%d.%m.%Y"),
|
matched_band = any(
|
||||||
"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(
|
|
||||||
artist_names_similar(event_band["name"], followed_band)
|
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 event_band in (candidate_bands.get(row[0]) or parse_band_names("", row[1], row[8]))
|
||||||
for followed_band in band_names
|
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(
|
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)
|
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")
|
@app.post("/concerts/{concert_id}/follow-band")
|
||||||
def follow_band(request: Request, concert_id: int, band_key: str = Form(...), action: str = Form("follow")):
|
def follow_band(request: Request, concert_id: int, band_key: str = Form(...), action: str = Form("follow")):
|
||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<a href="/profile">{{ _('Mein Profil') }}</a>
|
<a href="/profile">{{ _('Mein Profil') }}</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>
|
||||||
<a href="/diary">{{ _('Konzerttagebuch') }}</a>
|
<a href="/diary">{{ _('Konzerttagebuch') }}</a>
|
||||||
<a href="/following">{{ _('Gefolgte Bands & Locations') }}</a>
|
<a href="/following">{{ _('Gefolgte Inhalte') }}</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>
|
||||||
<a href="/impressum">{{ _('Impressum') }}</a>
|
<a href="/impressum">{{ _('Impressum') }}</a>
|
||||||
|
|||||||
@@ -11,7 +11,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, gefolgte Bands und Locations, private Konzerttagebuch-Einträge, 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, Locations und Nutzer, 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>
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
</style><link rel="stylesheet" href="/static/css/language.css">
|
</style><link rel="stylesheet" href="/static/css/language.css">
|
||||||
</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> {% include '_language_switch.html' %}
|
</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> {% include '_language_switch.html' %}
|
||||||
</header><main>
|
</header><main>
|
||||||
<div class="page-title"><h1>{{ _('⭐ Gefolgt') }}</h1><p>{{ _('Bands, Locations und passende kommende Veranstaltungen.') }}</p></div>
|
<div class="page-title"><h1>{{ _('⭐ Gefolgt') }}</h1><p>{{ _('Bands, Locations, Personen 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>
|
<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"><h2>{{ _('Personen') }}</h2><ul class="follow-list">{% for person in followed_users %}<li><a href="/users/{{ person.username }}">{{ person.name }}</a><form method="post" action="/following/users/remove"><input type="hidden" name="followed_id" value="{{ person.id }}"><button class="follow-remove" type="submit" title="{{ _('Nutzer nicht mehr folgen') }}" aria-label="{{ person.name }} {{ _('Nutzer nicht mehr folgen') }}">×</button></form></li>{% else %}<li>{{ _('Du folgst noch keinen Nutzern.') }}</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>
|
<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 %}{% if event.matched_band or event.matched_venue %}<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>{% endif %}{% if event.going_users %}<div class="follow-reason">{{ _('Gehen hin:') }} {% for person in event.going_users %}{{ person.name }}{% if not loop.last %}, {% endif %}{% endfor %}</div>{% endif %}</a>{% else %}<p>{{ _('Momentan gibt es keine kommenden Treffer.') }}</p>{% endfor %}</div></section>
|
||||||
</main></body></html>
|
</main></body></html>
|
||||||
|
|||||||
@@ -101,6 +101,12 @@
|
|||||||
<div class="stat"><strong>{{ attended_count }}</strong>{{ _('besuchte Konzerte') }}</div>
|
<div class="stat"><strong>{{ attended_count }}</strong>{{ _('besuchte Konzerte') }}</div>
|
||||||
{% if not is_own_profile %}
|
{% if not is_own_profile %}
|
||||||
<div class="friend-actions">
|
<div class="friend-actions">
|
||||||
|
{% if can_view_details and not block_status.blocked_by_viewer and not block_status.blocked_viewer %}
|
||||||
|
<form method="post" action="/users/{{ profile.username }}/follow">
|
||||||
|
<input type="hidden" name="action" value="{% if is_following_user %}unfollow{% else %}follow{% endif %}">
|
||||||
|
<button class="button {% if is_following_user %}button-secondary{% endif %}" type="submit">{% if is_following_user %}{{ _('Nutzer nicht mehr folgen') }}{% else %}{{ _('Nutzer folgen') }}{% endif %}</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
{% if not block_status.blocked_by_viewer and not block_status.blocked_viewer %}
|
{% 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') %}
|
{% if (user.is_admin or profile.is_admin) and (not friendship or friendship.status != 'accepted') %}
|
||||||
<a class="button" href="/messages/{{ profile.username }}">{{ _('Nachricht senden') }}</a>
|
<a class="button" href="/messages/{{ profile.username }}">{{ _('Nachricht senden') }}</a>
|
||||||
|
|||||||
@@ -34,15 +34,26 @@ class FeatureApiTests(unittest.TestCase):
|
|||||||
with main.get_db_connection() as db:
|
with main.get_db_connection() as db:
|
||||||
db.execute('''
|
db.execute('''
|
||||||
CREATE TABLE users(id SERIAL PRIMARY KEY, username TEXT UNIQUE, email TEXT UNIQUE,
|
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);
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
|
||||||
CREATE TABLE sessions(id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
CREATE TABLE sessions(id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||||
token_hash TEXT UNIQUE, expires_at TIMESTAMP);
|
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 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,
|
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));
|
PRIMARY KEY(user_id,badge_code));
|
||||||
''')
|
''')
|
||||||
for statement in FEATURE_SCHEMA: db.execute(statement)
|
for statement in FEATURE_SCHEMA: db.execute(statement)
|
||||||
@@ -83,10 +94,60 @@ class FeatureApiTests(unittest.TestCase):
|
|||||||
def count_devices(self):
|
def count_devices(self):
|
||||||
with main.get_db_connection() as db: return db.execute('SELECT count(*) FROM push_devices').fetchone()[0]
|
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):
|
def test_migrations_repeat_and_match_startup_schema(self):
|
||||||
with main.get_db_connection() as db:
|
with main.get_db_connection() as db:
|
||||||
for name, runtime in zip(('19_push_devices.sql', '20_bug_report_submissions.sql',
|
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()
|
source = Path('/test-migrations', name).read_text()
|
||||||
normalize = lambda s: re.sub(r'\s+', '', re.sub(r'--[^\n]*', '', s))
|
normalize = lambda s: re.sub(r'\s+', '', re.sub(r'--[^\n]*', '', s))
|
||||||
self.assertEqual(normalize(source), normalize(runtime))
|
self.assertEqual(normalize(source), normalize(runtime))
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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.
|
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.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ MetalCircle verwendet PostgreSQL. Das Initialschema liegt in `db/init/01_initial
|
|||||||
Wichtige Beziehungen:
|
Wichtige Beziehungen:
|
||||||
|
|
||||||
- `users` ist die Identität für Sessions, Freundschaften, Nachrichten, Kommentare, Attendance, Diary, Badges und Uploads.
|
- `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.
|
- `concerts` verweist optional auf `venues`, einen Parent-Event und den Ersteller.
|
||||||
- `concert_comments`, `concert_photos` und `concert_attendance` hängen an einem Konzert.
|
- `concert_comments`, `concert_photos` und `concert_attendance` hängen an einem Konzert.
|
||||||
- `concert_diary` und `concert_diary_photos` bilden persönliche Konzertnotizen.
|
- `concert_diary` und `concert_diary_photos` bilden persönliche Konzertnotizen.
|
||||||
|
|||||||
@@ -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.
|
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`.
|
Nicht jede geplante Community-Funktion ist vollständig umgesetzt; maßgeblich ist der aktuelle Code in `app/main.py`.
|
||||||
|
|||||||
Reference in New Issue
Block a user