Rebrand application as MetalCircle

This commit is contained in:
kai
2026-08-27 18:20:40 +02:00
parent eb71ea5a0e
commit 94e14ad3e8
83 changed files with 2561 additions and 51 deletions
+189 -8
View File
@@ -21,7 +21,8 @@ except ImportError:
Image = ImageOps = UnidentifiedImageError = None
from fastapi import FastAPI, File, Form, Request, UploadFile
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.encoders import jsonable_encoder
from fastapi.staticfiles import StaticFiles
from jinja2 import Environment, FileSystemLoader, select_autoescape
@@ -77,8 +78,8 @@ INITIAL_ADMIN_PASSWORD = os.environ.get("INITIAL_ADMIN_PASSWORD")
INITIAL_ADMIN_EMAIL = os.environ.get("INITIAL_ADMIN_EMAIL")
BADGE_DEFINITIONS = (
("founder", "Gründer", "⚔️", None, "Von Anfang an dabei und Pingu Concerts mit aufgebaut", "special"),
("admin", "Admin", "🏴‍☠️", None, "Verantwortung für Pingu Concerts", "special"),
("founder", "Gründer", "⚔️", None, "Von Anfang an dabei und MetalCircle mit aufgebaut", "special"),
("admin", "Admin", "🏴‍☠️", None, "Verantwortung für MetalCircle", "special"),
("beta_tester", "Beta Tester", "🧪", None, "In der Beta dabei", "beta"),
("first_gig", "Erster Gig", "🎸", 1, "Dein erstes besuchtes Konzert", "attendance"),
("regular", "Stammgast", "🤘", 5, "5 Konzerte am selben Veranstaltungsort besucht", "attendance"),
@@ -405,7 +406,7 @@ async def lifespan(_app: FastAPI):
yield
app = FastAPI(title="Pingu Concerts", lifespan=lifespan)
app = FastAPI(title="MetalCircle", lifespan=lifespan)
@app.middleware("http")
@@ -458,6 +459,8 @@ async def require_login(request: Request, call_next):
or request.url.path == "/register"
or request.url.path.startswith("/register/")
or request.url.path.startswith("/password-reset")
or request.url.path in {"/impressum", "/datenschutz"}
or request.url.path == "/profile/export"
or request.url.path.startswith("/static/")
):
return await call_next(request)
@@ -2037,6 +2040,18 @@ def register_page(token: str):
# ============================================================
# Rechtliche Hinweise
@app.get("/impressum", response_class=HTMLResponse)
def impressum_page():
return templates.get_template("impressum.html").render()
@app.get("/datenschutz", response_class=HTMLResponse)
def privacy_page():
return templates.get_template("datenschutz.html").render()
# Login
# ============================================================
@@ -2280,6 +2295,134 @@ def own_profile(request: Request, saved: str = ""):
)
@app.get("/profile/export")
def export_profile_data(request: Request):
"""Download the authenticated user's application data as JSON."""
user = get_current_user(request)
if not user:
return login_redirect("/profile/export")
user_id = user["id"]
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"""
SELECT id, username, email, display_name, avatar_path,
instagram_url, profile_visibility, is_admin, created_at
FROM users WHERE id = %s
""",
(user_id,),
)
account = cursor.fetchone()
cursor.execute(
"""
SELECT c.id, c.artist, c.event_type, c.start_datetime, c.end_datetime,
c.venue_id, c.description, c.ticket_url, c.ticket_price,
c.flyer_path, c.flyer_url, c.visibility, c.created_at
FROM concerts c WHERE c.created_by = %s ORDER BY c.start_datetime
""",
(user_id,),
)
created_events = cursor.fetchall()
cursor.execute(
"""
SELECT concert_id, status, updated_at
FROM concert_attendance WHERE user_id = %s ORDER BY updated_at
""",
(user_id,),
)
attendance = cursor.fetchall()
cursor.execute(
"""
SELECT id, requester_id, addressee_id, status, created_at, updated_at
FROM friendships WHERE requester_id = %s OR addressee_id = %s
ORDER BY created_at
""",
(user_id, user_id),
)
friendships = cursor.fetchall()
cursor.execute(
"""
SELECT id, sender_id, recipient_id, body, read_at, created_at
FROM direct_messages WHERE sender_id = %s OR recipient_id = %s
ORDER BY created_at
""",
(user_id, user_id),
)
messages = cursor.fetchall()
cursor.execute(
"""
SELECT concert_id, invited_by, viewed_at, created_at
FROM event_invitations WHERE user_id = %s ORDER BY created_at
""",
(user_id,),
)
invitations = cursor.fetchall()
cursor.execute(
"""
SELECT id, concert_id, body, created_at
FROM concert_comments WHERE user_id = %s ORDER BY created_at
""",
(user_id,),
)
comments = cursor.fetchall()
cursor.execute(
"""
SELECT id, concert_id, path, created_at
FROM concert_photos WHERE user_id = %s ORDER BY created_at
""",
(user_id,),
)
photos = cursor.fetchall()
cursor.execute(
"""
SELECT badge_code, awarded_at FROM user_badges
WHERE user_id = %s ORDER BY awarded_at
""",
(user_id,),
)
badges = cursor.fetchall()
def rows_to_dicts(rows, keys):
return [dict(zip(keys, row)) for row in rows]
data = {
"export_version": 1,
"exported_at": datetime.now(),
"account": dict(zip(
("id", "username", "email", "display_name", "avatar_path",
"instagram_url", "profile_visibility", "is_admin", "created_at"),
account,
)) if account else None,
"created_events": rows_to_dicts(
created_events,
("id", "artist", "event_type", "start_datetime", "end_datetime", "venue_id",
"description", "ticket_url", "ticket_price", "flyer_path", "flyer_url",
"visibility", "created_at"),
),
"attendance": rows_to_dicts(attendance, ("concert_id", "status", "updated_at")),
"friendships": rows_to_dicts(friendships, ("id", "requester_id", "addressee_id", "status", "created_at", "updated_at")),
"messages": rows_to_dicts(messages, ("id", "sender_id", "recipient_id", "body", "read_at", "created_at")),
"event_invitations": rows_to_dicts(invitations, ("concert_id", "invited_by", "viewed_at", "created_at")),
"comments": rows_to_dicts(comments, ("id", "concert_id", "body", "created_at")),
"photos": rows_to_dicts(photos, ("id", "concert_id", "path", "created_at")),
"badges": rows_to_dicts(badges, ("badge_code", "awarded_at")),
}
filename = re.sub(r"[^A-Za-z0-9_-]", "_", user["username"])
return JSONResponse(
content=jsonable_encoder(data),
headers={"Content-Disposition": f'attachment; filename="pingu-concerts-{filename}-daten.json"'},
)
@app.get("/users/{username}", response_class=HTMLResponse)
def user_profile(request: Request, username: str):
return render_profile(request, username)
@@ -2375,7 +2518,7 @@ def send_friend_request(request: Request, username: str):
@app.post("/friendships/{friendship_id}/{action}")
def manage_friendship(request: Request, friendship_id: int, action: str):
def manage_friendship(request: Request, friendship_id: int, action: str, return_to: str = Form("")):
user = get_current_user(request)
if action not in {"accept", "decline", "remove"}:
return HTMLResponse("Ungültige Aktion.", status_code=400)
@@ -2405,6 +2548,8 @@ def manage_friendship(request: Request, friendship_id: int, action: str):
with connection.cursor() as cursor:
cursor.execute("SELECT username FROM users WHERE id = %s", (other_id,))
other = cursor.fetchone()
if return_to == "/messages":
return RedirectResponse("/messages", status_code=303)
return RedirectResponse(f"/users/{other[0]}" if other else "/", status_code=303)
@@ -2440,8 +2585,41 @@ def load_chat_partner(cursor, user, username: str):
def message_inbox(request: Request):
user = get_current_user(request)
conversations = []
friend_requests = []
event_invitations = []
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"""
SELECT f.id, u.username, COALESCE(u.display_name, u.username), f.created_at
FROM friendships f JOIN users u ON u.id = f.requester_id
WHERE f.addressee_id = %s AND f.status = 'pending'
ORDER BY f.created_at DESC
""",
(user["id"],),
)
friend_requests = [
{"id": row[0], "username": row[1], "display_name": row[2],
"created_at": row[3].strftime("%d.%m.%Y %H:%M")}
for row in cursor.fetchall()
]
cursor.execute(
"""
SELECT ei.concert_id, c.artist, c.start_datetime,
COALESCE(u.display_name, u.username)
FROM event_invitations ei
JOIN concerts c ON c.id = ei.concert_id
LEFT JOIN users u ON u.id = ei.invited_by
WHERE ei.user_id = %s AND ei.viewed_at IS NULL
ORDER BY ei.created_at DESC
""",
(user["id"],),
)
event_invitations = [
{"concert_id": row[0], "artist": row[1],
"date": row[2].strftime("%d.%m.%Y %H:%M"), "invited_by": row[3] or "Ein Mitglied"}
for row in cursor.fetchall()
]
cursor.execute(
"""
SELECT u.id, u.username, COALESCE(u.display_name, u.username), u.avatar_path
@@ -2475,11 +2653,14 @@ def message_inbox(request: Request):
"id": row[0], "username": row[1], "display_name": row[2],
"avatar_path": row[3], "last_message": latest[0] if latest else None,
"last_at": latest[1].strftime("%d.%m.%Y %H:%M") if latest else None,
"last_at_raw": latest[1] if latest else None,
"last_from_me": bool(latest and latest[2] == user["id"]),
"unread_count": latest[3] if latest else 0,
})
conversations.sort(key=lambda item: (item["unread_count"] > 0, item["last_at_raw"] or datetime.min), reverse=True)
template = templates.get_template("messages.html")
return template.render(user=user, conversations=conversations, partner=None, messages=[])
return template.render(user=user, conversations=conversations, friend_requests=friend_requests,
event_invitations=event_invitations, partner=None, messages=[])
@app.get("/messages/{username}", response_class=HTMLResponse)
@@ -3375,7 +3556,7 @@ def legacy_search_venues(q: str):
if not results:
headers = {
"User-Agent": "PinguConcerts/1.0"
"User-Agent": "MetalCircle/1.0"
}
external_query = q
@@ -3811,7 +3992,7 @@ def search_venues(q: str):
"namedetails": 1,
"limit": 25,
},
headers={"User-Agent": "PinguConcerts/1.0"},
headers={"User-Agent": "MetalCircle/1.0"},
timeout=8,
)
response.raise_for_status()