- Freitext wird jetzt direkt als venue_name übertragen und somit auch ohne Suchtreffer gespeichert.

- Die Suche ist international und kombiniert lokale Ergebnisse mit Nominatim.
  - Externe IDs werden korrekt als nominatim:<Typ>:<ID> gespeichert und dedupliziert.
  - Straße, PLZ, Stadt und Land können beim Anlegen manuell ergänzt werden.
  - Im Adminbereich können Orte korrigiert, bestätigt, mit Aliasen versehen und zusammengeführt werden.
This commit is contained in:
kai
2026-08-26 10:02:52 +02:00
parent caddac9eab
commit 4035176370
7 changed files with 490 additions and 79 deletions
+345 -13
View File
@@ -174,6 +174,22 @@ def ensure_schema():
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) )
""", """,
"""
ALTER TABLE venues
ADD COLUMN IF NOT EXISTS is_verified BOOLEAN NOT NULL DEFAULT FALSE
""",
"""
CREATE TABLE IF NOT EXISTS venue_aliases (
id SERIAL PRIMARY KEY,
venue_id INTEGER NOT NULL REFERENCES venues(id) ON DELETE CASCADE,
alias VARCHAR(255) NOT NULL,
UNIQUE (venue_id, alias)
)
""",
"""
CREATE INDEX IF NOT EXISTS idx_venue_aliases_alias
ON venue_aliases (LOWER(alias))
""",
] ]
with get_db_connection() as connection: with get_db_connection() as connection:
@@ -610,6 +626,11 @@ def resolve_venue(
longitude: str, longitude: str,
): ):
selected_venue_id = None selected_venue_id = None
venue_name = venue_name.strip()
city = city.strip()
street = street.strip()
postal_code = postal_code.strip()
country = country.strip()
if venue_id: if venue_id:
if venue_id.startswith("nominatim:"): if venue_id.startswith("nominatim:"):
@@ -619,11 +640,11 @@ def resolve_venue(
""" """
SELECT id SELECT id
FROM venues FROM venues
WHERE external_id = %s WHERE external_id IN (%s, %s)
AND source = 'nominatim' AND source = 'nominatim'
LIMIT 1 LIMIT 1
""", """,
(external_id,), (external_id, external_id.rsplit(":", 1)[-1]),
) )
existing_venue = cursor.fetchone() existing_venue = cursor.fetchone()
@@ -651,7 +672,7 @@ def resolve_venue(
street or None, street or None,
postal_code or None, postal_code or None,
city or None, city or None,
country or "Deutschland", country or None,
float(latitude) if latitude else None, float(latitude) if latitude else None,
float(longitude) if longitude else None, float(longitude) if longitude else None,
external_id, external_id,
@@ -700,7 +721,7 @@ def resolve_venue(
street or None, street or None,
postal_code or None, postal_code or None,
city or None, city or None,
country or "Deutschland", country or None,
float(latitude) if latitude else None, float(latitude) if latitude else None,
float(longitude) if longitude else None, float(longitude) if longitude else None,
), ),
@@ -719,6 +740,48 @@ def require_admin(request: Request):
return user return user
def get_admin_venues():
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"""
SELECT
venues.id,
venues.name,
venues.street,
venues.postal_code,
venues.city,
venues.country,
venues.source,
venues.is_verified,
COALESCE(string_agg(venue_aliases.alias, ', ' ORDER BY venue_aliases.alias), ''),
COUNT(DISTINCT concerts.id)
FROM venues
LEFT JOIN venue_aliases ON venue_aliases.venue_id = venues.id
LEFT JOIN concerts ON concerts.venue_id = venues.id
GROUP BY venues.id
ORDER BY venues.is_verified ASC, venues.name ASC, venues.city ASC
"""
)
rows = cursor.fetchall()
return [
{
"id": row[0],
"name": row[1],
"street": row[2] or "",
"postal_code": row[3] or "",
"city": row[4] or "",
"country": row[5] or "",
"source": row[6] or "manuell",
"is_verified": bool(row[7]),
"aliases": row[8],
"concert_count": row[9],
}
for row in rows
]
@app.get("/admin", response_class=HTMLResponse) @app.get("/admin", response_class=HTMLResponse)
def admin_page(request: Request): def admin_page(request: Request):
user = require_admin(request) user = require_admin(request)
@@ -750,7 +813,12 @@ def admin_page(request: Request):
] ]
template = templates.get_template("admin.html") template = templates.get_template("admin.html")
return template.render(user=user, users=users, invite_url=None) return template.render(
user=user,
users=users,
venues=get_admin_venues(),
invite_url=None,
)
@app.post("/admin/invites", response_class=HTMLResponse) @app.post("/admin/invites", response_class=HTMLResponse)
@@ -808,11 +876,108 @@ def create_invite(request: Request):
return template.render( return template.render(
user=user, user=user,
users=users, users=users,
venues=get_admin_venues(),
invite_url=f"{str(request.base_url).rstrip('/')}/register/{token}", invite_url=f"{str(request.base_url).rstrip('/')}/register/{token}",
invite_id=invite_id, invite_id=invite_id,
) )
@app.post("/admin/venues/{venue_id}")
def update_venue(
request: Request,
venue_id: int,
name: str = Form(...),
street: str = Form(""),
postal_code: str = Form(""),
city: str = Form(""),
country: str = Form(""),
aliases: str = Form(""),
is_verified: str = Form(""),
):
if not require_admin(request):
return HTMLResponse("<h1>Nicht erlaubt</h1>", status_code=403)
name = name.strip()
if not name:
return HTMLResponse("<h1>Der Name darf nicht leer sein.</h1>", status_code=400)
normalized_aliases = sorted({
alias.strip()
for alias in aliases.split(",")
if alias.strip() and alias.strip().casefold() != name.casefold()
})
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"""
UPDATE venues
SET name = %s, street = %s, postal_code = %s, city = %s,
country = %s, is_verified = %s
WHERE id = %s
""",
(
name,
street.strip() or None,
postal_code.strip() or None,
city.strip() or None,
country.strip() or None,
is_verified == "on",
venue_id,
),
)
cursor.execute("DELETE FROM venue_aliases WHERE venue_id = %s", (venue_id,))
cursor.executemany(
"INSERT INTO venue_aliases (venue_id, alias) VALUES (%s, %s)",
[(venue_id, alias) for alias in normalized_aliases],
)
connection.commit()
return RedirectResponse("/admin#venues", status_code=303)
@app.post("/admin/venues/{venue_id}/merge")
def merge_venue(
request: Request,
venue_id: int,
target_venue_id: int = Form(...),
):
if not require_admin(request):
return HTMLResponse("<h1>Nicht erlaubt</h1>", status_code=403)
if venue_id == target_venue_id:
return HTMLResponse("<h1>Ein Ort kann nicht mit sich selbst zusammengeführt werden.</h1>", status_code=400)
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute("SELECT id FROM venues WHERE id IN (%s, %s)", (venue_id, target_venue_id))
if len(cursor.fetchall()) != 2:
return HTMLResponse("<h1>Veranstaltungsort nicht gefunden.</h1>", status_code=404)
cursor.execute(
"""
INSERT INTO venue_aliases (venue_id, alias)
SELECT %s, name FROM venues WHERE id = %s
ON CONFLICT DO NOTHING
""",
(target_venue_id, venue_id),
)
cursor.execute(
"""
INSERT INTO venue_aliases (venue_id, alias)
SELECT %s, alias FROM venue_aliases WHERE venue_id = %s
ON CONFLICT DO NOTHING
""",
(target_venue_id, venue_id),
)
cursor.execute(
"UPDATE concerts SET venue_id = %s WHERE venue_id = %s",
(target_venue_id, venue_id),
)
cursor.execute("DELETE FROM venues WHERE id = %s", (venue_id,))
connection.commit()
return RedirectResponse("/admin#venues", status_code=303)
@app.post("/admin/users/{user_id}") @app.post("/admin/users/{user_id}")
def update_user_role( def update_user_role(
request: Request, request: Request,
@@ -1423,7 +1588,7 @@ async def create_concert(
city: str = Form(""), city: str = Form(""),
street: str = Form(""), street: str = Form(""),
postal_code: str = Form(""), postal_code: str = Form(""),
country: str = Form("Deutschland"), country: str = Form(""),
latitude: str = Form(""), latitude: str = Form(""),
longitude: str = Form(""), longitude: str = Form(""),
start_datetime: str = Form(...), start_datetime: str = Form(...),
@@ -1552,7 +1717,7 @@ async def edit_concert(
city: str = Form(""), city: str = Form(""),
street: str = Form(""), street: str = Form(""),
postal_code: str = Form(""), postal_code: str = Form(""),
country: str = Form("Deutschland"), country: str = Form(""),
latitude: str = Form(""), latitude: str = Form(""),
longitude: str = Form(""), longitude: str = Form(""),
start_datetime: str = Form(""), start_datetime: str = Form(""),
@@ -1839,8 +2004,7 @@ async def add_photo(
# Venue search # Venue search
# ============================================================ # ============================================================
@app.get("/api/venues/search") def legacy_search_venues(q: str):
def search_venues(q: str):
q = q.strip() q = q.strip()
@@ -1968,7 +2132,7 @@ def search_venues(q: str):
"User-Agent": "PinguConcerts/1.0" "User-Agent": "PinguConcerts/1.0"
} }
external_query = re.sub(r"\bhall\b", "halle", q, flags=re.IGNORECASE) external_query = q
params = { params = {
@@ -1978,9 +2142,7 @@ def search_venues(q: str):
"addressdetails": 1, "addressdetails": 1,
"limit": 20, "limit": 20
"countrycodes": "de"
} }
@@ -2310,3 +2472,173 @@ def search_venues(q: str):
return results return results
def venue_search_tokens(value: str):
return [token for token in re.findall(r"[^\W_]+", value.casefold()) if len(token) >= 2]
def venue_result_key(venue):
return (
(venue.get("name") or "").strip().casefold(),
(venue.get("city") or "").strip().casefold(),
(venue.get("country") or "").strip().casefold(),
)
@app.get("/api/venues/search")
def search_venues(q: str):
query = q.strip()
if len(query) < 2:
return []
tokens = venue_search_tokens(query)
token_clauses = []
token_values = []
for token in tokens:
token_clauses.append(
"""(
venues.name ILIKE %s OR venues.city ILIKE %s OR venues.country ILIKE %s
OR EXISTS (
SELECT 1 FROM venue_aliases
WHERE venue_aliases.venue_id = venues.id
AND venue_aliases.alias ILIKE %s
)
)"""
)
token_values.extend([f"%{token}%"] * 4)
token_match = " AND ".join(token_clauses) or "FALSE"
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
f"""
SELECT venues.id, venues.name, venues.street, venues.postal_code,
venues.city, venues.country, venues.latitude, venues.longitude,
venues.external_id, venues.source, venues.is_verified
FROM venues
WHERE venues.name ILIKE %s OR venues.city ILIKE %s
OR venues.country ILIKE %s OR venues.street ILIKE %s
OR EXISTS (
SELECT 1 FROM venue_aliases
WHERE venue_aliases.venue_id = venues.id
AND venue_aliases.alias ILIKE %s
)
OR ({token_match})
ORDER BY venues.is_verified DESC,
CASE WHEN LOWER(venues.name) = LOWER(%s) THEN 0
WHEN venues.name ILIKE %s THEN 1 ELSE 2 END,
venues.name
LIMIT 15
""",
[f"%{query}%"] * 5
+ token_values
+ [query, f"{query}%"],
)
rows = cursor.fetchall()
ranked_results = []
for row in rows:
ranked_results.append({
"id": row[0],
"name": row[1],
"street": row[2],
"postal_code": row[3],
"city": row[4],
"country": row[5],
"latitude": row[6],
"longitude": row[7],
"external_id": row[8],
"source": row[9],
"local": True,
"verified": bool(row[10]),
"_score": 300 + (50 if row[10] else 0),
})
try:
response = httpx.get(
"https://nominatim.openstreetmap.org/search",
params={
"q": query,
"format": "jsonv2",
"addressdetails": 1,
"namedetails": 1,
"limit": 25,
},
headers={"User-Agent": "PinguConcerts/1.0"},
timeout=8,
)
response.raise_for_status()
venue_types = {
"music_venue", "concert_hall", "stadium", "sports_centre", "theatre",
"arts_centre", "exhibition_hall", "conference_centre", "events_venue",
"nightclub", "community_centre", "festival", "arena", "auditorium",
"cinema", "event_venue", "recreation_ground",
}
venue_keywords = {
"arena", "club", "festival", "halle", "hall", "stadion", "stadium",
"theater", "theatre", "concert", "konzert", "music", "venue", "centre",
"center", "festivalgelände", "festivalterrein",
}
excluded_types = {
"street", "road", "residential", "postcode", "house", "bus_stop", "person",
}
for item in response.json():
address = item.get("address") or {}
display_name = (item.get("display_name") or "").strip()
name = (item.get("name") or display_name.split(",", 1)[0]).strip()
osm_type = (item.get("type") or "").casefold()
osm_class = (item.get("class") or item.get("category") or "").casefold()
if not name or osm_type in excluded_types:
continue
searchable = " ".join((name, display_name)).casefold()
matched_tokens = sum(token in searchable for token in tokens)
score = matched_tokens * 35
if name.casefold() == query.casefold():
score += 120
elif name.casefold().startswith(query.casefold()):
score += 90
elif query.casefold() in searchable:
score += 60
if osm_type in venue_types:
score += 80
if any(keyword in name.casefold() for keyword in venue_keywords):
score += 35
if osm_class in {"amenity", "leisure", "tourism"}:
score += 20
if score < 50:
continue
osm_id = item.get("osm_id")
osm_kind = (item.get("osm_type") or "object").casefold()
external_id = f"{osm_kind}:{osm_id}" if osm_id is not None else None
ranked_results.append({
"id": f"nominatim:{external_id}" if external_id else None,
"name": name,
"street": address.get("road") or address.get("pedestrian"),
"postal_code": address.get("postcode"),
"city": address.get("city") or address.get("town") or address.get("village")
or address.get("municipality") or address.get("county"),
"country": address.get("country"),
"latitude": float(item["lat"]) if item.get("lat") else None,
"longitude": float(item["lon"]) if item.get("lon") else None,
"external_id": external_id,
"source": "nominatim",
"local": False,
"verified": False,
"_score": score,
})
except (httpx.HTTPError, ValueError) as error:
print(f"Nominatim search failed: {error}")
unique_results = {}
for venue in sorted(ranked_results, key=lambda item: item["_score"], reverse=True):
key = venue_result_key(venue)
if key not in unique_results:
venue.pop("_score", None)
unique_results[key] = venue
return list(unique_results.values())[:10]
+26 -4
View File
@@ -264,10 +264,7 @@ form .button {
} }
.venue-results { .venue-results {
position: absolute; position: relative;
top: 100%;
left: 0;
right: 0;
background: var(--surface); background: var(--surface);
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -279,6 +276,31 @@ form .button {
z-index: 50; z-index: 50;
} }
.venue-manual-details {
margin-top: 10px;
}
.venue-manual-details small {
color: var(--muted);
}
.venue-address-grid {
display: grid;
grid-template-columns: 2fr 0.7fr 1.4fr 1.2fr;
gap: 8px;
margin-top: 6px;
}
.venue-result-manual {
background: rgba(124, 58, 237, 0.08);
}
@media (max-width: 700px) {
.venue-address-grid {
grid-template-columns: 1fr 1fr;
}
}
.venue-result { .venue-result {
width: 100%; width: 100%;
display: flex; display: flex;
+49
View File
@@ -14,7 +14,16 @@
.role-form, .delete-form { margin: 0; display: flex; align-items: center; gap: 8px; } .role-form, .delete-form { margin: 0; display: flex; align-items: center; gap: 8px; }
.role-form select { padding: 9px; background: #0b0f19; color: var(--text); border: 1px solid var(--border); border-radius: 8px; } .role-form select { padding: 9px; background: #0b0f19; color: var(--text); border: 1px solid var(--border); border-radius: 8px; }
.delete-button { padding: 9px 12px; color: #fecaca; border: 1px solid #7f1d1d; background: #450a0a; border-radius: 8px; cursor: pointer; } .delete-button { padding: 9px 12px; color: #fecaca; border: 1px solid #7f1d1d; background: #450a0a; border-radius: 8px; cursor: pointer; }
.venue-list { display: grid; gap: 14px; }
.venue-card { padding: 16px; background: #0f1420; border: 1px solid var(--border); border-radius: 10px; }
.venue-fields { display: grid; grid-template-columns: 2fr 2fr .8fr 1.4fr 1.2fr; gap: 8px; }
.venue-fields input, .merge-form select { min-width: 0; width: 100%; }
.venue-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin-top: 10px; }
.venue-actions label { display: flex; align-items: center; gap: 6px; }
.venue-aliases { margin-top: 8px; }
.merge-form { display: grid; grid-template-columns: minmax(180px, 1fr) auto; gap: 8px; margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border); }
@media (max-width: 700px) { .user-row { grid-template-columns: 1fr; } .role-form, .delete-form { width: 100%; } } @media (max-width: 700px) { .user-row { grid-template-columns: 1fr; } .role-form, .delete-form { width: 100%; } }
@media (max-width: 900px) { .venue-fields { grid-template-columns: 1fr 1fr; } }
</style> </style>
</head> </head>
<body> <body>
@@ -64,6 +73,46 @@
{% endfor %} {% endfor %}
</div> </div>
</section> </section>
<section class="admin-section" id="venues">
<h2>Veranstaltungsorte</h2>
<p>Namen und Adressen korrigieren, Such-Aliase ergänzen oder Dubletten zusammenführen.</p>
<div class="venue-list">
{% for venue in venues %}
<div class="venue-card">
<form method="post" action="/admin/venues/{{ venue.id }}">
<div class="venue-fields">
<input name="name" value="{{ venue.name }}" aria-label="Name" required>
<input name="street" value="{{ venue.street }}" placeholder="Straße" aria-label="Straße">
<input name="postal_code" value="{{ venue.postal_code }}" placeholder="PLZ" aria-label="PLZ">
<input name="city" value="{{ venue.city }}" placeholder="Ort" aria-label="Ort">
<input name="country" value="{{ venue.country }}" placeholder="Land" aria-label="Land">
</div>
<input class="venue-aliases" name="aliases" value="{{ venue.aliases }}" placeholder="Aliase, durch Komma getrennt" aria-label="Aliase">
<div class="venue-actions">
<label><input type="checkbox" name="is_verified" {% if venue.is_verified %}checked{% endif %}> bestätigt</label>
<span class="user-meta">Quelle: {{ venue.source }} · {{ venue.concert_count }} Konzert(e) · ID {{ venue.id }}</span>
<button class="button" type="submit">Ort speichern</button>
</div>
</form>
{% if venues|length > 1 %}
<form class="merge-form" method="post" action="/admin/venues/{{ venue.id }}/merge" onsubmit="return confirm('Dieser Ort wird gelöscht und alle Konzerte werden dem Zielort zugeordnet. Fortfahren?');">
<select name="target_venue_id" required aria-label="Zielort">
<option value="">Mit anderem Ort zusammenführen …</option>
{% for target in venues %}
{% if target.id != venue.id %}
<option value="{{ target.id }}">{{ target.name }}{% if target.city %}, {{ target.city }}{% endif %}</option>
{% endif %}
{% endfor %}
</select>
<button class="delete-button" type="submit">Zusammenführen</button>
</form>
{% endif %}
</div>
{% else %}
<p>Noch keine Veranstaltungsorte vorhanden.</p>
{% endfor %}
</div>
</section>
</main> </main>
</body> </body>
</html> </html>
+5 -1
View File
@@ -41,7 +41,11 @@
<input type="text" name="city" value="{{ concert.venue.city or '' }}" placeholder="Ort"> <input type="text" name="city" value="{{ concert.venue.city or '' }}" placeholder="Ort">
</label> </label>
</p> </p>
<input type="hidden" name="country" value="{{ concert.venue.country or 'Deutschland' }}"> <p>
<label>Land<br>
<input type="text" name="country" value="{{ concert.venue.country or '' }}" placeholder="Land">
</label>
</p>
<input type="hidden" name="latitude" value="{{ concert.venue.latitude or '' }}"> <input type="hidden" name="latitude" value="{{ concert.venue.latitude or '' }}">
<input type="hidden" name="longitude" value="{{ concert.venue.longitude or '' }}"> <input type="hidden" name="longitude" value="{{ concert.venue.longitude or '' }}">
<p> <p>
+42 -61
View File
@@ -138,8 +138,10 @@
<input <input
type="text" type="text"
id="venue-search" id="venue-search"
name="venue_name"
autocomplete="off" autocomplete="off"
placeholder="z. B. Turbinenhalle" placeholder="z. B. Turbinenhalle"
required
> >
<div <div
@@ -153,6 +155,16 @@
class="selected-venue" class="selected-venue"
></div> ></div>
<div class="venue-manual-details">
<small>Optional für eine bessere Zuordnung:</small>
<div class="venue-address-grid">
<input type="text" name="street" id="venue-street" placeholder="Straße">
<input type="text" name="postal_code" id="venue-postal-code" placeholder="PLZ">
<input type="text" name="city" id="venue-city" placeholder="Ort / Stadt">
<input type="text" name="country" id="venue-country" placeholder="Land">
</div>
</div>
<input <input
type="hidden" type="hidden"
@@ -160,36 +172,6 @@
id="venue-id" id="venue-id"
> >
<input
type="hidden"
name="venue_name"
id="venue-name"
>
<input
type="hidden"
name="city"
id="venue-city"
>
<input
type="hidden"
name="street"
id="venue-street"
>
<input
type="hidden"
name="postal_code"
id="venue-postal-code"
>
<input
type="hidden"
name="country"
id="venue-country"
>
<input <input
type="hidden" type="hidden"
name="latitude" name="latitude"
@@ -396,7 +378,6 @@ function clearVenueSelection() {
[ [
"venue-id", "venue-id",
"venue-name",
"venue-city", "venue-city",
"venue-street", "venue-street",
"venue-postal-code", "venue-postal-code",
@@ -433,7 +414,6 @@ venueSearch.addEventListener(
clearVenueSelection(); clearVenueSelection();
if (query.length < 2) { if (query.length < 2) {
return; return;
@@ -482,7 +462,7 @@ async function searchVenues(
if (!response.ok) { if (!response.ok) {
appendManualVenueResult(query, true);
return; return;
} }
@@ -496,28 +476,6 @@ async function searchVenues(
""; "";
if (!venues.length) {
const empty =
document.createElement(
"div"
);
empty.className =
"venue-result";
empty.textContent =
"Keine Veranstaltungsorte gefunden";
venueResults.appendChild(
empty
);
return;
}
venues.forEach( venues.forEach(
venue => { venue => {
@@ -583,6 +541,8 @@ async function searchVenues(
} }
); );
appendManualVenueResult(query, venues.length === 0);
} catch (error) { } catch (error) {
@@ -595,11 +555,38 @@ async function searchVenues(
error error
); );
venueResults.innerHTML = "";
appendManualVenueResult(query, true);
} }
} }
function appendManualVenueResult(query, isOnlyResult) {
const result = document.createElement("button");
result.type = "button";
result.className = "venue-result venue-result-manual";
result.innerHTML = `
<strong>✏️ „${escapeHtml(query)}“ verwenden</strong>
<span>${isOnlyResult ? "Keine passenden Treffer Eingabe trotzdem speichern" : "Als eigenen Veranstaltungsort anlegen"}</span>
`;
result.addEventListener("click", () => {
selectVenue({
id: "",
name: query,
street: document.getElementById("venue-street").value,
postal_code: document.getElementById("venue-postal-code").value,
city: document.getElementById("venue-city").value,
country: document.getElementById("venue-country").value,
latitude: "",
longitude: ""
});
});
venueResults.appendChild(result);
}
/* ============================================================ /* ============================================================
VENUE SELECT VENUE SELECT
============================================================ */ ============================================================ */
@@ -614,12 +601,6 @@ function selectVenue(
venue.id ?? ""; venue.id ?? "";
document.getElementById(
"venue-name"
).value =
venue.name ?? "";
document.getElementById( document.getElementById(
"venue-city" "venue-city"
).value = ).value =
+11
View File
@@ -37,9 +37,17 @@ CREATE TABLE venues (
longitude DOUBLE PRECISION, longitude DOUBLE PRECISION,
external_id VARCHAR(255), external_id VARCHAR(255),
source VARCHAR(50), source VARCHAR(50),
is_verified BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
CREATE TABLE venue_aliases (
id SERIAL PRIMARY KEY,
venue_id INTEGER NOT NULL REFERENCES venues(id) ON DELETE CASCADE,
alias VARCHAR(255) NOT NULL,
UNIQUE (venue_id, alias)
);
CREATE TABLE concerts ( CREATE TABLE concerts (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
artist VARCHAR(255) NOT NULL, artist VARCHAR(255) NOT NULL,
@@ -96,6 +104,9 @@ CREATE INDEX idx_venues_name
CREATE INDEX idx_venues_external_id CREATE INDEX idx_venues_external_id
ON venues(external_id); ON venues(external_id);
CREATE INDEX idx_venue_aliases_alias
ON venue_aliases (LOWER(alias));
CREATE INDEX idx_concert_comments_concert CREATE INDEX idx_concert_comments_concert
ON concert_comments(concert_id, created_at); ON concert_comments(concert_id, created_at);
+12
View File
@@ -0,0 +1,12 @@
ALTER TABLE venues
ADD COLUMN IF NOT EXISTS is_verified BOOLEAN NOT NULL DEFAULT FALSE;
CREATE TABLE IF NOT EXISTS venue_aliases (
id SERIAL PRIMARY KEY,
venue_id INTEGER NOT NULL REFERENCES venues(id) ON DELETE CASCADE,
alias VARCHAR(255) NOT NULL,
UNIQUE (venue_id, alias)
);
CREATE INDEX IF NOT EXISTS idx_venue_aliases_alias
ON venue_aliases (LOWER(alias));