1382 lines
30 KiB
Python
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
|