Files
pingu-concerts/app/main.py
T
2026-08-25 04:08:13 +00:00

1382 lines
30 KiB
Python

import os
import uuid
import secrets
import hashlib
import httpx
import psycopg
from datetime import datetime
from fastapi import FastAPI, Form, UploadFile, File
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from jinja2 import Environment, FileSystemLoader, select_autoescape
app = FastAPI(title="Pingu Concerts")
DATABASE_URL = os.environ["DATABASE_URL"]
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
UPLOAD_DIR = os.path.join(
BASE_DIR,
"static",
"uploads",
"flyers"
)
os.makedirs(
UPLOAD_DIR,
exist_ok=True
)
# ============================================================
# Templates
# ============================================================
templates = Environment(
loader=FileSystemLoader(
os.path.join(BASE_DIR, "templates")
),
autoescape=select_autoescape(["html"])
)
# ============================================================
# Static files
# ============================================================
app.mount(
"/static",
StaticFiles(
directory=os.path.join(BASE_DIR, "static")
),
name="static"
)
# ============================================================
# Database
# ============================================================
def get_db_connection():
return psycopg.connect(DATABASE_URL)
# ============================================================
# Token helpers
# ============================================================
def hash_token(token: str) -> str:
return hashlib.sha256(
token.encode("utf-8")
).hexdigest()
def generate_token() -> str:
return secrets.token_urlsafe(32)
@app.get("/admin/invites")
def create_invite():
token = secrets.token_urlsafe(32)
token_hash = hash_token(token)
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute("""
INSERT INTO registration_invites (
token_hash
)
VALUES (%s)
RETURNING id
""", (
token_hash,
))
invite_id = cursor.fetchone()[0]
connection.commit()
return {
"invite_id": invite_id,
"invite_url": f"/register/{token}"
}
@app.post("/register")
def register_user(
token: str = Form(...),
username: str = Form(...),
display_name: str = Form(""),
email: str = Form(...),
password: str = Form(...)
):
username = username.strip()
display_name = display_name.strip()
email = email.strip().lower()
if len(username) < 3:
return HTMLResponse(
"<h1>Fehler</h1><p>Der Benutzername muss mindestens 3 Zeichen lang sein.</p>",
status_code=400
)
if len(password) < 8:
return HTMLResponse(
"<h1>Fehler</h1><p>Das Passwort muss mindestens 8 Zeichen lang sein.</p>",
status_code=400
)
with get_db_connection() as connection:
with connection.cursor() as cursor:
# Einladung prüfen
cursor.execute("""
SELECT
id,
expires_at,
used_at
FROM registration_invites
WHERE token_hash = %s
""", (
hash_token(token),
))
invite = cursor.fetchone()
if not invite:
return HTMLResponse(
"<h1>Ungültige Einladung</h1>",
status_code=404
)
invite_id, expires_at, used_at = invite
if used_at:
return HTMLResponse(
"<h1>Diese Einladung wurde bereits verwendet.</h1>",
status_code=410
)
if expires_at and datetime.now() > expires_at:
return HTMLResponse(
"<h1>Diese Einladung ist abgelaufen.</h1>",
status_code=410
)
# Prüfen ob Username bereits existiert
cursor.execute("""
SELECT id
FROM users
WHERE LOWER(username) = LOWER(%s)
""", (
username,
))
if cursor.fetchone():
return HTMLResponse(
"<h1>Fehler</h1><p>Dieser Benutzername ist bereits vergeben.</p>",
status_code=400
)
# Prüfen ob E-Mail bereits existiert
cursor.execute("""
SELECT id
FROM users
WHERE LOWER(email) = LOWER(%s)
""", (
email,
))
if cursor.fetchone():
return HTMLResponse(
"<h1>Fehler</h1><p>Diese E-Mail-Adresse ist bereits registriert.</p>",
status_code=400
)
# Passwort hashen
import bcrypt
password_hash = bcrypt.hashpw(
password.encode("utf-8"),
bcrypt.gensalt()
).decode("utf-8")
# Benutzer anlegen
cursor.execute("""
INSERT INTO users (
username,
email,
password_hash,
display_name
)
VALUES (
%s,
%s,
%s,
%s
)
RETURNING id
""", (
username,
email,
password_hash,
display_name or username
))
user_id = cursor.fetchone()[0]
# Einladung verbrauchen
cursor.execute("""
UPDATE registration_invites
SET
used_by = %s,
used_at = CURRENT_TIMESTAMP
WHERE id = %s
""", (
user_id,
invite_id
))
connection.commit()
return HTMLResponse(
f"""
<h1>Account erstellt 🎸</h1>
<p>Willkommen bei Pingu Concerts, {display_name or username}!</p>
<p>Dein Account wurde erfolgreich erstellt.</p>
<p>
<a href="/">Zu Pingu Concerts</a>
</p>
"""
)
# ============================================================
# Registration
# ============================================================
@app.get(
"/register/{token}",
response_class=HTMLResponse
)
def register_page(token: str):
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute("""
SELECT
id,
expires_at,
used_at
FROM registration_invites
WHERE token_hash = %s
""", (
hash_token(token),
))
invite = cursor.fetchone()
if not invite:
return HTMLResponse(
"<h1>Ungültige Einladung</h1>",
status_code=404
)
invite_id, expires_at, used_at = invite
if used_at:
return HTMLResponse(
"<h1>Diese Einladung wurde bereits verwendet.</h1>",
status_code=410
)
if expires_at:
from datetime import datetime
if datetime.now() > expires_at:
return HTMLResponse(
"<h1>Diese Einladung ist abgelaufen.</h1>",
status_code=410
)
template = templates.get_template(
"register.html"
)
return template.render(
token=token
)
# ============================================================
# Home
# ============================================================
@app.get("/", response_class=HTMLResponse)
def home():
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute("""
SELECT
concerts.id,
concerts.artist,
concerts.start_datetime,
venues.name,
venues.city
FROM concerts
LEFT JOIN venues
ON concerts.venue_id = venues.id
ORDER BY concerts.start_datetime
""")
rows = cursor.fetchall()
concerts = []
for row in rows:
(
concert_id,
artist,
start_datetime,
venue,
city
) = row
venue_text = venue or "Veranstaltungsort unbekannt"
if city:
venue_text += f", {city}"
concerts.append({
"id": concert_id,
"artist": artist,
"date": start_datetime.strftime("%d.%m.%Y"),
"time": start_datetime.strftime("%H:%M"),
"venue": venue_text
})
template = templates.get_template(
"index.html"
)
return template.render(
concerts=concerts
)
# ============================================================
# New concert
# ============================================================
@app.get(
"/concerts/new",
response_class=HTMLResponse
)
def new_concert():
template = templates.get_template(
"new_concert.html"
)
return template.render()
# ============================================================
# Concert detail
# ============================================================
@app.get(
"/concerts/{concert_id}",
response_class=HTMLResponse
)
def concert_detail(concert_id: int):
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute("""
SELECT
concerts.id,
concerts.artist,
concerts.start_datetime,
concerts.end_datetime,
concerts.description,
concerts.ticket_url,
concerts.ticket_price,
concerts.flyer_path,
venues.name,
venues.street,
venues.postal_code,
venues.city,
venues.country
FROM concerts
LEFT JOIN venues
ON concerts.venue_id = venues.id
WHERE concerts.id = %s
""", (
concert_id,
))
row = cursor.fetchone()
if not row:
return HTMLResponse(
"<h1>Konzert nicht gefunden</h1>",
status_code=404
)
(
concert_id,
artist,
start_datetime,
end_datetime,
description,
ticket_url,
ticket_price,
flyer_path,
venue_name,
venue_street,
venue_postal_code,
venue_city,
venue_country
) = row
concert = {
"id": concert_id,
"artist": artist,
"date":
start_datetime.strftime(
"%d.%m.%Y"
),
"time":
start_datetime.strftime(
"%H:%M"
),
"end_date":
end_datetime.strftime(
"%d.%m.%Y"
)
if end_datetime
else None,
"end_time":
end_datetime.strftime(
"%H:%M"
)
if end_datetime
else None,
"description":
description,
"ticket_url":
ticket_url,
"ticket_price":
ticket_price,
"flyer_path":
flyer_path,
"venue": {
"name":
venue_name
or "Veranstaltungsort unbekannt",
"street":
venue_street,
"postal_code":
venue_postal_code,
"city":
venue_city,
"country":
venue_country
}
}
template = templates.get_template(
"concert_detail.html"
)
return template.render(
concert=concert
)
# ============================================================
# Create concert
# ============================================================
@app.post("/concerts")
async def create_concert(
artist: str = Form(...),
venue_id: str = Form(""),
venue_name: str = Form(""),
city: str = Form(""),
street: str = Form(""),
postal_code: str = Form(""),
country: str = Form(
"Deutschland"
),
latitude: str = Form(""),
longitude: str = Form(""),
start_datetime: str = Form(...),
end_datetime: str = Form(""),
description: str = Form(""),
ticket_url: str = Form(""),
ticket_price: str = Form(""),
flyer: UploadFile | None = File(None)
):
flyer_path = None
# ========================================================
# Flyer speichern
# ========================================================
if flyer and flyer.filename:
allowed_extensions = {
".jpg",
".jpeg",
".png",
".webp"
}
original_name = flyer.filename
extension = os.path.splitext(
original_name
)[1].lower()
if extension not in allowed_extensions:
return HTMLResponse(
"Ungültiges Flyer-Format. "
"Erlaubt sind JPG, JPEG, PNG und WEBP.",
status_code=400
)
filename = (
str(uuid.uuid4())
+ extension
)
destination = os.path.join(
UPLOAD_DIR,
filename
)
contents = await flyer.read()
# 10 MB Limit
if len(contents) > 10 * 1024 * 1024:
return HTMLResponse(
"Der Flyer darf maximal 10 MB groß sein.",
status_code=400
)
with open(
destination,
"wb"
) as file:
file.write(contents)
flyer_path = (
"/static/uploads/flyers/"
+ filename
)
# ========================================================
# Datenbank
# ========================================================
with get_db_connection() as connection:
with connection.cursor() as cursor:
selected_venue_id = None
# ==================================================
# Venue auswählen
# ==================================================
if venue_id:
if venue_id.startswith(
"nominatim:"
):
external_id = venue_id.split(
":",
1
)[1]
cursor.execute("""
SELECT id
FROM venues
WHERE
external_id = %s
AND source = 'nominatim'
LIMIT 1
""", (
external_id,
))
existing_venue = (
cursor.fetchone()
)
if existing_venue:
selected_venue_id = (
existing_venue[0]
)
else:
cursor.execute("""
INSERT INTO venues (
name,
street,
postal_code,
city,
country,
latitude,
longitude,
external_id,
source
)
VALUES (
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s
)
RETURNING id
""", (
venue_name,
street or None,
postal_code or None,
city or None,
country
or "Deutschland",
float(latitude)
if latitude
else None,
float(longitude)
if longitude
else None,
external_id,
"nominatim"
))
selected_venue_id = (
cursor.fetchone()[0]
)
else:
try:
selected_venue_id = int(
venue_id
)
except ValueError:
selected_venue_id = None
# ==================================================
# Manueller Venue-Fallback
# ==================================================
if (
not selected_venue_id
and venue_name
):
cursor.execute("""
SELECT id
FROM venues
WHERE
LOWER(name)
= LOWER(%s)
AND LOWER(
COALESCE(city, '')
)
= LOWER(%s)
LIMIT 1
""", (
venue_name,
city
))
existing_venue = (
cursor.fetchone()
)
if existing_venue:
selected_venue_id = (
existing_venue[0]
)
else:
cursor.execute("""
INSERT INTO venues (
name,
street,
postal_code,
city,
country,
latitude,
longitude
)
VALUES (
%s,
%s,
%s,
%s,
%s,
%s,
%s
)
RETURNING id
""", (
venue_name,
street or None,
postal_code or None,
city or None,
country
or "Deutschland",
float(latitude)
if latitude
else None,
float(longitude)
if longitude
else None
))
selected_venue_id = (
cursor.fetchone()[0]
)
# ==================================================
# Konzert speichern
# ==================================================
cursor.execute("""
INSERT INTO concerts (
artist,
venue_id,
start_datetime,
end_datetime,
description,
ticket_url,
ticket_price,
flyer_path
)
VALUES (
%s,
%s,
%s,
%s,
%s,
%s,
%s,
%s
)
RETURNING id
""", (
artist,
selected_venue_id,
start_datetime,
end_datetime
or None,
description
or None,
ticket_url
or None,
ticket_price
or None,
flyer_path
))
concert_id = (
cursor.fetchone()[0]
)
connection.commit()
return RedirectResponse(
f"/concerts/{concert_id}",
status_code=303
)
# ============================================================
# Venue search
# ============================================================
@app.get("/api/venues/search")
def search_venues(q: str):
q = q.strip()
if len(q) < 2:
return []
results = []
# ========================================================
# Local database
# ========================================================
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute("""
SELECT
id,
name,
street,
postal_code,
city,
country,
latitude,
longitude,
external_id,
source
FROM venues
WHERE
name ILIKE %s
OR city ILIKE %s
OR street ILIKE %s
ORDER BY
CASE
WHEN LOWER(name) = LOWER(%s)
THEN 0
WHEN LOWER(name) LIKE LOWER(%s)
THEN 1
WHEN LOWER(name) LIKE LOWER(%s)
THEN 2
ELSE 3
END,
name
LIMIT 10
""", (
f"%{q}%",
f"%{q}%",
f"%{q}%",
q,
f"{q}%",
f"%{q}%"
))
rows = cursor.fetchall()
for row in rows:
(
venue_id,
name,
street,
postal_code,
city,
country,
latitude,
longitude,
external_id,
source
) = row
results.append({
"id": venue_id,
"name": name,
"street": street,
"postal_code": postal_code,
"city": city,
"country": country,
"latitude": latitude,
"longitude": longitude,
"external_id": external_id,
"source": source,
"local": True
})
# ========================================================
# Nominatim
# ========================================================
if not results:
headers = {
"User-Agent": "PinguConcerts/1.0"
}
params = {
"q": q,
"format": "jsonv2",
"addressdetails": 1,
"limit": 20,
"countrycodes": "de"
}
try:
response = httpx.get(
"https://nominatim.openstreetmap.org/search",
params=params,
headers=headers,
timeout=8
)
response.raise_for_status()
data = response.json()
candidates = []
venue_types = {
"music_venue",
"concert_hall",
"stadium",
"sports_centre",
"theatre",
"arts_centre",
"exhibition_hall",
"conference_centre",
"events_venue",
"nightclub",
"community_centre",
"social_centre",
"festival",
"arena",
"auditorium",
"dance",
"cinema"
}
venue_keywords = [
"halle",
"arena",
"stadion",
"stadium",
"club",
"klub",
"theater",
"theatre",
"bühne",
"buehne",
"concert",
"konzert",
"music",
"musik",
"festival",
"event",
"venue",
"zentrum",
"center",
"centre",
"matrix",
"turbinenhalle",
"westfalenhalle"
]
excluded_types = {
"street",
"road",
"residential",
"postcode",
"house",
"railway",
"bus_stop",
"station",
"person"
}
for item in data:
address = item.get(
"address",
{}
)
name = (
item.get("name")
or ""
).strip()
display_name = (
item.get("display_name")
or ""
)
if not name:
name = display_name.split(
","
)[0].strip()
if not name:
continue
osm_type = (
item.get("type")
or ""
).lower()
osm_class = (
item.get("class")
or ""
).lower()
if osm_type in excluded_types:
continue
name_lower = name.lower()
query_lower = q.lower()
display_lower = display_name.lower()
score = 0
if name_lower == query_lower:
score += 120
elif name_lower.startswith(
query_lower
):
score += 100
elif query_lower in name_lower:
score += 80
elif query_lower in display_lower:
score += 40
if osm_type in venue_types:
score += 70
for keyword in venue_keywords:
if keyword in name_lower:
score += 40
break
if osm_class in {
"amenity",
"leisure",
"tourism"
}:
score += 20
if osm_type in {
"street",
"road",
"residential",
"person",
"postcode",
"house"
}:
score -= 200
if score < 50:
continue
candidates.append({
"id": None,
"name": name,
"street":
address.get("road")
or address.get("pedestrian")
or address.get("footway"),
"postal_code":
address.get("postcode"),
"city":
address.get("city")
or address.get("town")
or address.get("village")
or address.get("municipality"),
"country":
address.get(
"country",
"Deutschland"
),
"latitude":
float(item["lat"])
if item.get("lat")
else None,
"longitude":
float(item["lon"])
if item.get("lon")
else None,
"external_id":
item.get("osm_id"),
"source":
"nominatim",
"local":
False,
"_score":
score
})
unique = {}
for candidate in candidates:
key = (
candidate["external_id"],
candidate["name"],
candidate["city"]
)
if key not in unique:
unique[key] = candidate
elif (
candidate["_score"]
>
unique[key]["_score"]
):
unique[key] = candidate
sorted_candidates = sorted(
unique.values(),
key=lambda item:
item["_score"],
reverse=True
)
for candidate in sorted_candidates[:10]:
candidate.pop(
"_score",
None
)
results.append(candidate)
except Exception as error:
print(
f"Nominatim search failed: {error}"
)
return results