Add activity push notifications and registration patches
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
.env
|
||||
.env.*
|
||||
secrets/
|
||||
**/*service-account*.json
|
||||
**/*service_account*.json
|
||||
**/*firebase-adminsdk*.json
|
||||
*.pem
|
||||
*.key
|
||||
private_uploads/
|
||||
static/uploads/
|
||||
__pycache__/
|
||||
**/__pycache__/
|
||||
+1
-1
@@ -2,7 +2,7 @@ FROM python:3.13-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN pip install --no-cache-dir fastapi uvicorn "psycopg[binary]" python-multipart jinja2 httpx bcrypt Pillow
|
||||
RUN pip install --no-cache-dir fastapi uvicorn "psycopg[binary]" python-multipart jinja2 httpx bcrypt Pillow "firebase-admin==7.1.0"
|
||||
|
||||
COPY . .
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Exclusive cohorts; convert legacy DB-local registration timestamps to Europe/Berlin."""
|
||||
from datetime import date, datetime, time, timedelta
|
||||
import os
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
COHORTS = ('alpha_tester', 'beta_tester', 'early_bird')
|
||||
|
||||
|
||||
def boundaries():
|
||||
alpha = date.fromisoformat(os.environ.get('ALPHA_TESTER_UNTIL', '2026-10-31'))
|
||||
beta = date.fromisoformat(os.environ.get('BETA_TESTER_UNTIL', '2026-12-31'))
|
||||
if beta <= alpha:
|
||||
raise ValueError('BETA_TESTER_UNTIL must be after ALPHA_TESTER_UNTIL')
|
||||
return (datetime.combine(alpha + timedelta(days=1), time()),
|
||||
datetime.combine(beta + timedelta(days=1), time()))
|
||||
|
||||
|
||||
def cohort(registered_at):
|
||||
if registered_at.tzinfo:
|
||||
registered_at = registered_at.astimezone(ZoneInfo('Europe/Berlin')).replace(tzinfo=None)
|
||||
alpha_end, beta_end = boundaries()
|
||||
return 'alpha_tester' if registered_at < alpha_end else 'beta_tester' if registered_at < beta_end else 'early_bird'
|
||||
|
||||
|
||||
def reconcile(cursor, user_id=None):
|
||||
"""Also upgrades existing beta members and reclassifies when configured dates change."""
|
||||
alpha_end, beta_end = boundaries()
|
||||
cursor.execute('''
|
||||
DELETE FROM user_badges b USING users u WHERE b.user_id=u.id
|
||||
AND (%s::integer IS NULL OR u.id=%s)
|
||||
AND b.badge_code=ANY(%s) AND b.badge_code <> CASE
|
||||
WHEN u.created_at AT TIME ZONE current_setting('TimeZone') AT TIME ZONE 'Europe/Berlin' < %s THEN 'alpha_tester'
|
||||
WHEN u.created_at AT TIME ZONE current_setting('TimeZone') AT TIME ZONE 'Europe/Berlin' < %s THEN 'beta_tester' ELSE 'early_bird' END
|
||||
''', (user_id, user_id, list(COHORTS), alpha_end, beta_end))
|
||||
cursor.execute('''
|
||||
INSERT INTO user_badges(user_id,badge_code,awarded_at)
|
||||
SELECT id, CASE
|
||||
WHEN created_at AT TIME ZONE current_setting('TimeZone') AT TIME ZONE 'Europe/Berlin' < %s THEN 'alpha_tester'
|
||||
WHEN created_at AT TIME ZONE current_setting('TimeZone') AT TIME ZONE 'Europe/Berlin' < %s THEN 'beta_tester'
|
||||
ELSE 'early_bird' END, created_at
|
||||
FROM users WHERE (%s::integer IS NULL OR id=%s)
|
||||
ON CONFLICT(user_id,badge_code) DO NOTHING
|
||||
''', (alpha_end, beta_end, user_id, user_id))
|
||||
+29
-1
@@ -1,4 +1,4 @@
|
||||
"""Startup equivalents of migrations 19 and 20 (kept in sync by tests)."""
|
||||
"""Startup equivalents of migrations 19–22 (kept in sync by tests)."""
|
||||
|
||||
FEATURE_SCHEMA = (
|
||||
'''CREATE TABLE IF NOT EXISTS push_devices (
|
||||
@@ -27,4 +27,32 @@ FEATURE_SCHEMA = (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_bug_report_submissions_user
|
||||
ON bug_report_submissions(user_id, submitted_at);''',
|
||||
'''CREATE TABLE IF NOT EXISTS notification_preferences (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
language VARCHAR(2) NOT NULL DEFAULT 'de' CHECK (language IN ('de', 'en')),
|
||||
friend_request BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
direct_message BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
event_invitation BOOLEAN NOT NULL DEFAULT TRUE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS push_notifications (
|
||||
id UUID PRIMARY KEY,
|
||||
device_id BIGINT NOT NULL REFERENCES push_devices(id) ON DELETE CASCADE,
|
||||
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
recipient_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
actor_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
kind VARCHAR(32) NOT NULL CHECK (kind IN ('friend_request', 'direct_message', 'event_invitation')),
|
||||
object_id BIGINT NOT NULL,
|
||||
event_key TEXT NOT NULL,
|
||||
token_hash VARCHAR(64) NOT NULL,
|
||||
state VARCHAR(16) NOT NULL DEFAULT 'pending' CHECK (state IN ('pending', 'sent', 'dropped', 'failed')),
|
||||
attempts SMALLINT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + INTERVAL '1 hour',
|
||||
available_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(device_id, event_key)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_push_notifications_pending
|
||||
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');''',
|
||||
)
|
||||
|
||||
@@ -460,6 +460,27 @@
|
||||
"Wirklich unwiderruflich löschen? Die Veranstaltung kann nicht mehr eingetragen werden.": "Really delete permanently? This event cannot be added again.",
|
||||
"Diesen Nutzer wirklich blockieren?": "Really block this user?",
|
||||
"Account wirklich dauerhaft löschen?": "Really delete your account permanently?",
|
||||
"Alpha Tester": "Alpha Tester",
|
||||
"Early Bird": "Early Bird",
|
||||
"Schon in der Alpha dabei – unsere frühesten Tester": "Part of the alpha – our earliest testers",
|
||||
"Früh Teil der MetalCircle-Community geworden": "An early member of the MetalCircle community",
|
||||
"Zu Englisch wechseln": "Switch to English",
|
||||
"Zu Deutsch wechseln": "Switch to German",
|
||||
"Push-Benachrichtigungen": "Push notifications",
|
||||
"Wähle, welche Hinweise du auf deinen angemeldeten Android-Geräten erhalten möchtest. Nachrichteninhalte werden nicht angezeigt.": "Choose which alerts to receive on your signed-in Android devices. Message contents are never displayed.",
|
||||
"Freundschaftsanfragen": "Friend requests",
|
||||
"Direktnachrichten": "Direct messages",
|
||||
"Veranstaltungseinladungen": "Event invitations",
|
||||
"Benachrichtigungen speichern": "Save notifications",
|
||||
"Benachrichtigungseinstellungen gespeichert": "Notification preferences saved",
|
||||
"Neue Freundschaftsanfrage": "New friend request",
|
||||
"Du hast eine neue Freundschaftsanfrage.": "You have a new friend request.",
|
||||
"Neue Nachricht": "New message",
|
||||
"Du hast eine neue Nachricht.": "You have a new message.",
|
||||
"Neue Veranstaltungseinladung": "New event invitation",
|
||||
"Du wurdest zu einer Veranstaltung eingeladen.": "You have been invited to an event.",
|
||||
"Neue Benachrichtigung öffnen": "Open new notification",
|
||||
"Für automatische Pushs speichern wir deine Sprache, gewählte Kategorien und Versandmetadaten. Push-Texte enthalten keine privaten Nachrichteninhalte. Der aktive Versanddienst löscht Versandmetadaten nach sieben Tagen; beim Abmelden werden die zur Sitzung gehörenden Aufträge entfernt.": "For automatic push notifications, we store your language, selected categories and delivery metadata. Push texts contain no private message content. The active delivery worker deletes delivery metadata after seven days; logging out removes jobs belonging to that session.",
|
||||
"🐛 Bug melden": "🐛 Report a bug",
|
||||
"Bug melden · MetalCircle": "Report a bug · MetalCircle",
|
||||
"Hilf uns, MetalCircle zu verbessern.": "Help us improve MetalCircle.",
|
||||
|
||||
+50
-17
@@ -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)
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Transactional push outbox, recipient preferences and bounded background delivery.
|
||||
|
||||
No message bodies, display names, FCM tokens or credentials are stored in the outbox.
|
||||
"""
|
||||
from datetime import timedelta
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from urllib.parse import quote
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import Form, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from i18n import ENGLISH, current_language
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
KINDS = ('friend_request', 'direct_message', 'event_invitation')
|
||||
TEXT = {
|
||||
'friend_request': ('Neue Freundschaftsanfrage', 'Du hast eine neue Freundschaftsanfrage.'),
|
||||
'direct_message': ('Neue Nachricht', 'Du hast eine neue Nachricht.'),
|
||||
'event_invitation': ('Neue Veranstaltungseinladung', 'Du wurdest zu einer Veranstaltung eingeladen.'),
|
||||
}
|
||||
|
||||
|
||||
def enabled():
|
||||
return os.environ.get('PUSH_ENABLED', 'false').lower() in ('1', 'true', 'yes')
|
||||
|
||||
|
||||
def save_language(db, user_id, language):
|
||||
db.execute('''INSERT INTO notification_preferences(user_id,language) VALUES (%s,%s)
|
||||
ON CONFLICT(user_id) DO UPDATE SET language=EXCLUDED.language''',
|
||||
(user_id, language if language in ('de', 'en') else 'de'))
|
||||
|
||||
|
||||
def preferences(db, user_id):
|
||||
row = db.execute('SELECT language,friend_request,direct_message,event_invitation '
|
||||
'FROM notification_preferences WHERE user_id=%s', (user_id,)).fetchone()
|
||||
return dict(zip(('language', *KINDS), row or ('de', True, True, True)))
|
||||
|
||||
|
||||
def enqueue(cursor, kind, actor_id, recipient_id, object_id, event_key=None):
|
||||
"""Called inside the domain write transaction; disabled means no backlog accumulation."""
|
||||
if not enabled() or actor_id == recipient_id:
|
||||
return
|
||||
if kind not in KINDS:
|
||||
raise ValueError('Unknown notification kind')
|
||||
cursor.execute('SELECT friend_request,direct_message,event_invitation FROM notification_preferences WHERE user_id=%s',
|
||||
(recipient_id,))
|
||||
pref = cursor.fetchone()
|
||||
if pref and not pref[KINDS.index(kind)]:
|
||||
return
|
||||
if kind == 'event_invitation':
|
||||
# A removed and subsequently re-added invitation replaces its previous pending delivery.
|
||||
cursor.execute("UPDATE push_notifications SET state='dropped' WHERE kind='event_invitation' "
|
||||
"AND object_id=%s AND recipient_id=%s AND state='pending'", (object_id, recipient_id))
|
||||
cursor.execute('''SELECT p.id,p.session_id,p.token FROM push_devices p
|
||||
JOIN sessions s ON s.id=p.session_id
|
||||
WHERE p.user_id=%s AND s.user_id=%s AND s.expires_at>CURRENT_TIMESTAMP
|
||||
AND p.last_seen_at>CURRENT_TIMESTAMP-INTERVAL '90 days' ''', (recipient_id, recipient_id))
|
||||
for device_id, session_id, token in cursor.fetchall():
|
||||
cursor.execute('''INSERT INTO push_notifications
|
||||
(id,device_id,session_id,recipient_id,actor_id,kind,object_id,event_key,token_hash)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s) ON CONFLICT(device_id,event_key) DO NOTHING''',
|
||||
(uuid4(), device_id, session_id, recipient_id, actor_id, kind, object_id,
|
||||
event_key or f'{kind}:{object_id}', hashlib.sha256(token.encode()).hexdigest()))
|
||||
|
||||
|
||||
def destination(db, kind, actor_id, recipient_id, object_id):
|
||||
"""Recheck current authorization and event state immediately before sending/opening."""
|
||||
if db.execute('''SELECT 1 FROM user_blocks WHERE
|
||||
(blocker_id=%s AND blocked_id=%s) OR (blocker_id=%s AND blocked_id=%s)''',
|
||||
(actor_id, recipient_id, recipient_id, actor_id)).fetchone():
|
||||
return None
|
||||
actor = db.execute('SELECT username,is_admin FROM users WHERE id=%s', (actor_id,)).fetchone()
|
||||
recipient = db.execute('SELECT is_admin FROM users WHERE id=%s', (recipient_id,)).fetchone()
|
||||
if not actor or not recipient:
|
||||
return None
|
||||
if kind == 'friend_request':
|
||||
valid = db.execute("SELECT 1 FROM friendships WHERE id=%s AND requester_id=%s AND addressee_id=%s AND status='pending'",
|
||||
(object_id, actor_id, recipient_id)).fetchone()
|
||||
return '/users/' + quote(actor[0], safe='') if valid else None
|
||||
if kind == 'direct_message':
|
||||
valid = db.execute('SELECT 1 FROM direct_messages WHERE id=%s AND sender_id=%s AND recipient_id=%s AND read_at IS NULL',
|
||||
(object_id, actor_id, recipient_id)).fetchone()
|
||||
allowed = actor[1] or recipient[0] or db.execute("""SELECT 1 FROM friendships WHERE status='accepted'
|
||||
AND ((requester_id=%s AND addressee_id=%s) OR (requester_id=%s AND addressee_id=%s))""",
|
||||
(actor_id, recipient_id, recipient_id, actor_id)).fetchone()
|
||||
return '/messages/' + quote(actor[0], safe='') + '#latest' if valid and allowed else None
|
||||
if kind == 'event_invitation':
|
||||
valid = db.execute('''SELECT 1 FROM event_invitations i JOIN concerts c ON c.id=i.concert_id
|
||||
WHERE i.concert_id=%s AND i.user_id=%s AND i.invited_by=%s AND i.viewed_at IS NULL
|
||||
AND (c.visibility IN ('public','private') OR c.created_by=%s OR %s OR EXISTS (
|
||||
SELECT 1 FROM friendships f WHERE f.status='accepted' AND
|
||||
((f.requester_id=c.created_by AND f.addressee_id=%s) OR
|
||||
(f.addressee_id=c.created_by AND f.requester_id=%s))))''',
|
||||
(object_id, recipient_id, actor_id, recipient_id, recipient[0], recipient_id, recipient_id)).fetchone()
|
||||
return '/concerts/' + str(object_id) if valid else None
|
||||
return None
|
||||
|
||||
|
||||
class DeliveryError(Exception):
|
||||
def __init__(self, code):
|
||||
self.code = code
|
||||
super().__init__(code)
|
||||
|
||||
|
||||
class FirebaseSender:
|
||||
def __init__(self):
|
||||
self.app = None
|
||||
|
||||
def send(self, token, title, body, data, tag):
|
||||
# Lazy import/init: missing credentials never prevent the core app from starting.
|
||||
try:
|
||||
import firebase_admin
|
||||
from firebase_admin import credentials, messaging
|
||||
if self.app is None:
|
||||
path = os.environ.get('GOOGLE_APPLICATION_CREDENTIALS', '')
|
||||
project = os.environ.get('FIREBASE_PROJECT_ID', '')
|
||||
if not path or not project:
|
||||
raise DeliveryError('configuration')
|
||||
credential = credentials.Certificate(path)
|
||||
if credential.project_id != project:
|
||||
raise DeliveryError('configuration')
|
||||
self.app = firebase_admin.initialize_app(credential, {'projectId': project, 'httpTimeout': 10},
|
||||
name='metalcircle-push-' + uuid4().hex)
|
||||
messaging.send(messaging.Message(token=token, notification=messaging.Notification(title=title, body=body),
|
||||
data=data, android=messaging.AndroidConfig(priority='high', ttl=timedelta(minutes=5),
|
||||
notification=messaging.AndroidNotification(tag=tag, icon='ic_notification',
|
||||
sound='default', visibility='private'))), app=self.app)
|
||||
except DeliveryError:
|
||||
raise
|
||||
except Exception as error:
|
||||
# Never log SDK errors: they may contain requests, credentials or FCM tokens.
|
||||
code = getattr(error, 'code', '')
|
||||
name = type(error).__name__
|
||||
if name == 'UnregisteredError':
|
||||
raise DeliveryError('unregistered') from None
|
||||
if code in ('UNAVAILABLE', 'INTERNAL', 'DEADLINE_EXCEEDED', 'RESOURCE_EXHAUSTED'):
|
||||
raise DeliveryError('transient') from None
|
||||
if isinstance(error, (OSError, ValueError, ImportError)) or code in ('UNAUTHENTICATED', 'PERMISSION_DENIED'):
|
||||
raise DeliveryError('configuration') from None
|
||||
raise DeliveryError('permanent') from None
|
||||
|
||||
|
||||
class PushWorker:
|
||||
def __init__(self, get_db, sender=None):
|
||||
self.get_db = get_db
|
||||
self.sender = sender or FirebaseSender()
|
||||
self.stop_event = threading.Event()
|
||||
self.thread = None
|
||||
|
||||
def start(self):
|
||||
if enabled():
|
||||
self.thread = threading.Thread(target=self.run, name='metalcircle-push', daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
def stop(self):
|
||||
self.stop_event.set()
|
||||
if self.thread:
|
||||
self.thread.join(timeout=15)
|
||||
|
||||
def run(self):
|
||||
while not self.stop_event.is_set():
|
||||
try:
|
||||
busy = self.deliver_one()
|
||||
except Exception:
|
||||
logger.warning('Push worker: database_or_processing_failure')
|
||||
busy = False
|
||||
self.stop_event.wait(0.1 if busy else 3)
|
||||
|
||||
def deliver_one(self):
|
||||
with self.get_db() as db:
|
||||
db.execute("DELETE FROM push_notifications WHERE created_at<CURRENT_TIMESTAMP-INTERVAL '7 days'")
|
||||
row = db.execute('''SELECT id,device_id,session_id,recipient_id,actor_id,kind,object_id,token_hash,attempts,
|
||||
expires_at>CURRENT_TIMESTAMP FROM push_notifications WHERE state='pending'
|
||||
AND available_at<=CURRENT_TIMESTAMP ORDER BY available_at LIMIT 1 FOR UPDATE SKIP LOCKED''').fetchone()
|
||||
if not row:
|
||||
return False
|
||||
job, device_id, session_id, recipient, actor, kind, obj, token_hash, attempts, fresh = row
|
||||
# Match registration's lock order. NOWAIT avoids a deadlock with session deletion's cascade.
|
||||
session = db.execute('SELECT token_hash FROM sessions WHERE id=%s AND user_id=%s '
|
||||
'AND expires_at>CURRENT_TIMESTAMP FOR UPDATE NOWAIT', (session_id, recipient)).fetchone()
|
||||
device = db.execute('SELECT token FROM push_devices WHERE id=%s AND session_id=%s AND user_id=%s '
|
||||
'FOR UPDATE NOWAIT', (device_id, session_id, recipient)).fetchone()
|
||||
prefs = preferences(db, recipient)
|
||||
target = destination(db, kind, actor, recipient, obj) if fresh and prefs[kind] else None
|
||||
if not session or not device or not target or hashlib.sha256(device[0].encode()).hexdigest() != token_hash:
|
||||
db.execute("UPDATE push_notifications SET state='dropped' WHERE id=%s", (job,))
|
||||
return True
|
||||
title, body = TEXT[kind]
|
||||
if prefs['language'] == 'en':
|
||||
title, body = ENGLISH.get(title, title), ENGLISH.get(body, body)
|
||||
try:
|
||||
self.sender.send(device[0], title, body,
|
||||
{'notification_id': str(job), 'session_tag': session[0]}, str(job))
|
||||
except DeliveryError as error:
|
||||
logger.warning('Push delivery failed: %s', error.code)
|
||||
if error.code == 'unregistered':
|
||||
db.execute('DELETE FROM push_devices WHERE id=%s AND token=%s', (device_id, device[0]))
|
||||
elif error.code in ('transient', 'configuration') and attempts < 3:
|
||||
db.execute("UPDATE push_notifications SET attempts=attempts+1, available_at=CURRENT_TIMESTAMP + %s * INTERVAL '1 second' WHERE id=%s",
|
||||
(60 * 2 ** attempts, job))
|
||||
else:
|
||||
db.execute("UPDATE push_notifications SET state='failed',attempts=attempts+1 WHERE id=%s", (job,))
|
||||
else:
|
||||
db.execute("UPDATE push_notifications SET state='sent',attempts=attempts+1 WHERE id=%s", (job,))
|
||||
return True
|
||||
|
||||
|
||||
def register_routes(app, get_db, get_user, cookie_name):
|
||||
@app.post('/profile/notifications')
|
||||
def update_preferences(request: Request, friend_request: bool = Form(False),
|
||||
direct_message: bool = Form(False), event_invitation: bool = Form(False)):
|
||||
user = get_user(request)
|
||||
with get_db() as db:
|
||||
save_language(db, user['id'], current_language.get())
|
||||
db.execute('''UPDATE notification_preferences SET friend_request=%s,direct_message=%s,event_invitation=%s
|
||||
WHERE user_id=%s''', (friend_request, direct_message, event_invitation, user['id']))
|
||||
# Turning a category off cancels pending notifications, even if quickly enabled again.
|
||||
disabled = [kind for kind, value in zip(KINDS, (friend_request, direct_message, event_invitation)) if not value]
|
||||
db.execute("UPDATE push_notifications SET state='dropped' WHERE recipient_id=%s AND state='pending' AND kind=ANY(%s)",
|
||||
(user['id'], disabled))
|
||||
return RedirectResponse('/profile?saved=notifications#notification-settings', status_code=303)
|
||||
|
||||
@app.get('/notifications/{identifier}')
|
||||
def open_notification(request: Request, identifier: str):
|
||||
from uuid import UUID
|
||||
try:
|
||||
identifier = UUID(identifier)
|
||||
except ValueError:
|
||||
return RedirectResponse('/', status_code=303)
|
||||
user = get_user(request)
|
||||
with get_db() as db:
|
||||
row = db.execute('''SELECT kind,actor_id,object_id FROM push_notifications n JOIN sessions s ON s.id=n.session_id
|
||||
WHERE n.id=%s AND n.recipient_id=%s AND s.token_hash=%s AND s.expires_at>CURRENT_TIMESTAMP''',
|
||||
(identifier, user['id'], hashlib.sha256(request.cookies.get(cookie_name, '').encode()).hexdigest())).fetchone()
|
||||
target = destination(db, row[0], row[1], user['id'], row[2]) if row else None
|
||||
return RedirectResponse(target or '/', status_code=303)
|
||||
+4
-1
@@ -1,4 +1,4 @@
|
||||
"""Device registration only. This module deliberately contains no push sender."""
|
||||
"""Device registration. Background sending lives in notifications.py."""
|
||||
|
||||
import hashlib
|
||||
from uuid import UUID
|
||||
@@ -6,6 +6,8 @@ from uuid import UUID
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from i18n import current_language
|
||||
from notifications import save_language
|
||||
|
||||
|
||||
class DeviceRegistration(BaseModel):
|
||||
@@ -59,6 +61,7 @@ def register_routes(app, get_db, get_user, cookie_name):
|
||||
RETURNING id
|
||||
''', (user['id'], session[0], data.device_id, data.token, data.platform, data.app_version))
|
||||
device_id = cursor.fetchone()[0]
|
||||
save_language(connection, user['id'], current_language.get())
|
||||
cursor.execute('DELETE FROM push_devices WHERE session_id IN '
|
||||
'(SELECT id FROM sessions WHERE expires_at<=CURRENT_TIMESTAMP) '
|
||||
'OR last_seen_at<CURRENT_TIMESTAMP - INTERVAL \'90 days\'')
|
||||
|
||||
@@ -609,3 +609,9 @@ h1, .page-title h1 {
|
||||
.bug-success { color:#bbf7d0; }
|
||||
.bug-report-form button:disabled { opacity:.6; cursor:wait; }
|
||||
@media(max-width:600px) { .bug-report-options { grid-template-columns:1fr; } }
|
||||
.notification-preferences { display: grid; gap: 12px; margin: 16px 0; }
|
||||
.notification-preferences label { display: flex; gap: 10px; align-items: center; }
|
||||
.notification-preferences input[type="checkbox"] { width: auto; }
|
||||
.patch.earned.patch-alpha_tester { border: 2px solid #e7bc58; box-shadow: 0 0 12px #e7bc5855; background: #211b0f; }
|
||||
.patch.earned.patch-beta_tester { border: 1px solid #a7b4c5; }
|
||||
.patch.earned.patch-early_bird { border: 1px solid #b4835e; }
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 210" role="img" aria-label="Alpha Tester">
|
||||
<path fill="#151313" stroke="#e8bf63" stroke-width="5" d="M90 6 170 36v99q-12 45-80 69-68-24-80-69V36Z"/>
|
||||
<path fill="none" stroke="#a88236" stroke-width="1.5" stroke-dasharray="3 3" d="M90 15 161 43v89q-10 39-71 62-61-23-71-62V43Z"/>
|
||||
<path fill="#e8bf63" d="m57 37 16 10 17-20 17 20 16-10-7 27H64Z"/>
|
||||
<path fill="#e8bf63" d="m90 73 31 65h-17l-5-13H81l-5 13H59Zm0 26-5 14h10Z"/>
|
||||
<text x="90" y="157" fill="#f5dfab" text-anchor="middle" font-family="sans-serif" font-weight="700" font-size="18" letter-spacing="2">ALPHA</text>
|
||||
<text x="90" y="177" fill="#d4b97a" text-anchor="middle" font-family="sans-serif" font-size="11" letter-spacing="3">TESTER</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 775 B |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 210" role="img" aria-label="Early Bird">
|
||||
<path fill="#171514" stroke="#b98b66" stroke-width="4" d="M90 9 167 38v95q-11 43-77 67-66-24-77-67V38Z"/>
|
||||
<path fill="none" stroke="#947153" stroke-dasharray="3 3" d="M90 18 158 45v85q-10 38-68 60-58-22-68-60V45Z"/>
|
||||
<path fill="#c8996f" d="m40 65 44 32 33-29 15 11 16 3-15 9-12 25-33 18-26-14 21-4-28-14 26 1Z"/>
|
||||
<circle cx="125" cy="80" r="2.5" fill="#171514"/>
|
||||
<text x="90" y="155" fill="#e2be9f" text-anchor="middle" font-family="sans-serif" font-weight="700" font-size="17" letter-spacing="1">EARLY BIRD</text>
|
||||
<text x="90" y="177" fill="#b98b66" text-anchor="middle" font-family="sans-serif" font-size="9" letter-spacing="2">METALCIRCLE</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 766 B |
@@ -15,6 +15,17 @@
|
||||
let sendQueue = Promise.resolve();
|
||||
let panel;
|
||||
|
||||
async function notificationTarget(notification) {
|
||||
const data = notification?.data || {};
|
||||
if (!/^[a-f0-9-]{36}$/.test(data.notification_id || '') || !/^[a-f0-9]{64}$/.test(data.session_tag || '')) return null;
|
||||
const response = await fetch('/api/push/session', {credentials: 'same-origin', cache: 'no-store'});
|
||||
if (!response.ok || stopped) return null;
|
||||
const session = await response.json();
|
||||
const native = await device.getInfo();
|
||||
if (!session.authenticated || session.session_tag !== data.session_tag || native.binding !== data.session_tag) return null;
|
||||
return '/notifications/' + data.notification_id;
|
||||
}
|
||||
|
||||
function notice(message, offerPermission = false) {
|
||||
if (!panel) {
|
||||
panel = document.createElement('section');
|
||||
@@ -130,9 +141,29 @@
|
||||
});
|
||||
}),
|
||||
push.addListener('registrationError', () => { if (!stopped) notice(texts.failed); }),
|
||||
push.addListener('pushNotificationActionPerformed', () => {
|
||||
// Do not navigate to arbitrary URLs supplied by a notification payload.
|
||||
location.assign('/');
|
||||
push.addListener('pushNotificationActionPerformed', async event => {
|
||||
try {
|
||||
const target = await notificationTarget(event.notification);
|
||||
if (target) location.assign(target);
|
||||
} catch (_) { /* A tap never bypasses current-session authorization. */ }
|
||||
}),
|
||||
push.addListener('pushNotificationReceived', async notification => {
|
||||
try {
|
||||
const target = await notificationTarget(notification);
|
||||
if (!target) return;
|
||||
document.getElementById('push-in-app-notice')?.remove();
|
||||
const banner = document.createElement('section');
|
||||
banner.id = 'push-in-app-notice';
|
||||
banner.className = 'native-push-panel';
|
||||
banner.setAttribute('role', 'status');
|
||||
const link = document.createElement('a');
|
||||
link.className = 'button';
|
||||
link.href = target;
|
||||
// Generic localized text; never insert remote HTML or private message content.
|
||||
link.textContent = texts.openNotification;
|
||||
banner.append(link);
|
||||
(document.querySelector('main') || document.body).prepend(banner);
|
||||
} catch (_) { /* Push reception cannot interrupt use of the app. */ }
|
||||
})
|
||||
]).then(() => {
|
||||
synchronize();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<nav class="language-switch" aria-label="{{ _('Sprache') }}">
|
||||
<a href="{{ language_url('de') }}" lang="de" hreflang="de" aria-label="Deutsch"{% if language() == 'de' %} aria-current="true" class="active"{% endif %}>DE</a>
|
||||
<a href="{{ language_url('en') }}" lang="en" hreflang="en" aria-label="English"{% if language() == 'en' %} aria-current="true" class="active"{% endif %}>EN</a>
|
||||
{% set target_language = 'en' if language() == 'de' else 'de' %}
|
||||
<a class="active" href="{{ language_url(target_language) }}" lang="{{ target_language }}" hreflang="{{ target_language }}" aria-label="{{ _('Zu Englisch wechseln') if target_language == 'en' else _('Zu Deutsch wechseln') }}" title="{{ _('Zu Englisch wechseln') if target_language == 'en' else _('Zu Deutsch wechseln') }}">{{ target_language | upper }}</a>
|
||||
</nav>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script id="native-push-config" type="application/json">{{ {
|
||||
'openNotification': _('Neue Benachrichtigung öffnen'),
|
||||
'permission': _('Möchtest du Benachrichtigungen von MetalCircle auf diesem Gerät erhalten?'),
|
||||
'enable': _('Benachrichtigungen aktivieren'),
|
||||
'later': _('Später'),
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
<h2>{{ _('Weitergabe und externe Dienste') }}</h2>
|
||||
<p>{{ _('Es werden keine Werbe- oder Trackingdienste eingesetzt. Bei der Veranstaltungsortsuche können Suchanfragen an einen externen Geocoding-Dienst übermittelt werden. Externe Flyer- und Instagram-Links werden beim Aufruf direkt von deinem Browser geladen; dafür gelten die Datenschutzbestimmungen des jeweiligen Anbieters.') }}</p>
|
||||
<p>{{ _('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.') }}</p>
|
||||
<p>{{ _('Für automatische Pushs speichern wir deine Sprache, gewählte Kategorien und Versandmetadaten. Push-Texte enthalten keine privaten Nachrichteninhalte. Der aktive Versanddienst löscht Versandmetadaten nach sieben Tagen; beim Abmelden werden die zur Sitzung gehörenden Aufträge entfernt.') }}</p>
|
||||
<p>{{ _('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.') }}</p>
|
||||
<h2>{{ _('Deine Rechte') }}</h2>
|
||||
<p>{{ _('Du kannst Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und – soweit anwendbar – Datenübertragbarkeit verlangen. Einen Export deiner gespeicherten Anwendungsdaten findest du klein am Ende des eigenen Profilbereichs. Anfragen bitte an') }} <a href="mailto:konzert@pinguholic.de">konzert@pinguholic.de</a>.</p>
|
||||
|
||||
@@ -135,7 +135,7 @@
|
||||
{% for badge in badges %}
|
||||
{% if badge.earned %}
|
||||
<button class="patch-button" type="button" onclick="document.getElementById('patch-{{ badge.code }}').showModal()" aria-label="{{ _('Details zu') }} {{ badge.name | t }} {{ _('anzeigen') }}">
|
||||
<span class="patch earned {% if badge.image_path %}patch-image-frame{% else %}patch-icon-frame{% endif %}" title="{{ badge.name | t }} – {{ badge.description | t }}">
|
||||
<span class="patch earned patch-{{ badge.code }} {% if badge.image_path %}patch-image-frame{% else %}patch-icon-frame{% endif %}" title="{{ badge.name | t }} – {{ badge.description | t }}">
|
||||
{% if badge.image_path %}
|
||||
<img class="patch-image" src="{{ badge.image_path }}" alt="Patch {{ badge.name | t }}">
|
||||
{% else %}
|
||||
@@ -243,6 +243,16 @@
|
||||
<span class="avatar-upload-status" id="avatar-upload-status">{{ _('Große Bilder werden vor dem Upload automatisch optimiert.') }}</span>
|
||||
<button class="button" type="submit">{{ _('Profil speichern') }}</button>
|
||||
</form>
|
||||
<section id="notification-settings">
|
||||
<h2>{{ _('Push-Benachrichtigungen') }}</h2>
|
||||
<p>{{ _('Wähle, welche Hinweise du auf deinen angemeldeten Android-Geräten erhalten möchtest. Nachrichteninhalte werden nicht angezeigt.') }}</p>
|
||||
<form method="post" action="/profile/notifications" class="notification-preferences">
|
||||
{% for kind, label in [('friend_request', 'Freundschaftsanfragen'), ('direct_message', 'Direktnachrichten'), ('event_invitation', 'Veranstaltungseinladungen')] %}
|
||||
<label><input type="checkbox" name="{{ kind }}" value="true"{% if push_preferences is not defined or push_preferences[kind] %} checked{% endif %}> {{ label | t }}</label>
|
||||
{% endfor %}
|
||||
<button class="button" type="submit">{{ _('Benachrichtigungen speichern') }}</button>
|
||||
</form>
|
||||
</section>
|
||||
<div class="account-actions">
|
||||
<details class="account-delete">
|
||||
<summary>{{ _('Account löschen') }}</summary>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
const {test} = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const vm = require('node:vm');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const source = fs.readFileSync(path.join(__dirname, '../static/js/native-push.js'), 'utf8');
|
||||
const id = '12345678-1234-1234-1234-123456789abc';
|
||||
const tag = 'a'.repeat(64);
|
||||
|
||||
async function setup(options={}) {
|
||||
const listeners = {}, navigations = [], elements = [];
|
||||
const session = {authenticated:true, session_tag:tag, ...options.session};
|
||||
const config = {textContent:JSON.stringify({openNotification:'Open new notification'})};
|
||||
const main = {prepend(node) { elements.push(node); }};
|
||||
const document = {
|
||||
getElementById(name) { return name === 'native-push-config' ? config : null; },
|
||||
querySelector(name) { return name === 'main' ? main : null; },
|
||||
createElement(type) { return {type,children:[],append(node){this.children.push(node);},setAttribute(){}}; },
|
||||
addEventListener() {}, body:main,
|
||||
};
|
||||
const push = {
|
||||
addListener(name, callback) { listeners[name]=callback; return Promise.resolve(); },
|
||||
checkPermissions:async()=>({receive:'granted'}), register:async()=>{},
|
||||
};
|
||||
const device = {getInfo:async()=>({deviceId:id,appVersion:'1.1.0',binding:options.binding ?? tag}), prepareSession:async()=>{}};
|
||||
vm.runInNewContext(source, {document, window:{Capacitor:{getPlatform:()=> 'android',Plugins:{PushNotifications:push,MetalCircleDevice:device}},addEventListener(){}},
|
||||
location:{pathname:'/',assign(value){navigations.push(value);}},
|
||||
fetch:async()=>({ok:true,json:async()=>session}), localStorage:{getItem(){return 'seen';}}});
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
return {listeners,navigations,elements};
|
||||
}
|
||||
|
||||
test('tap opens only backend-resolved destination for the matching session',async()=>{
|
||||
const app=await setup();
|
||||
await app.listeners.pushNotificationActionPerformed({notification:{data:{notification_id:id,session_tag:tag,url:'https://evil.invalid'}}});
|
||||
assert.deepEqual(app.navigations,['/notifications/'+id]);
|
||||
});
|
||||
test('old-account push cannot navigate after user switch',async()=>{
|
||||
const app=await setup({session:{session_tag:'b'.repeat(64)}});
|
||||
await app.listeners.pushNotificationActionPerformed({notification:{data:{notification_id:id,session_tag:tag}}});
|
||||
assert.deepEqual(app.navigations,[]);
|
||||
});
|
||||
test('logout and native binding mismatch cannot open a notification',async()=>{
|
||||
for(const options of [{session:{authenticated:false}}, {binding:''}]) {
|
||||
const app=await setup(options);
|
||||
await app.listeners.pushNotificationActionPerformed({notification:{data:{notification_id:id,session_tag:tag}}});
|
||||
assert.deepEqual(app.navigations,[]);
|
||||
}
|
||||
});
|
||||
test('arbitrary URL and malformed identifier are ignored',async()=>{
|
||||
const app=await setup();
|
||||
for(const data of [{url:'https://evil.invalid'}, {notification_id:'../../profile',session_tag:tag}])
|
||||
await app.listeners.pushNotificationActionPerformed({notification:{data}});
|
||||
assert.deepEqual(app.navigations,[]);
|
||||
});
|
||||
test('foreground hint uses local text and never remote HTML',async()=>{
|
||||
const app=await setup();
|
||||
await app.listeners.pushNotificationReceived({body:'<script>private message</script>',data:{notification_id:id,session_tag:tag}});
|
||||
assert.equal(app.elements.length,1);
|
||||
const link=app.elements[0].children[0];
|
||||
assert.equal(link.textContent,'Open new notification');
|
||||
assert.equal(link.href,'/notifications/'+id);
|
||||
assert.equal(link.innerHTML,undefined);
|
||||
});
|
||||
@@ -34,12 +34,16 @@ 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, 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 direct_messages(recipient_id INTEGER, read_at TIMESTAMP);
|
||||
CREATE TABLE event_invitations(user_id INTEGER, viewed_at TIMESTAMP);
|
||||
CREATE TABLE user_badges(user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
badge_code TEXT, awarded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY(user_id,badge_code));
|
||||
''')
|
||||
for statement in FEATURE_SCHEMA: db.execute(statement)
|
||||
cls.password_hash = bcrypt.hashpw(b'Test-only-password-123', bcrypt.gensalt()).decode()
|
||||
@@ -81,7 +85,8 @@ class FeatureApiTests(unittest.TestCase):
|
||||
|
||||
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'), FEATURE_SCHEMA):
|
||||
for name, runtime in zip(('19_push_devices.sql', '20_bug_report_submissions.sql',
|
||||
'21_push_notifications.sql', '22_registration_badges.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))
|
||||
|
||||
@@ -69,7 +69,7 @@ class TranslationTests(unittest.TestCase):
|
||||
for path, title in [('/datenschutz', 'Privacy policy'), ('/impressum', 'Legal notice')]:
|
||||
page = client.get(path)
|
||||
self.assertIn(title, page.text)
|
||||
self.assertIn('aria-label="English" aria-current="true"', page.text)
|
||||
self.assertIn('aria-label="Switch to German"', page.text)
|
||||
response = client.get('/language/de?next=/login')
|
||||
self.assertIn('Anmelden', response.text)
|
||||
self.assertIn('<html lang="de">', response.text)
|
||||
@@ -136,7 +136,8 @@ class TranslationTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(ValueError, 'Instagram') as error:
|
||||
main.normalize_instagram_url('https://evil.example/test')
|
||||
self.assertIn('Please enter', str(error.exception))
|
||||
response = main.change_language('invalid')
|
||||
from starlette.requests import Request
|
||||
response = main.change_language(Request({'type': 'http', 'headers': []}), 'invalid')
|
||||
self.assertEqual(response.body, b'Invalid language.')
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
"""Local PostgreSQL and simulated Firebase tests. Never contacts Firebase."""
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
from uuid import uuid4
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import bcrypt
|
||||
import psycopg
|
||||
from psycopg import sql
|
||||
from psycopg.conninfo import make_conninfo
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import main
|
||||
from community_badges import cohort, reconcile
|
||||
from i18n import current_language
|
||||
from notifications import DeliveryError, FirebaseSender, PushWorker, enqueue, save_language
|
||||
|
||||
|
||||
class BadgeAndLanguageTests(unittest.TestCase):
|
||||
def test_registration_boundaries_and_timezone(self):
|
||||
with patch.dict(os.environ, {'ALPHA_TESTER_UNTIL':'2026-10-31', 'BETA_TESTER_UNTIL':'2026-12-31'}):
|
||||
for value, expected in (
|
||||
(datetime(2026, 10, 31, 23, 59, 59), 'alpha_tester'),
|
||||
(datetime(2026, 11, 1), 'beta_tester'),
|
||||
(datetime(2026, 12, 31, 23, 59, 59), 'beta_tester'),
|
||||
(datetime(2027, 1, 1), 'early_bird'),
|
||||
(datetime(2026, 10, 31, 23, tzinfo=ZoneInfo('UTC')), 'beta_tester')):
|
||||
self.assertEqual(cohort(value), expected)
|
||||
|
||||
def test_configurable_dates_and_invalid_order(self):
|
||||
with patch.dict(os.environ, {'ALPHA_TESTER_UNTIL':'2026-11-30', 'BETA_TESTER_UNTIL':'2027-01-31'}):
|
||||
self.assertEqual(cohort(datetime(2026, 11, 15)), 'alpha_tester')
|
||||
self.assertEqual(cohort(datetime(2027, 1, 1)), 'beta_tester')
|
||||
with patch.dict(os.environ, {'ALPHA_TESTER_UNTIL':'2027-02-01', 'BETA_TESTER_UNTIL':'2027-01-31'}):
|
||||
with self.assertRaises(ValueError): cohort(datetime(2026, 1, 1))
|
||||
|
||||
def test_one_language_link_targets_opposite_language(self):
|
||||
import re
|
||||
for language, target in [('de', 'en'), ('en', 'de')]:
|
||||
token = current_language.set(language)
|
||||
try:
|
||||
html = main.templates.get_template('_language_switch.html').render()
|
||||
finally:
|
||||
current_language.reset(token)
|
||||
self.assertEqual(len(re.findall(r'<a\s', html)), 1)
|
||||
self.assertIn('/language/' + target, html)
|
||||
self.assertIn('>' + target.upper() + '</a>', html)
|
||||
|
||||
def test_sender_missing_credentials_is_safe(self):
|
||||
with patch.dict(os.environ, {'GOOGLE_APPLICATION_CREDENTIALS':'', 'FIREBASE_PROJECT_ID':''}):
|
||||
with self.assertRaisesRegex(DeliveryError, '^configuration$'):
|
||||
FirebaseSender().send('secret-token', 'title', 'body', {}, 'tag')
|
||||
|
||||
def test_sdk_payload_errors_and_no_credentials_in_payload(self):
|
||||
from firebase_admin import messaging, exceptions
|
||||
sender = FirebaseSender()
|
||||
sender.app = object()
|
||||
with patch.object(messaging, 'send') as send:
|
||||
sender.send('synthetic-token', 'New message', 'You have a new message.',
|
||||
{'notification_id':str(uuid4()), 'session_tag':'a'*64}, 'tag')
|
||||
payload = send.call_args.args[0]
|
||||
self.assertEqual(payload.android.notification.visibility, 'private')
|
||||
self.assertEqual(payload.android.ttl.total_seconds(), 300)
|
||||
self.assertEqual(payload.notification.body, 'You have a new message.')
|
||||
for failure, expected in ((messaging.UnregisteredError('sensitive'), 'unregistered'),
|
||||
(exceptions.UnavailableError('sensitive'), 'transient'),
|
||||
(exceptions.PermissionDeniedError('sensitive'), 'configuration')):
|
||||
with patch.object(messaging, 'send', side_effect=failure):
|
||||
with self.assertRaisesRegex(DeliveryError, '^' + expected + '$'):
|
||||
sender.send('secret-token', 'title', 'body', {}, 'tag')
|
||||
|
||||
|
||||
@unittest.skipUnless(os.environ.get('METALCIRCLE_TEST_DATABASE') == '1', 'explicit local DB opt-in required')
|
||||
class NotificationDatabaseTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.original_dsn = main.DATABASE_URL
|
||||
cls.schema = 'metalcircle_push_test_' + uuid4().hex
|
||||
with psycopg.connect(cls.original_dsn) as db:
|
||||
db.execute(sql.SQL('CREATE SCHEMA {}').format(sql.Identifier(cls.schema)))
|
||||
main.DATABASE_URL = make_conninfo(cls.original_dsn, options='-csearch_path='+cls.schema)
|
||||
with main.get_db_connection() as db:
|
||||
db.execute(Path('/test-init/01_initial.sql').read_text())
|
||||
with patch.object(main, 'INITIAL_ADMIN_USERNAME', None): main.ensure_schema()
|
||||
cls.password_hash = bcrypt.hashpw(b'Push-local-test-123', bcrypt.gensalt()).decode()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
main.DATABASE_URL = cls.original_dsn
|
||||
with psycopg.connect(cls.original_dsn) as db:
|
||||
db.execute(sql.SQL('DROP SCHEMA {} CASCADE').format(sql.Identifier(cls.schema)))
|
||||
|
||||
def setUp(self):
|
||||
self.flags = patch.dict(os.environ, {'PUSH_ENABLED':'true', 'ALPHA_TESTER_UNTIL':'2026-10-31', 'BETA_TESTER_UNTIL':'2026-12-31'})
|
||||
self.flags.start()
|
||||
self.secure = patch.object(main, 'COOKIE_SECURE', False)
|
||||
self.secure.start()
|
||||
main.rate_limit_buckets.clear()
|
||||
with main.get_db_connection() as db:
|
||||
db.execute('TRUNCATE users,concerts RESTART IDENTITY CASCADE')
|
||||
for name in ('sender', 'recipient', 'outsider'):
|
||||
db.execute('INSERT INTO users(username,email,password_hash,created_at) VALUES (%s,%s,%s,%s)',
|
||||
(name, name+'@example.invalid', self.password_hash, datetime(2026, 9, 1)))
|
||||
self.clients = []
|
||||
for name in ('sender', 'recipient', 'outsider'):
|
||||
client = TestClient(main.app)
|
||||
client.headers['Origin'] = 'http://testserver'
|
||||
self.assertEqual(client.post('/login', data={'username':name, 'password':'Push-local-test-123'}, follow_redirects=False).status_code, 303)
|
||||
self.clients.append(client)
|
||||
self.device = dict(device_id=str(uuid4()), token='synthetic-fcm-token-'+'x'*120, platform='android',
|
||||
session_tag=self.clients[1].get('/api/push/session').json()['session_tag'])
|
||||
self.assertEqual(self.clients[1].post('/api/push/devices', json=self.device).status_code, 200)
|
||||
self.sender = Mock()
|
||||
self.worker = PushWorker(main.get_db_connection, self.sender)
|
||||
|
||||
def tearDown(self):
|
||||
for client in self.clients: client.close()
|
||||
self.secure.stop()
|
||||
self.flags.stop()
|
||||
|
||||
def scalar(self, query, args=()):
|
||||
with main.get_db_connection() as db: return db.execute(query, args).fetchone()[0]
|
||||
|
||||
def friendship(self):
|
||||
result = self.clients[0].post('/users/recipient/friend-request', follow_redirects=False)
|
||||
self.assertEqual(result.status_code, 303)
|
||||
|
||||
def message(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('/messages/recipient', data={'body':'PRIVATE message content'}, follow_redirects=False)
|
||||
self.assertEqual(result.status_code, 303)
|
||||
|
||||
def invitation(self):
|
||||
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)
|
||||
return int(result.headers['location'].rsplit('/', 1)[1])
|
||||
|
||||
def test_friend_event_deduplicated_and_english_recipient(self):
|
||||
self.clients[1].get('/language/en', follow_redirects=False)
|
||||
self.friendship()
|
||||
self.friendship()
|
||||
self.assertEqual(self.scalar('SELECT count(*) FROM push_notifications'), 1)
|
||||
self.assertTrue(self.worker.deliver_one())
|
||||
args = self.sender.send.call_args.args
|
||||
self.assertEqual(args[1], 'New friend request')
|
||||
self.assertNotIn('PRIVATE', str(args))
|
||||
target = self.clients[1].get('/notifications/'+args[3]['notification_id'], follow_redirects=False)
|
||||
self.assertEqual(target.headers['location'], '/users/sender')
|
||||
denied = self.clients[2].get('/notifications/'+args[3]['notification_id'], follow_redirects=False)
|
||||
self.assertEqual(denied.headers['location'], '/')
|
||||
|
||||
def test_message_route_and_private_content(self):
|
||||
self.message()
|
||||
self.worker.deliver_one()
|
||||
args = self.sender.send.call_args.args
|
||||
self.assertEqual(args[1:3], ('Neue Nachricht', 'Du hast eine neue Nachricht.'))
|
||||
self.assertNotIn('PRIVATE message content', str(args))
|
||||
response = self.clients[1].get('/notifications/'+args[3]['notification_id'], follow_redirects=False)
|
||||
self.assertEqual(response.headers['location'], '/messages/sender#latest')
|
||||
|
||||
def test_invitation_route_and_removed_invitation(self):
|
||||
concert = self.invitation()
|
||||
self.worker.deliver_one()
|
||||
args = self.sender.send.call_args.args
|
||||
response = self.clients[1].get('/notifications/'+args[3]['notification_id'], follow_redirects=False)
|
||||
self.assertEqual(response.headers['location'], '/concerts/'+str(concert))
|
||||
with main.get_db_connection() as db: db.execute('DELETE FROM event_invitations')
|
||||
response = self.clients[1].get('/notifications/'+args[3]['notification_id'], follow_redirects=False)
|
||||
self.assertEqual(response.headers['location'], '/')
|
||||
|
||||
def test_disabled_sender_has_no_backlog(self):
|
||||
with patch.dict(os.environ, {'PUSH_ENABLED':'false'}): self.friendship()
|
||||
self.assertEqual(self.scalar('SELECT count(*) FROM push_notifications'), 0)
|
||||
|
||||
def test_invitation_edit_only_notifies_new_invitees(self):
|
||||
concert = self.invitation()
|
||||
form = {'artist':'Private test event','start_datetime':'2027-04-01T20:00',
|
||||
'event_type':'other','visibility':'private','invited_user_ids':'2'}
|
||||
result = self.clients[0].post(f'/concerts/{concert}/edit', data=form, follow_redirects=False)
|
||||
self.assertEqual(result.status_code, 303)
|
||||
self.assertEqual(self.scalar("SELECT count(*) FROM push_notifications WHERE state='pending'"), 1)
|
||||
form.pop('invited_user_ids')
|
||||
self.clients[0].post(f'/concerts/{concert}/edit', data=form, follow_redirects=False)
|
||||
form['invited_user_ids'] = '2'
|
||||
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 test_profile_preferences_and_alpha_render_in_both_languages(self):
|
||||
for language, label in [('de', 'Push-Benachrichtigungen'), ('en', 'Push notifications')]:
|
||||
self.clients[1].get('/language/'+language, follow_redirects=False)
|
||||
response = self.clients[1].get('/profile')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(label, response.text)
|
||||
self.assertIn('patch-alpha-tester.svg', response.text)
|
||||
self.assertNotIn('id="patch-beta_tester"', response.text)
|
||||
exported = self.clients[1].get('/profile/export')
|
||||
self.assertEqual(exported.status_code, 200)
|
||||
self.assertEqual(exported.json()['notification_preferences']['language'], language)
|
||||
|
||||
def test_enqueue_rolls_back_with_domain_transaction(self):
|
||||
try:
|
||||
with main.get_db_connection() as db:
|
||||
request_id = db.execute('INSERT INTO friendships(requester_id,addressee_id) VALUES (1,2) RETURNING id').fetchone()[0]
|
||||
enqueue(db.cursor(), 'friend_request', 1, 2, request_id)
|
||||
raise RuntimeError('simulated rollback')
|
||||
except RuntimeError:
|
||||
pass
|
||||
self.assertEqual(self.scalar('SELECT count(*) FROM push_notifications'), 0)
|
||||
self.assertEqual(self.scalar('SELECT count(*) FROM friendships'), 0)
|
||||
|
||||
def test_preference_opt_out_and_csrf_auth(self):
|
||||
unauth = TestClient(main.app)
|
||||
self.assertEqual(unauth.post('/profile/notifications', follow_redirects=False).status_code, 303)
|
||||
self.assertEqual(self.clients[1].post('/profile/notifications', headers={'Origin':'http://evil.invalid'}).status_code, 403)
|
||||
self.friendship()
|
||||
result = self.clients[1].post('/profile/notifications', data={'direct_message':'true'}, follow_redirects=False)
|
||||
self.assertEqual(result.status_code, 303)
|
||||
self.assertEqual(self.scalar("SELECT count(*) FROM push_notifications WHERE state='dropped'"), 1)
|
||||
self.assertFalse(self.worker.deliver_one())
|
||||
self.sender.send.assert_not_called()
|
||||
|
||||
def test_disabled_category_never_enqueues(self):
|
||||
self.clients[1].post('/profile/notifications', data={}, follow_redirects=False)
|
||||
self.friendship()
|
||||
self.assertEqual(self.scalar('SELECT count(*) FROM push_notifications'), 0)
|
||||
|
||||
def test_logout_cancels_queue_and_switch_does_not_receive_old_push(self):
|
||||
self.friendship()
|
||||
self.clients[1].post('/logout', follow_redirects=False)
|
||||
self.assertEqual(self.scalar('SELECT count(*) FROM push_notifications'), 0)
|
||||
self.assertFalse(self.worker.deliver_one())
|
||||
self.sender.send.assert_not_called()
|
||||
|
||||
def test_token_rotation_invalidates_old_delivery(self):
|
||||
self.friendship()
|
||||
self.device['token'] = 'synthetic-fcm-token-'+'y'*120
|
||||
self.clients[1].post('/api/push/devices', json=self.device)
|
||||
self.worker.deliver_one()
|
||||
self.sender.send.assert_not_called()
|
||||
self.assertEqual(self.scalar('SELECT state FROM push_notifications'), 'dropped')
|
||||
|
||||
def test_multiple_devices_each_receive_once(self):
|
||||
another = dict(self.device, device_id=str(uuid4()), token='synthetic-fcm-token-'+'z'*120)
|
||||
self.clients[1].post('/api/push/devices', json=another)
|
||||
self.friendship()
|
||||
self.worker.deliver_one()
|
||||
self.worker.deliver_one()
|
||||
self.assertEqual(self.sender.send.call_count, 2)
|
||||
self.assertFalse(self.worker.deliver_one())
|
||||
|
||||
def test_read_message_block_and_removed_friendship_drop_pending(self):
|
||||
for operation in ('read', 'block', 'unfriend'):
|
||||
with self.subTest(operation=operation):
|
||||
self.message()
|
||||
with main.get_db_connection() as db:
|
||||
if operation == 'read': db.execute('UPDATE direct_messages SET read_at=CURRENT_TIMESTAMP')
|
||||
elif operation == 'block': db.execute('INSERT INTO user_blocks(blocker_id,blocked_id) VALUES (2,1)')
|
||||
else: db.execute('DELETE FROM friendships')
|
||||
self.worker.deliver_one()
|
||||
self.sender.send.assert_not_called()
|
||||
with main.get_db_connection() as db: db.execute('DELETE FROM user_blocks')
|
||||
|
||||
def test_retry_limit_and_safe_logging(self):
|
||||
self.friendship()
|
||||
self.sender.send.side_effect = DeliveryError('transient')
|
||||
with self.assertLogs('notifications', 'WARNING') as logs:
|
||||
for attempt in range(4):
|
||||
self.worker.deliver_one()
|
||||
with main.get_db_connection() as db:
|
||||
db.execute('UPDATE push_notifications SET available_at=CURRENT_TIMESTAMP')
|
||||
self.assertNotIn(self.device['token'], str(logs.output))
|
||||
self.assertEqual(self.scalar('SELECT attempts FROM push_notifications'), 4)
|
||||
self.assertEqual(self.scalar('SELECT state FROM push_notifications'), 'failed')
|
||||
self.assertEqual(self.clients[1].get('/impressum').status_code, 200)
|
||||
|
||||
def test_unregistered_removes_device_but_configuration_error_does_not(self):
|
||||
self.friendship()
|
||||
self.sender.send.side_effect = DeliveryError('configuration')
|
||||
self.worker.deliver_one()
|
||||
self.assertEqual(self.scalar('SELECT count(*) FROM push_devices'), 1)
|
||||
with main.get_db_connection() as db: db.execute('UPDATE push_notifications SET available_at=CURRENT_TIMESTAMP')
|
||||
self.sender.send.side_effect = DeliveryError('unregistered')
|
||||
self.worker.deliver_one()
|
||||
self.assertEqual(self.scalar('SELECT count(*) FROM push_devices'), 0)
|
||||
|
||||
def test_expired_job_and_expired_session_are_dropped(self):
|
||||
self.friendship()
|
||||
with main.get_db_connection() as db: db.execute("UPDATE push_notifications SET expires_at=CURRENT_TIMESTAMP-INTERVAL '1 second'")
|
||||
self.worker.deliver_one()
|
||||
self.sender.send.assert_not_called()
|
||||
with main.get_db_connection() as db:
|
||||
db.execute("UPDATE push_notifications SET expires_at=CURRENT_TIMESTAMP+INTERVAL '1 hour',state='pending'")
|
||||
db.execute("UPDATE sessions SET expires_at=CURRENT_TIMESTAMP-INTERVAL '1 second' WHERE user_id=2")
|
||||
self.worker.deliver_one()
|
||||
self.sender.send.assert_not_called()
|
||||
|
||||
def test_two_workers_do_not_send_same_job_twice(self):
|
||||
self.friendship()
|
||||
workers = [PushWorker(main.get_db_connection, self.sender) for _ in range(2)]
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
list(pool.map(lambda worker: worker.deliver_one(), workers))
|
||||
self.assertEqual(self.sender.send.call_count, 1)
|
||||
|
||||
def test_backfill_is_exclusive_idempotent_and_reconfigurable(self):
|
||||
with main.get_db_connection() as db:
|
||||
db.execute("INSERT INTO user_badges(user_id,badge_code) VALUES (1,'beta_tester')")
|
||||
db.execute("UPDATE users SET created_at='2026-11-01' WHERE id=2")
|
||||
db.execute("UPDATE users SET created_at='2027-01-01' WHERE id=3")
|
||||
reconcile(db.cursor())
|
||||
reconcile(db.cursor())
|
||||
rows = db.execute('SELECT user_id,badge_code FROM user_badges ORDER BY user_id').fetchall()
|
||||
self.assertEqual(rows, [(1,'alpha_tester'),(2,'beta_tester'),(3,'early_bird')])
|
||||
with patch.dict(os.environ, {'ALPHA_TESTER_UNTIL':'2026-11-30'}): reconcile(db.cursor())
|
||||
self.assertEqual(db.execute('SELECT badge_code FROM user_badges WHERE user_id=2').fetchone()[0], 'alpha_tester')
|
||||
|
||||
def test_db_utc_registration_respects_berlin_midnight(self):
|
||||
with main.get_db_connection() as db:
|
||||
db.execute("SET LOCAL TIME ZONE 'UTC'")
|
||||
db.execute("UPDATE users SET created_at='2026-10-31 22:59:59' WHERE id=1")
|
||||
db.execute("UPDATE users SET created_at='2026-10-31 23:00:00' WHERE id=2")
|
||||
db.execute("UPDATE users SET created_at='2026-12-31 23:00:00' WHERE id=3")
|
||||
reconcile(db.cursor())
|
||||
rows = db.execute('SELECT user_id,badge_code FROM user_badges ORDER BY user_id').fetchall()
|
||||
self.assertEqual(rows, [(1,'alpha_tester'),(2,'beta_tester'),(3,'early_bird')])
|
||||
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
Reference in New Issue
Block a user