Initial commit

This commit is contained in:
2026-08-25 04:08:13 +00:00
commit e2fec5d3dc
12 changed files with 3273 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
# Python
__pycache__/
*.py[cod]
*.pyo
# Virtual environments
.venv/
venv/
env/
# Environment / secrets
.env
.env.*
!.env.example
# Logs
*.log
# Backup / alte Arbeitskopien
*.backup
*.old
*.save
*.before-*
# Hochgeladene Dateien
app/static/uploads/
# Lokale Laufzeitdaten
db/data/
# IDE
.vscode/
.idea/
+9
View File
@@ -0,0 +1,9 @@
FROM python:3.13-slim
WORKDIR /app
RUN pip install --no-cache-dir fastapi uvicorn "psycopg[binary]" python-multipart jinja2 httpx
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
+1381
View File
File diff suppressed because it is too large Load Diff
+317
View File
@@ -0,0 +1,317 @@
:root {
--bg: #0b0f19;
--surface: #151b29;
--surface-hover: #1b2333;
--border: #293449;
--text: #f3f4f6;
--muted: #9ca3af;
--accent: #8b5cf6;
--accent-hover: #7c3aed;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
background: var(--bg);
color: var(--text);
}
a {
color: inherit;
text-decoration: none;
}
header {
background: rgba(21, 27, 41, 0.95);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 10;
backdrop-filter: blur(10px);
}
.header-inner {
max-width: 1100px;
margin: 0 auto;
padding: 18px 20px;
display: flex;
align-items: center;
justify-content: space-between;
}
.logo {
font-size: 1.4rem;
font-weight: 800;
}
.logo span {
color: var(--accent);
}
main {
max-width: 1100px;
margin: 0 auto;
padding: 30px 20px 100px;
}
.page-title {
margin-bottom: 25px;
}
.page-title h1 {
margin: 0;
font-size: 2rem;
}
.page-title p {
margin: 6px 0 0;
color: var(--muted);
}
.concert-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px;
}
.concert-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 16px;
overflow: hidden;
transition:
transform 0.15s ease,
background 0.15s ease,
border-color 0.15s ease;
}
.concert-card:hover {
transform: translateY(-3px);
background: var(--surface-hover);
border-color: #3b4860;
}
.concert-image {
width: 100%;
aspect-ratio: 16 / 9;
background: #0f1420;
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
}
.concert-content {
padding: 20px;
}
.concert-artist {
margin: 0 0 12px;
font-size: 1.35rem;
}
.concert-info {
display: flex;
flex-direction: column;
gap: 7px;
color: var(--muted);
font-size: 0.95rem;
}
.empty {
text-align: center;
padding: 70px 20px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 18px;
}
.empty-icon {
font-size: 4rem;
margin-bottom: 15px;
}
.empty h2 {
margin: 0 0 8px;
}
.empty p {
color: var(--muted);
margin: 0;
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
padding: 11px 17px;
border-radius: 10px;
background: var(--accent);
color: white;
font-weight: 600;
border: 0;
cursor: pointer;
transition: background 0.15s ease;
}
.button:hover {
background: var(--accent-hover);
}
.button-secondary {
background: var(--surface);
border: 1px solid var(--border);
}
.button-secondary:hover {
background: var(--surface-hover);
}
@media (max-width: 600px) {
.header-inner {
padding: 15px;
}
main {
padding: 22px 15px 80px;
}
.page-title h1 {
font-size: 1.6rem;
}
.concert-grid {
grid-template-columns: 1fr;
}
}
form {
max-width: 650px;
margin: 0 auto;
text-align: left;
}
form p {
margin: 0 0 18px;
}
label {
display: block;
color: var(--muted);
font-size: 0.9rem;
font-weight: 600;
}
input,
textarea {
width: 100%;
margin-top: 7px;
padding: 12px 14px;
background: #0f1420;
color: var(--text);
border: 1px solid var(--border);
border-radius: 9px;
font: inherit;
}
input:focus,
textarea:focus {
outline: none;
border-color: var(--accent);
}
textarea {
resize: vertical;
}
form .button {
margin-right: 8px;
margin-top: 5px;
}
.venue-search {
position: relative;
max-width: 650px;
margin: 0 auto 18px;
text-align: left;
}
.venue-search label {
display: block;
}
.venue-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
margin-top: 5px;
overflow: hidden;
z-index: 50;
}
.venue-result {
width: 100%;
display: flex;
flex-direction: column;
gap: 3px;
padding: 12px 14px;
background: transparent;
color: var(--text);
border: 0;
border-bottom: 1px solid var(--border);
text-align: left;
cursor: pointer;
font: inherit;
}
.venue-result:last-child {
border-bottom: 0;
}
.venue-result:hover {
background: var(--surface-hover);
}
.venue-result strong {
font-size: 0.95rem;
}
.venue-result span {
color: var(--muted);
font-size: 0.85rem;
}
+161
View File
@@ -0,0 +1,161 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
background: #0d1117;
color: #e6edf3;
font-family: Arial, Helvetica, sans-serif;
}
.container {
width: min(1100px, 92%);
margin: 0 auto;
padding: 30px 0 60px;
}
a {
color: inherit;
}
.back-link {
display: inline-block;
margin-bottom: 25px;
color: #9da7b3;
text-decoration: none;
}
.back-link:hover {
color: #ffffff;
}
.concert-detail {
background: #161b22;
border: 1px solid #30363d;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
}
.flyer-container {
width: 100%;
background: #0a0d12;
display: flex;
justify-content: center;
padding: 25px;
}
.concert-flyer {
display: block;
max-width: 100%;
max-height: 700px;
object-fit: contain;
border-radius: 10px;
}
.concert-content {
padding: 35px;
}
.concert-content h1 {
margin-top: 0;
margin-bottom: 30px;
font-size: clamp(2rem, 5vw, 3.5rem);
line-height: 1.1;
}
.concert-info {
display: grid;
grid-template-columns: repeat(
auto-fit,
minmax(220px, 1fr)
);
gap: 15px;
}
.info-item {
display: flex;
gap: 14px;
align-items: flex-start;
padding: 18px;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 12px;
}
.info-icon {
font-size: 1.5rem;
}
.info-item strong {
color: #ffffff;
}
.description {
margin-top: 35px;
padding-top: 30px;
border-top: 1px solid #30363d;
}
.description h2,
.coming-soon h2 {
margin-top: 0;
}
.description p {
color: #b8c1cc;
line-height: 1.7;
white-space: pre-wrap;
}
.ticket-button-container {
margin-top: 30px;
}
.ticket-button {
display: inline-block;
padding: 13px 22px;
background: #238636;
color: white;
text-decoration: none;
border-radius: 10px;
font-weight: bold;
}
.ticket-button:hover {
background: #2ea043;
}
.coming-soon {
margin-top: 35px;
padding: 25px;
background: #0d1117;
border: 1px dashed #30363d;
border-radius: 12px;
}
.coming-soon p {
color: #8b949e;
}
@media (max-width: 600px) {
.container {
width: 94%;
padding-top: 20px;
}
.concert-content {
padding: 22px;
}
.flyer-container {
padding: 12px;
}
.concert-flyer {
max-height: 600px;
}
}
+197
View File
@@ -0,0 +1,197 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
>
<title>{{ concert.artist }} | Pingu Concerts</title>
<link
rel="stylesheet"
href="/static/style.css"
>
</head>
<body>
<div class="container">
<a href="/" class="back-link">
← Zurück zum Kalender
</a>
<article class="concert-detail">
{% if concert.flyer_path %}
<div class="flyer-container">
<img
src="{{ concert.flyer_path }}"
alt="Flyer {{ concert.artist }}"
class="concert-flyer"
>
</div>
{% endif %}
<div class="concert-content">
<h1>
{{ concert.artist }}
</h1>
<div class="concert-info">
<div class="info-item">
<span class="info-icon">
📅
</span>
<div>
<strong>
{{ concert.date }}
</strong>
<br>
{{ concert.time }} Uhr
</div>
</div>
<div class="info-item">
<span class="info-icon">
📍
</span>
<div>
<strong>
{{ concert.venue.name }}
</strong>
{% if concert.venue.street %}
<br>
{{ concert.venue.street }}
{% endif %}
{% if concert.venue.postal_code
or concert.venue.city %}
<br>
{{ concert.venue.postal_code }}
{{ concert.venue.city }}
{% endif %}
</div>
</div>
{% if concert.ticket_price %}
<div class="info-item">
<span class="info-icon">
🎟️
</span>
<div>
<strong>
Ticket
</strong>
<br>
{{ concert.ticket_price }} €
</div>
</div>
{% endif %}
</div>
{% if concert.description %}
<section class="description">
<h2>
Über das Konzert
</h2>
<p>
{{ concert.description }}
</p>
</section>
{% endif %}
{% if concert.ticket_url %}
<div class="ticket-button-container">
<a
href="{{ concert.ticket_url }}"
target="_blank"
rel="noopener noreferrer"
class="ticket-button"
>
🎟️ Tickets
</a>
</div>
{% endif %}
<section class="coming-soon">
<h2>
🐧 Community
</h2>
<p>
Wer geht hin, wer ist interessiert?
Kommentare und Teilnehmer folgen hier.
</p>
</section>
</div>
</article>
</div>
</body>
</html>
+209
View File
@@ -0,0 +1,209 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
>
<title>Pingu Concerts</title>
<link
rel="stylesheet"
href="/static/style.css"
>
<style>
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 20px;
margin-bottom: 35px;
}
.page-header h1 {
margin: 0;
font-size: 2.5rem;
}
.subtitle {
color: #8b949e;
margin-top: 8px;
}
.new-concert-button {
display: inline-block;
padding: 12px 18px;
background: #238636;
color: white;
text-decoration: none;
border-radius: 10px;
font-weight: bold;
white-space: nowrap;
}
.new-concert-button:hover {
background: #2ea043;
}
.concert-list {
display: grid;
gap: 15px;
}
.concert-card {
display: block;
padding: 22px;
background: #161b22;
border: 1px solid #30363d;
border-radius: 14px;
text-decoration: none;
transition:
transform 0.15s ease,
border-color 0.15s ease,
background 0.15s ease;
}
.concert-card:hover {
transform: translateY(-2px);
border-color: #58a6ff;
background: #1b222c;
}
.concert-artist {
font-size: 1.4rem;
font-weight: bold;
color: #ffffff;
margin-bottom: 10px;
}
.concert-meta {
color: #b8c1cc;
line-height: 1.6;
}
.concert-date {
color: #58a6ff;
font-weight: bold;
}
.empty-state {
padding: 40px;
text-align: center;
background: #161b22;
border: 1px dashed #30363d;
border-radius: 14px;
color: #8b949e;
}
@media (max-width: 600px) {
.page-header {
align-items: flex-start;
flex-direction: column;
}
.new-concert-button {
width: 100%;
text-align: center;
}
}
</style>
</head>
<body>
<div class="container">
<header class="page-header">
<div>
<h1>
🐧 Pingu Concerts
</h1>
<div class="subtitle">
Unser Konzertkalender
</div>
</div>
<a
href="/concerts/new"
class="new-concert-button"
>
+ Konzert hinzufügen
</a>
</header>
{% if concerts %}
<div class="concert-list">
{% for concert in concerts %}
<a
href="/concerts/{{ concert.id }}"
class="concert-card"
>
<div class="concert-artist">
🎸 {{ concert.artist }}
</div>
<div class="concert-meta">
<div class="concert-date">
📅 {{ concert.date }}
um {{ concert.time }} Uhr
</div>
<div>
📍 {{ concert.venue }}
</div>
</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<h2>
Noch keine Konzerte 🎸
</h2>
<p>
Leg das erste Konzert an!
</p>
</div>
{% endif %}
</div>
</body>
</html>
+815
View File
@@ -0,0 +1,815 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
>
<title>Konzert hinzufügen · Pingu Concerts</title>
<link
rel="stylesheet"
href="/static/css/style.css"
>
<style>
.flyer-upload {
margin-top: 25px;
}
.flyer-upload label {
display: block;
margin-bottom: 8px;
}
.flyer-help {
color: #8b949e;
font-size: 0.9rem;
margin-top: 6px;
}
.flyer-preview-container {
margin-top: 15px;
display: none;
}
.flyer-preview {
max-width: 300px;
max-height: 400px;
display: block;
border-radius: 10px;
border: 1px solid #30363d;
}
.flyer-preview-name {
margin-top: 8px;
color: #8b949e;
font-size: 0.9rem;
}
.selected-venue {
margin-top: 10px;
padding: 12px;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 8px;
color: #b8c1cc;
display: none;
}
</style>
</head>
<body>
<header>
<div class="header-inner">
<a href="/" class="logo">
🎸 Pingu <span>Concerts</span>
</a>
</div>
</header>
<main>
<div class="page-title">
<h1>🎤 Konzert hinzufügen</h1>
<p>
Trag ein, worauf wir uns freuen.
</p>
</div>
<section class="empty">
<form
method="post"
action="/concerts"
enctype="multipart/form-data"
>
<!-- ================================================= -->
<!-- Künstler -->
<!-- ================================================= -->
<p>
<label>
Künstler / Band<br>
<input
type="text"
name="artist"
required
placeholder="z. B. Iron Maiden"
>
</label>
</p>
<!-- ================================================= -->
<!-- Veranstaltungsort -->
<!-- ================================================= -->
<div class="venue-search">
<label for="venue-search">
Veranstaltungsort
</label>
<input
type="text"
id="venue-search"
autocomplete="off"
placeholder="z. B. Turbinenhalle"
>
<div
id="venue-results"
class="venue-results"
></div>
<div
id="selected-venue"
class="selected-venue"
></div>
<input
type="hidden"
name="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
type="hidden"
name="latitude"
id="venue-latitude"
>
<input
type="hidden"
name="longitude"
id="venue-longitude"
>
</div>
<!-- ================================================= -->
<!-- Datum -->
<!-- ================================================= -->
<p>
<label>
Datum und Uhrzeit<br>
<input
type="datetime-local"
name="start_datetime"
required
>
</label>
</p>
<!-- ================================================= -->
<!-- Beschreibung -->
<!-- ================================================= -->
<p>
<label>
Beschreibung<br>
<textarea
name="description"
rows="5"
placeholder="Optional: Infos zum Konzert, Tour, Supportbands usw."
></textarea>
</label>
</p>
<!-- ================================================= -->
<!-- Ticket-Link -->
<!-- ================================================= -->
<p>
<label>
Ticket-Link<br>
<input
type="url"
name="ticket_url"
placeholder="https://..."
>
</label>
</p>
<!-- ================================================= -->
<!-- Ticketpreis -->
<!-- ================================================= -->
<p>
<label>
Ticketpreis<br>
<input
type="number"
name="ticket_price"
step="0.01"
min="0"
placeholder="z. B. 59.90"
>
</label>
</p>
<!-- ================================================= -->
<!-- Flyer -->
<!-- ================================================= -->
<div class="flyer-upload">
<label for="flyer">
🎨 Konzert-Flyer
</label>
<input
type="file"
id="flyer"
name="flyer"
accept="image/jpeg,image/png,image/webp"
>
<div class="flyer-help">
JPG, PNG oder WEBP · maximal 10 MB
</div>
<div
id="flyer-preview-container"
class="flyer-preview-container"
>
<img
id="flyer-preview"
class="flyer-preview"
alt="Flyer Vorschau"
>
<div
id="flyer-preview-name"
class="flyer-preview-name"
></div>
</div>
</div>
<!-- ================================================= -->
<!-- Buttons -->
<!-- ================================================= -->
<div style="margin-top: 30px;">
<button
type="submit"
class="button"
>
💾 Konzert speichern
</button>
<a
href="/"
class="button button-secondary"
>
Abbrechen
</a>
</div>
</form>
</section>
</main>
<script>
/* ============================================================
VENUE SEARCH
============================================================ */
const venueSearch =
document.getElementById(
"venue-search"
);
const venueResults =
document.getElementById(
"venue-results"
);
const selectedVenue =
document.getElementById(
"selected-venue"
);
let searchTimeout = null;
venueSearch.addEventListener(
"input",
() => {
clearTimeout(
searchTimeout
);
const query =
venueSearch.value.trim();
venueResults.innerHTML = "";
selectedVenue.style.display =
"none";
if (query.length < 2) {
return;
}
searchTimeout = setTimeout(
() => {
searchVenues(
query
);
},
300
);
}
);
/* ============================================================
VENUE SEARCH REQUEST
============================================================ */
async function searchVenues(
query
) {
try {
const response =
await fetch(
`/api/venues/search?q=${encodeURIComponent(query)}`
);
if (!response.ok) {
return;
}
const venues =
await response.json();
venueResults.innerHTML =
"";
if (!venues.length) {
const empty =
document.createElement(
"div"
);
empty.className =
"venue-result";
empty.textContent =
"Keine Veranstaltungsorte gefunden";
venueResults.appendChild(
empty
);
return;
}
venues.forEach(
venue => {
const result =
document.createElement(
"button"
);
result.type =
"button";
result.className =
"venue-result";
const address = [
venue.street,
venue.postal_code,
venue.city
]
.filter(Boolean)
.join(" ");
result.innerHTML = `
<strong>
📍 ${escapeHtml(
venue.name
)}
</strong>
<span>
${escapeHtml(
address
)}
</span>
`;
result.addEventListener(
"click",
() => {
selectVenue(
venue
);
}
);
venueResults.appendChild(
result
);
}
);
} catch (error) {
console.error(
"Venue search failed:",
error
);
}
}
/* ============================================================
VENUE SELECT
============================================================ */
function selectVenue(
venue
) {
document.getElementById(
"venue-id"
).value =
venue.id ?? "";
document.getElementById(
"venue-name"
).value =
venue.name ?? "";
document.getElementById(
"venue-city"
).value =
venue.city ?? "";
document.getElementById(
"venue-street"
).value =
venue.street ?? "";
document.getElementById(
"venue-postal-code"
).value =
venue.postal_code ?? "";
document.getElementById(
"venue-country"
).value =
venue.country ?? "";
document.getElementById(
"venue-latitude"
).value =
venue.latitude ?? "";
document.getElementById(
"venue-longitude"
).value =
venue.longitude ?? "";
venueSearch.value =
venue.name;
venueResults.innerHTML =
"";
const address = [
venue.street,
venue.postal_code,
venue.city
]
.filter(Boolean)
.join(" ");
selectedVenue.innerHTML = `
<strong>
📍 ${escapeHtml(
venue.name
)}
</strong>
${
address
? `<br>${escapeHtml(address)}`
: ""
}
`;
selectedVenue.style.display =
"block";
}
/* ============================================================
FLYER PREVIEW
============================================================ */
const flyerInput =
document.getElementById(
"flyer"
);
const flyerPreviewContainer =
document.getElementById(
"flyer-preview-container"
);
const flyerPreview =
document.getElementById(
"flyer-preview"
);
const flyerPreviewName =
document.getElementById(
"flyer-preview-name"
);
flyerInput.addEventListener(
"change",
() => {
const file =
flyerInput.files[0];
if (!file) {
flyerPreviewContainer.style.display =
"none";
flyerPreview.src =
"";
flyerPreviewName.textContent =
"";
return;
}
if (
![
"image/jpeg",
"image/png",
"image/webp"
].includes(
file.type
)
) {
alert(
"Bitte JPG, PNG oder WEBP auswählen."
);
flyerInput.value =
"";
return;
}
if (
file.size >
10 * 1024 * 1024
) {
alert(
"Der Flyer darf maximal 10 MB groß sein."
);
flyerInput.value =
"";
return;
}
const reader =
new FileReader();
reader.onload =
event => {
flyerPreview.src =
event.target.result;
flyerPreviewName.textContent =
file.name;
flyerPreviewContainer.style.display =
"block";
};
reader.readAsDataURL(
file
);
}
);
/* ============================================================
HTML ESCAPING
============================================================ */
function escapeHtml(
value
) {
const div =
document.createElement(
"div"
);
div.textContent =
value ?? "";
return div.innerHTML;
}
</script>
</body>
</html>
+87
View File
@@ -0,0 +1,87 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pingu Concerts - Registrierung</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 500px;
margin: 60px auto;
padding: 20px;
}
input {
width: 100%;
padding: 12px;
margin: 8px 0 16px;
box-sizing: border-box;
}
button {
width: 100%;
padding: 12px;
cursor: pointer;
}
</style>
</head>
<body>
<h1>🎸 Pingu Concerts</h1>
<h2>Einladung angenommen</h2>
<p>
Erstelle deinen Account für Pingu Concerts.
</p>
<form method="post" action="/register">
<input
type="hidden"
name="token"
value="{{ token }}"
>
<label>Benutzername</label>
<input
type="text"
name="username"
required
maxlength="50"
>
<label>Anzeigename</label>
<input
type="text"
name="display_name"
maxlength="100"
>
<label>E-Mail</label>
<input
type="email"
name="email"
required
maxlength="255"
>
<label>Passwort</label>
<input
type="password"
name="password"
required
>
<button type="submit">
Account erstellen
</button>
</form>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
services:
db:
image: postgres:17
container_name: pingu-concerts-db
restart: unless-stopped
environment:
POSTGRES_DB: concerts
POSTGRES_USER: concerts
POSTGRES_PASSWORD: change-me-later
volumes:
- postgres_data:/var/lib/postgresql/data
- ./db/init:/docker-entrypoint-initdb.d:ro
web:
build: ./app
container_name: pingu-concerts-web
restart: unless-stopped
ports:
- "8080:8000"
environment:
DATABASE_URL: postgresql://concerts:change-me-later@db:5432/concerts
depends_on:
- db
volumes:
postgres_data:
+30
View File
@@ -0,0 +1,30 @@
CREATE TABLE venues (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
street VARCHAR(255),
postal_code VARCHAR(20),
city VARCHAR(100),
country VARCHAR(100) DEFAULT 'Deutschland',
latitude DOUBLE PRECISION,
longitude DOUBLE PRECISION,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE concerts (
id SERIAL PRIMARY KEY,
artist VARCHAR(255) NOT NULL,
venue_id INTEGER REFERENCES venues(id) ON DELETE SET NULL,
start_datetime TIMESTAMP NOT NULL,
end_datetime TIMESTAMP,
description TEXT,
ticket_url TEXT,
ticket_price NUMERIC(10,2),
flyer_path TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_concerts_start_datetime
ON concerts(start_datetime);
CREATE INDEX idx_venues_name
ON venues(name);
+8
View File
@@ -0,0 +1,8 @@
ALTER TABLE venues
ADD COLUMN IF NOT EXISTS external_id VARCHAR(255);
ALTER TABLE venues
ADD COLUMN IF NOT EXISTS source VARCHAR(50);
CREATE INDEX IF NOT EXISTS idx_venues_external_id
ON venues(external_id);