Add activity push notifications and registration patches

This commit is contained in:
2026-09-15 08:31:45 +02:00
parent 7c801dfddd
commit 55174684af
38 changed files with 1096 additions and 42 deletions
+50 -17
View File
@@ -35,6 +35,8 @@ from fastapi.exception_handlers import request_validation_exception_handler
from feature_schema import FEATURE_SCHEMA
import push_devices
import bug_reporter
import notifications
import community_badges
from i18n import (
LANGUAGE_COOKIE, current_language, current_page, gettext as _,
language_url, safe_return_path, format_time, format_datetime,
@@ -100,7 +102,9 @@ BADGE_DEFINITIONS = (
("founder", "Gründer", "⚔️", None, "Von Anfang an dabei und MetalCircle aufgebaut", "special"),
("admin", "Admin", "🏴‍☠️", None, "Verantwortung für MetalCircle", "special"),
("captns_mate", "Captns Mate", "☠️", None, "Die treue Gefährtin des Captains", "special"),
("alpha_tester", "Alpha Tester", "👑", None, "Schon in der Alpha dabei unsere frühesten Tester", "beta"),
("beta_tester", "Beta Tester", "🧪", None, "In der Beta dabei", "beta"),
("early_bird", "Early Bird", "🐦", None, "Früh Teil der MetalCircle-Community geworden", "beta"),
("first_gig", "Erster Gig", "🎸", 1, "Dein erstes besuchtes Konzert", "attendance"),
("regular", "Stammgast", "🤘", 5, "5 Konzerte am selben Veranstaltungsort besucht", "venue"),
("ten_gigs", "10 Gigs", "🔥", 10, "10 besuchte Konzerte", "attendance"),
@@ -121,7 +125,6 @@ VENUE_BADGE_CODES = tuple(
badge_code for badge_code, _name, _icon, _threshold, _description, category in BADGE_DEFINITIONS
if category == "venue"
)
BETA_REGISTRATION_DEADLINE = datetime(2026, 9, 16)
BADGE_BY_CODE = {
badge_code: (name, icon, threshold, description, category)
for badge_code, name, icon, threshold, description, category in BADGE_DEFINITIONS
@@ -527,7 +530,14 @@ def ensure_schema():
@asynccontextmanager
async def lifespan(_app: FastAPI):
ensure_schema()
yield
with get_db_connection() as connection:
community_badges.reconcile(connection.cursor())
worker = notifications.PushWorker(get_db_connection)
worker.start()
try:
yield
finally:
worker.stop()
app = FastAPI(title="MetalCircle", lifespan=lifespan)
@@ -638,9 +648,14 @@ async def localize_request(request: Request, call_next):
@app.get("/language/{language}")
def change_language(language: str, next: str = "/"):
def change_language(request: Request, language: str, next: str = "/"):
if language not in {"de", "en"}:
return HTMLResponse(_("Ungültige Sprache."), status_code=400)
if request.cookies.get(SESSION_COOKIE):
user = get_current_user(request)
if user:
with get_db_connection() as connection:
notifications.save_language(connection, user['id'], language)
response = RedirectResponse(safe_return_path(next), status_code=303)
response.set_cookie(
LANGUAGE_COOKIE, language, max_age=365 * 24 * 60 * 60,
@@ -1494,15 +1509,7 @@ def grant_earned_badges(user_id: int, stats: dict, registered_at):
with get_db_connection() as connection:
with connection.cursor() as cursor:
if registered_at < BETA_REGISTRATION_DEADLINE:
cursor.execute(
"""
INSERT INTO user_badges (user_id, badge_code)
VALUES (%s, 'beta_tester')
ON CONFLICT (user_id, badge_code) DO NOTHING
""",
(user_id,),
)
community_badges.reconcile(cursor, user_id)
for badge_code, _name, _icon, threshold, _description, category in BADGE_DEFINITIONS:
if category == "attendance" and threshold is not None and stats["total"] >= threshold:
@@ -1545,7 +1552,10 @@ def load_badge_assets():
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute("SELECT badge_code, path FROM badge_assets")
return {row[0]: row[1] for row in cursor.fetchall()}
assets = {'alpha_tester': '/static/images/patch-alpha-tester.svg',
'early_bird': '/static/images/patch-early-bird.svg'}
assets.update({row[0]: row[1] for row in cursor.fetchall()})
return assets
def load_profile(username: str):
@@ -2399,6 +2409,8 @@ def register_user(
user_id,
invite_id
))
community_badges.reconcile(cursor, user_id)
notifications.save_language(connection, user_id, current_language.get())
connection.commit()
@@ -2532,6 +2544,8 @@ def login(
with get_db_connection() as connection:
connection.execute('DELETE FROM sessions WHERE token_hash=%s', (hash_token(old_token),))
connection.commit()
with get_db_connection() as connection:
notifications.save_language(connection, row[0], current_language.get())
response = RedirectResponse(next_path, status_code=303)
return attach_session(response, create_session(row[0]))
@@ -2735,6 +2749,8 @@ def render_profile(
}
badges.sort(key=lambda badge: badge["sort_key"])
with get_db_connection() as connection:
push_preferences = notifications.preferences(connection, viewer['id'])
template = templates.get_template("profile.html")
return HTMLResponse(
template.render(
@@ -2750,6 +2766,7 @@ def render_profile(
form_error=form_error,
form_success=form_success,
instagram_input=instagram_input,
push_preferences=push_preferences,
),
status_code=status_code,
)
@@ -2766,6 +2783,8 @@ def own_profile(request: Request, saved: str = ""):
messages.append(_("Profilbild aktualisiert"))
if "instagram" in saved_items:
messages.append(_("Instagram verknüpft"))
if "notifications" in saved_items:
messages.append(_("Benachrichtigungseinstellungen gespeichert"))
return render_profile(
request,
user["username"],
@@ -2901,11 +2920,14 @@ def export_profile_data(request: Request):
)
badges = cursor.fetchall()
push_preferences = notifications.preferences(connection, user_id)
def rows_to_dicts(rows, keys):
return [dict(zip(keys, row)) for row in rows]
data = {
"export_version": 1,
"notification_preferences": push_preferences,
"exported_at": datetime.now(),
"account": dict(zip(
("id", "username", "email", "display_name", "avatar_path",
@@ -3062,10 +3084,13 @@ def send_friend_request(request: Request, username: str):
"""
INSERT INTO friendships (requester_id, addressee_id)
VALUES (%s, %s)
ON CONFLICT DO NOTHING
ON CONFLICT DO NOTHING RETURNING id
""",
(user["id"], profile["id"]),
)
created = cursor.fetchone()
if created:
notifications.enqueue(cursor, 'friend_request', user['id'], profile['id'], created[0])
connection.commit()
return RedirectResponse(f"/users/{profile['username']}", status_code=303)
@@ -3319,9 +3344,10 @@ def send_message(request: Request, username: str, body: str = Form(...)):
if not partner:
return HTMLResponse(_("Chat nicht erlaubt. Er ist für Freunde und Unterhaltungen mit Admins verfügbar."), status_code=403)
cursor.execute(
"INSERT INTO direct_messages (sender_id, recipient_id, body) VALUES (%s, %s, %s)",
"INSERT INTO direct_messages (sender_id, recipient_id, body) VALUES (%s, %s, %s) RETURNING id",
(user["id"], partner["id"], body),
)
notifications.enqueue(cursor, 'direct_message', user['id'], partner['id'], cursor.fetchone()[0])
connection.commit()
return RedirectResponse(f"/messages/{partner['username']}#latest", status_code=303)
@@ -4026,10 +4052,13 @@ async def create_concert(
INSERT INTO event_invitations (concert_id, user_id, invited_by)
SELECT %s, id, %s FROM users
WHERE id = ANY(%s) AND id <> %s
ON CONFLICT (concert_id, user_id) DO NOTHING
ON CONFLICT (concert_id, user_id) DO NOTHING RETURNING user_id
""",
(concert_id, user["id"], invited_user_ids, user["id"]),
)
for invited_id, in cursor.fetchall():
notifications.enqueue(cursor, 'event_invitation', user['id'], invited_id, concert_id,
'invitation:' + uuid.uuid4().hex)
connection.commit()
@@ -4257,10 +4286,13 @@ async def edit_concert(
"""
INSERT INTO event_invitations (concert_id, user_id, invited_by)
SELECT %s, id, %s FROM users WHERE id = ANY(%s) AND id <> %s
ON CONFLICT (concert_id, user_id) DO NOTHING
ON CONFLICT (concert_id, user_id) DO NOTHING RETURNING user_id
""",
(concert_id, user["id"], invited_user_ids, user["id"]),
)
for invited_id, in cursor.fetchall():
notifications.enqueue(cursor, 'event_invitation', user['id'], invited_id, concert_id,
'invitation:' + uuid.uuid4().hex)
elif can_manage_event_access(user, concert):
cursor.execute("DELETE FROM event_invitations WHERE concert_id = %s", (concert_id,))
if can_edit_title(user, concert):
@@ -5115,3 +5147,4 @@ def search_venues(q: str):
push_devices.register_routes(app, get_db_connection, get_current_user, SESSION_COOKIE)
bug_reporter.register_routes(app, templates, get_db_connection, get_current_user)
notifications.register_routes(app, get_db_connection, get_current_user, SESSION_COOKIE)