Allow creators to delete events and restrict private invitations to friends
This commit is contained in:
+4
-1
@@ -550,5 +550,8 @@
|
||||
"Die Push-Registrierung ist momentan nicht möglich. Bitte versuche es später erneut.": "Push registration is currently unavailable. Please try again later.",
|
||||
"FCM-Testtoken anzeigen (nur Debug-App)": "Show FCM test token (debug app only)",
|
||||
"Für Android-Benachrichtigungen speichern wir die Gerätekennung, den FCM-Registrierungstoken, die App-Version und die Zuordnung zur aktuellen Anmeldung. Beim Abmelden wird die Zuordnung gelöscht. Firebase verarbeitet die für die Push-Zustellung erforderlichen Gerätedaten.": "For Android notifications, we store the device identifier, FCM registration token, app version and link to the current login session. Logging out deletes the link. Firebase processes the device data required to deliver push notifications.",
|
||||
"Bugmeldungen werden mit Benutzername und User-ID an unser internes Gitea-Ticketsystem übertragen. Technische Zusatzinformationen werden nur auf Wunsch mitgesendet. Lokale Versandkennungen zur Vermeidung doppelter Meldungen laufen nach 24 Stunden ab.": "Bug reports are sent to our internal Gitea ticket system with your username and user ID. Additional technical information is sent only if you choose to include it. Local submission identifiers used to prevent duplicate reports expire after 24 hours."
|
||||
"Bugmeldungen werden mit Benutzername und User-ID an unser internes Gitea-Ticketsystem übertragen. Technische Zusatzinformationen werden nur auf Wunsch mitgesendet. Lokale Versandkennungen zur Vermeidung doppelter Meldungen laufen nach 24 Stunden ab.": "Bug reports are sent to our internal Gitea ticket system with your username and user ID. Additional technical information is sent only if you choose to include it. Local submission identifiers used to prevent duplicate reports expire after 24 hours.",
|
||||
"Freunde einladen": "Invite friends",
|
||||
"Noch keine bestätigten Freunde vorhanden.": "No confirmed friends yet.",
|
||||
"Es können nur bestätigte Freunde eingeladen werden.": "Only confirmed friends can be invited."
|
||||
}
|
||||
|
||||
+41
-11
@@ -1027,11 +1027,7 @@ def can_edit_concert(user, concert) -> bool:
|
||||
|
||||
|
||||
def can_delete_concert(user, concert) -> bool:
|
||||
if not user:
|
||||
return False
|
||||
if concert["is_past"]:
|
||||
return user["is_admin"]
|
||||
return user["is_admin"] or user["id"] == concert["created_by"]
|
||||
return bool(user and (user["is_admin"] or user["id"] == concert["created_by"]))
|
||||
|
||||
|
||||
def can_manage_event_access(user, concert) -> bool:
|
||||
@@ -1167,20 +1163,42 @@ def can_view_event(user, concert) -> bool:
|
||||
return cursor.fetchone() is not None
|
||||
|
||||
|
||||
def get_invitable_users(exclude_user_id: int):
|
||||
def get_invitable_users(user_id: int):
|
||||
"""Only confirmed, unblocked friends of the event owner can be newly invited."""
|
||||
with get_db_connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT id, username, COALESCE(display_name, username)
|
||||
FROM users WHERE id <> %s
|
||||
ORDER BY COALESCE(display_name, username), username
|
||||
SELECT u.id, u.username, COALESCE(u.display_name, u.username)
|
||||
FROM users u WHERE u.id <> %s
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM friendships f WHERE f.status = 'accepted'
|
||||
AND ((f.requester_id = %s AND f.addressee_id = u.id)
|
||||
OR (f.addressee_id = %s AND f.requester_id = u.id))
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM user_blocks b
|
||||
WHERE (b.blocker_id = %s AND b.blocked_id = u.id)
|
||||
OR (b.blocked_id = %s AND b.blocker_id = u.id)
|
||||
)
|
||||
ORDER BY COALESCE(u.display_name, u.username), u.username
|
||||
""",
|
||||
(exclude_user_id,),
|
||||
(user_id, user_id, user_id, user_id, user_id),
|
||||
)
|
||||
return [{"id": row[0], "username": row[1], "display_name": row[2]} for row in cursor.fetchall()]
|
||||
|
||||
|
||||
def resolve_invitee_selection(owner_id: int, selected_ids, concert_id=None):
|
||||
eligible = {member["id"] for member in get_invitable_users(owner_id)}
|
||||
selected = set(selected_ids)
|
||||
if not selected <= eligible:
|
||||
raise ValueError(_("Es können nur bestätigte Freunde eingeladen werden."))
|
||||
if concert_id is not None:
|
||||
# Existing invitations outside the picker must not disappear on unrelated edits.
|
||||
selected |= get_event_invitee_ids(concert_id) - eligible
|
||||
return sorted(selected)
|
||||
|
||||
|
||||
def get_event_invitee_ids(concert_id: int):
|
||||
with get_db_connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
@@ -4063,6 +4081,11 @@ async def create_concert(
|
||||
return HTMLResponse(_("<h1>Ungültige Sichtbarkeit.</h1>"), status_code=400)
|
||||
if event_type != "other":
|
||||
visibility = "public"
|
||||
if visibility == "private":
|
||||
try:
|
||||
invited_user_ids = resolve_invitee_selection(user["id"], invited_user_ids)
|
||||
except ValueError as error:
|
||||
return HTMLResponse(f"<h1>{error}</h1>", status_code=400)
|
||||
duplicate_matches = find_duplicate_concerts(user, artist, start_datetime, band_names)
|
||||
if duplicate_matches and not duplicate_confirmed:
|
||||
match_items = "".join(
|
||||
@@ -4213,7 +4236,7 @@ def edit_concert_page(request: Request, concert_id: int):
|
||||
can_edit_title=can_edit_title(user, concert),
|
||||
can_edit_details=can_edit_details(user, concert),
|
||||
can_delete=can_delete_concert(user, concert),
|
||||
invitable_users=get_invitable_users(user["id"]),
|
||||
invitable_users=get_invitable_users(concert["created_by"]),
|
||||
invited_user_ids=get_event_invitee_ids(concert_id),
|
||||
can_manage_access=can_manage_event_access(user, concert),
|
||||
band_names="\n".join(band["name"] for band in load_concert_bands(concert_id, concert["artist"], concert["event_type"])),
|
||||
@@ -4295,6 +4318,13 @@ async def edit_concert(
|
||||
return HTMLResponse(_("<h1>Ungültige Sichtbarkeit.</h1>"), status_code=400)
|
||||
if event_type != "other":
|
||||
visibility = "public"
|
||||
if visibility == "private":
|
||||
try:
|
||||
invited_user_ids = resolve_invitee_selection(
|
||||
concert["created_by"], invited_user_ids, concert_id
|
||||
)
|
||||
except ValueError as error:
|
||||
return HTMLResponse(f"<h1>{error}</h1>", status_code=400)
|
||||
else:
|
||||
visibility = concert["visibility"]
|
||||
effective_start = start_datetime or concert["start_local"]
|
||||
|
||||
@@ -237,14 +237,16 @@
|
||||
|
||||
<div class="calendar-export"><a href="/concerts/{{ concert.id }}.ics" class="concert-action">{{ _('📅 In Kalender speichern (.ics)') }}</a></div>
|
||||
|
||||
{% if can_edit %}
|
||||
{% if can_edit or can_delete %}
|
||||
|
||||
<div class="detail-actions" aria-label="{{ _('Veranstaltung verwalten') }}">
|
||||
{% if can_edit %}
|
||||
<a href="/concerts/{{ concert.id }}/edit" class="concert-action concert-action-edit">
|
||||
{{ _('✏️ Veranstaltung bearbeiten') }}
|
||||
</a>
|
||||
|
||||
{% if user.is_admin and can_delete %}
|
||||
{% endif %}
|
||||
{% if can_delete %}
|
||||
|
||||
<form
|
||||
method="post"
|
||||
|
||||
@@ -49,10 +49,10 @@
|
||||
<label><input type="radio" name="visibility" value="private" {% if concert.visibility == 'private' %}checked{% endif %}> {{ _('Privat – nur eingeladene Mitglieder') }}</label>
|
||||
</fieldset>
|
||||
<fieldset id="event-invitations-field" {% if concert.event_type != 'other' or concert.visibility != 'private' %}hidden{% endif %}>
|
||||
<legend>{{ _('Mitglieder einladen') }}</legend>
|
||||
<legend>{{ _('Freunde einladen') }}</legend>
|
||||
{% for member in invitable_users %}
|
||||
<label style="display:block;margin:7px 0"><input type="checkbox" name="invited_user_ids" value="{{ member.id }}" {% if member.id in invited_user_ids %}checked{% endif %}> {{ member.display_name }} <small>@{{ member.username }}</small></label>
|
||||
{% else %}<p>{{ _('Noch keine weiteren Mitglieder vorhanden.') }}</p>{% endfor %}
|
||||
{% else %}<p>{{ _('Noch keine bestätigten Freunde vorhanden.') }}</p>{% endfor %}
|
||||
</fieldset>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
@@ -141,11 +141,11 @@
|
||||
</fieldset>
|
||||
|
||||
<fieldset id="event-invitations-field" hidden>
|
||||
<legend>{{ _('Mitglieder einladen') }}</legend>
|
||||
<legend>{{ _('Freunde einladen') }}</legend>
|
||||
<p class="flyer-help">{{ _('Eingeladene erhalten eine Benachrichtigung und sehen die Veranstaltung in ihrer Übersicht.') }}</p>
|
||||
{% for member in invitable_users %}
|
||||
<label style="display:block;margin:7px 0"><input type="checkbox" name="invited_user_ids" value="{{ member.id }}"> {{ member.display_name }} <small>@{{ member.username }}</small></label>
|
||||
{% else %}<p>{{ _('Noch keine weiteren Mitglieder vorhanden.') }}</p>{% endfor %}
|
||||
{% else %}<p>{{ _('Noch keine bestätigten Freunde vorhanden.') }}</p>{% endfor %}
|
||||
</fieldset>
|
||||
|
||||
<!-- ================================================= -->
|
||||
|
||||
@@ -163,6 +163,8 @@ class NotificationDatabaseTests(unittest.TestCase):
|
||||
self.assertEqual(result.status_code, 303)
|
||||
|
||||
def invitation(self):
|
||||
with main.get_db_connection() as db:
|
||||
db.execute("INSERT INTO friendships(requester_id,addressee_id,status) VALUES (1,2,'accepted') ON CONFLICT DO NOTHING")
|
||||
result = self.clients[0].post('/concerts', data={'artist':'Private test event', 'start_datetime':'2027-04-01T20:00',
|
||||
'event_type':'other','visibility':'private','invited_user_ids':'2'}, follow_redirects=False)
|
||||
self.assertEqual(result.status_code, 303)
|
||||
@@ -232,6 +234,110 @@ class NotificationDatabaseTests(unittest.TestCase):
|
||||
self.clients[0].post(f'/concerts/{concert}/edit', data=form, follow_redirects=False)
|
||||
self.assertEqual(self.scalar("SELECT count(*) FROM push_notifications WHERE state='pending'"), 1)
|
||||
|
||||
def event(self, owner=1, past=False, kind='other', visibility='public'):
|
||||
start = datetime.now() + timedelta(days=-10 if past else 10)
|
||||
with main.get_db_connection() as db:
|
||||
return db.execute("""INSERT INTO concerts
|
||||
(artist,start_datetime,end_datetime,event_type,visibility,created_by)
|
||||
VALUES (%s,%s,%s,%s,%s,%s) RETURNING id""",
|
||||
('Event access test', start, start + timedelta(days=1) if kind == 'festival' else None,
|
||||
kind, visibility, owner)).fetchone()[0]
|
||||
|
||||
def test_creator_can_delete_own_events_including_past_in_both_languages(self):
|
||||
for language in ('de', 'en'):
|
||||
self.clients[0].get('/language/' + language, follow_redirects=False)
|
||||
for past in (False, True):
|
||||
for kind in ('concert', 'festival', 'other'):
|
||||
with self.subTest(language=language, past=past, kind=kind):
|
||||
event = self.event(past=past, kind=kind)
|
||||
page = self.clients[0].get(f'/concerts/{event}')
|
||||
self.assertEqual(page.status_code, 200)
|
||||
self.assertIn(f'action="/concerts/{event}/delete"', page.text)
|
||||
self.assertIn('Delete event' if language == 'en' else 'Veranstaltung löschen', page.text)
|
||||
if past:
|
||||
self.assertNotIn(f'href="/concerts/{event}/edit"', page.text)
|
||||
result = self.clients[0].post(f'/concerts/{event}/delete', follow_redirects=False)
|
||||
self.assertEqual(result.status_code, 303)
|
||||
self.assertEqual(self.scalar('SELECT count(*) FROM concerts WHERE id=%s', (event,)), 0)
|
||||
|
||||
def test_delete_rejects_foreign_and_anonymous_but_preserves_admin_access(self):
|
||||
for past in (False, True):
|
||||
event = self.event(owner=2, past=past)
|
||||
page = self.clients[0].get(f'/concerts/{event}')
|
||||
self.assertNotIn(f'action="/concerts/{event}/delete"', page.text)
|
||||
self.assertEqual(self.clients[0].post(f'/concerts/{event}/delete', follow_redirects=False).status_code, 403)
|
||||
self.assertEqual(self.scalar('SELECT count(*) FROM concerts WHERE id=%s', (event,)), 1)
|
||||
with TestClient(main.app) as anonymous:
|
||||
self.assertEqual(anonymous.post(f'/concerts/{event}/delete', follow_redirects=False).status_code, 303)
|
||||
self.assertEqual(self.clients[1].post(f'/concerts/{event}/delete',
|
||||
headers={'Origin':'https://evil.invalid'}, follow_redirects=False).status_code, 403)
|
||||
with main.get_db_connection() as db:
|
||||
db.execute('UPDATE users SET is_admin=TRUE WHERE id=3')
|
||||
self.assertIn(f'action="/concerts/{event}/delete"', self.clients[2].get(f'/concerts/{event}').text)
|
||||
self.assertEqual(self.clients[2].post(f'/concerts/{event}/delete', follow_redirects=False).status_code, 303)
|
||||
# Deleting a parent does not delete another user's linked event.
|
||||
parent = self.event(kind='concert')
|
||||
child = self.event(owner=2)
|
||||
with main.get_db_connection() as db:
|
||||
db.execute('UPDATE concerts SET parent_event_id=%s WHERE id=%s', (parent, child))
|
||||
self.assertEqual(self.clients[0].post(f'/concerts/{parent}/delete', follow_redirects=False).status_code, 303)
|
||||
self.assertIsNone(self.scalar('SELECT parent_event_id FROM concerts WHERE id=%s', (child,)))
|
||||
|
||||
def test_private_picker_shows_only_confirmed_unblocked_owner_friends(self):
|
||||
event = self.event(visibility='private')
|
||||
with main.get_db_connection() as db:
|
||||
db.execute("INSERT INTO friendships(requester_id,addressee_id,status) VALUES (2,1,'accepted'),(1,3,'pending')")
|
||||
for language, heading in (('de','Freunde einladen'), ('en','Invite friends')):
|
||||
self.clients[0].get('/language/' + language, follow_redirects=False)
|
||||
for route in ('/concerts/new', f'/concerts/{event}/edit'):
|
||||
page = self.clients[0].get(route)
|
||||
self.assertEqual(page.status_code, 200)
|
||||
field = re.search(r'<fieldset id="event-invitations-field".*?</fieldset>', page.text, re.S).group()
|
||||
self.assertIn(heading, field)
|
||||
self.assertEqual(re.findall(r'name="invited_user_ids" value="(\d+)"', field), ['2'])
|
||||
with main.get_db_connection() as db:
|
||||
db.execute('UPDATE users SET is_admin=TRUE WHERE id=3')
|
||||
admin_page = self.clients[2].get(f'/concerts/{event}/edit')
|
||||
self.assertEqual(re.findall(r'name="invited_user_ids" value="(\d+)"', admin_page.text), ['2'])
|
||||
# Verify the other direction of the friendship and a block overriding it.
|
||||
self.assertEqual([u['id'] for u in main.get_invitable_users(2)], [1])
|
||||
with main.get_db_connection() as db:
|
||||
db.execute('INSERT INTO user_blocks(blocker_id,blocked_id) VALUES (2,1)')
|
||||
self.assertEqual(main.get_invitable_users(1), [])
|
||||
for language, empty in (('de','Noch keine bestätigten Freunde vorhanden.'), ('en','No confirmed friends yet.')):
|
||||
self.clients[0].get('/language/' + language, follow_redirects=False)
|
||||
self.assertIn(empty, self.clients[0].get('/concerts/new').text)
|
||||
|
||||
def test_private_invites_reject_forged_ids_and_preserve_existing_hidden_invites(self):
|
||||
form = {'artist':'Invitation access test', 'start_datetime':'2027-04-01T20:00',
|
||||
'event_type':'other', 'visibility':'private'}
|
||||
for language, error in (('de','Es können nur bestätigte Freunde eingeladen werden.'),
|
||||
('en','Only confirmed friends can be invited.')):
|
||||
self.clients[0].get('/language/' + language, follow_redirects=False)
|
||||
for selected in ('1','2','99999'):
|
||||
result = self.clients[0].post('/concerts', data=dict(form, invited_user_ids=selected), follow_redirects=False)
|
||||
self.assertEqual(result.status_code, 400)
|
||||
self.assertIn(error, result.text)
|
||||
self.assertEqual(self.scalar('SELECT count(*) FROM concerts'), 0)
|
||||
event = self.invitation()
|
||||
result = self.clients[0].post(f'/concerts/{event}/edit',
|
||||
data=dict(form, invited_user_ids=['2','3']), follow_redirects=False)
|
||||
self.assertEqual(result.status_code, 400)
|
||||
self.assertEqual(main.get_event_invitee_ids(event), {2})
|
||||
self.assertEqual(self.scalar('SELECT artist FROM concerts WHERE id=%s', (event,)), 'Private test event')
|
||||
with main.get_db_connection() as db:
|
||||
db.execute('DELETE FROM friendships')
|
||||
# The now hidden legacy invitation survives an unrelated edit, without a new push.
|
||||
result = self.clients[0].post(f'/concerts/{event}/edit', data=form, follow_redirects=False)
|
||||
self.assertEqual(result.status_code, 303)
|
||||
self.assertEqual(main.get_event_invitee_ids(event), {2})
|
||||
self.assertEqual(self.scalar("SELECT count(*) FROM push_notifications WHERE kind='event_invitation'"), 1)
|
||||
# An invited non-owner cannot use the edit route to grant access to someone else.
|
||||
result = self.clients[1].post(f'/concerts/{event}/edit',
|
||||
data=dict(form, invited_user_ids='3'), follow_redirects=False)
|
||||
self.assertEqual(result.status_code, 303)
|
||||
self.assertEqual(main.get_event_invitee_ids(event), {2})
|
||||
|
||||
def test_linkable_events_are_chronological_in_create_and_edit_forms(self):
|
||||
with main.get_db_connection() as db:
|
||||
rows = db.execute("""
|
||||
|
||||
@@ -4,4 +4,8 @@ Veranstaltungen können als Konzert, Festival oder sonstiges Event angelegt werd
|
||||
|
||||
Auf der Detailseite stehen – abhängig von Anmeldung und Berechtigungen – Teilnahme-/Interesse-Status, eventbezogene Kommentare, Einladungen und Fotos zur Verfügung. Die Zeitdarstellung folgt der gewählten Sprache; Englisch verwendet das 12-Stunden-Format.
|
||||
|
||||
Ersteller können ihre eigenen Veranstaltungen über „Veranstaltung löschen“ auf der Detailseite nach Bestätigung löschen, auch vergangene Veranstaltungen. Normale Mitglieder dürfen fremde Veranstaltungen weder über die Oberfläche noch per direktem POST löschen. Administratoren behalten ihr Löschrecht. Beim Löschen eines Konzerts/Festivals bleiben verknüpfte sonstige Veranstaltungen anderer Ersteller erhalten; lediglich die Verknüpfung entfällt. Die bisherigen Einschränkungen für das Bearbeiten vergangener Veranstaltungen bleiben bestehen.
|
||||
|
||||
Unter „Sonstige Veranstaltung → Privat“ zeigt „Freunde einladen“ beim Anlegen und Bearbeiten ausschließlich bestätigte, nicht blockierte Freunde des Erstellers. Offene Freundschaftsanfragen und reine Nutzer-Follows reichen nicht aus. Das Backend weist manipulierte Einladungs-IDs zurück. Auch bei Bearbeitung durch einen Admin bezieht sich die Auswahl auf die Freunde des Erstellers. Bereits vorhandene Einladungen an inzwischen nicht mehr auswählbare Mitglieder bleiben bei sonstigen Änderungen erhalten; die Umstellung entzieht bestehenden Gästen nicht automatisch ihren Zugang. Abwählen eines auswählbaren Freundes entfernt dessen Einladung weiterhin.
|
||||
|
||||
Flyer und Tagebuchfotos sind Uploads. Persönliche oder nur für Freunde sichtbare Veranstaltungen werden serverseitig anhand der bestehenden Sichtbarkeitsregeln geschützt. Konzertalben als eigenständige Funktion sind noch nicht vollständig ausgebaut.
|
||||
|
||||
Reference in New Issue
Block a user