Initial commit for eventlens
This commit is contained in:
commit
51aab152ff
22
.env.example
Normal file
22
.env.example
Normal file
@ -0,0 +1,22 @@
|
||||
APP_NAME=eventlens
|
||||
APP_ENV=production
|
||||
|
||||
DB_HOST=db
|
||||
DB_PORT=3306
|
||||
DB_USER=eventlens
|
||||
DB_PASSWORD=eventlens
|
||||
DB_NAME=eventlens
|
||||
MYSQL_ROOT_PASSWORD=rootsecret
|
||||
|
||||
TICKETMASTER_API_KEY=
|
||||
POLL_INTERVAL_HOURS=6
|
||||
REMINDER_INTERVAL_HOURS=12
|
||||
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASS=
|
||||
SMTP_SENDER=eventlens@example.local
|
||||
NOTIFICATION_EMAIL_TO=
|
||||
SMTP_STARTTLS=true
|
||||
|
||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
85
README.md
Normal file
85
README.md
Normal file
@ -0,0 +1,85 @@
|
||||
# eventlens
|
||||
|
||||
`eventlens` ist ein selbst gehostetes MVP zum Beobachten von Kuenstlern und Events in Hamburg oder ganz Deutschland.
|
||||
|
||||
## Funktionen
|
||||
|
||||
- Watchlist fuer Kuenstler oder Events
|
||||
- Regionen `hamburg` und `germany`
|
||||
- Geplanter Event-Abgleich ueber die Ticketmaster Discovery API
|
||||
- E-Mail-Benachrichtigung bei neu gefundenen Terminen
|
||||
- Markierung, ob Tickets gekauft wurden
|
||||
- Erinnerung etwa eine Woche vor dem Termin
|
||||
- Webfrontend ohne separaten Build-Step
|
||||
- Docker-Deployment hinter NGINX
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
- `backend/`: FastAPI-Anwendung
|
||||
- `deploy/nginx/eventlens.conf`: Beispiel fuer Reverse Proxy
|
||||
- `docker-compose.yml`: App- und Datenbank-Container
|
||||
|
||||
## Start
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Danach ist das Webfrontend lokal unter `http://127.0.0.1:8000` erreichbar.
|
||||
Die Swagger-Oberflaeche liegt unter `http://127.0.0.1:8000/docs`.
|
||||
API-Statusinfo findest du unter `http://127.0.0.1:8000/api`.
|
||||
|
||||
## Wichtige Umgebungsvariablen
|
||||
|
||||
- `TICKETMASTER_API_KEY`: noetig fuer die externe Eventsuche
|
||||
- `NOTIFICATION_EMAIL_TO`: Empfaenger fuer Benachrichtigungen
|
||||
- `SMTP_HOST`, `SMTP_USER`, `SMTP_PASS`: SMTP-Zugang fuer E-Mails
|
||||
|
||||
## Beispielablauf
|
||||
|
||||
1. Watch Item anlegen:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8000/watch-items \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "AnnenMayKantereit",
|
||||
"watch_type": "artist",
|
||||
"region_scope": "hamburg"
|
||||
}'
|
||||
```
|
||||
|
||||
2. Sync manuell anstossen:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8000/sync
|
||||
```
|
||||
|
||||
3. Events abfragen:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8000/events
|
||||
```
|
||||
|
||||
4. Ticketkauf markieren:
|
||||
|
||||
```bash
|
||||
curl -X PATCH http://127.0.0.1:8000/events/1/purchase \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"is_ticket_purchased": true}'
|
||||
```
|
||||
|
||||
## Hinweise fuer Debian 13 und NGINX
|
||||
|
||||
- NGINX kann nativ auf dem Host laufen und auf `127.0.0.1:8000` proxyen.
|
||||
- Das Backend lauscht absichtlich nur auf `127.0.0.1`, damit es nicht direkt aus dem Internet erreichbar ist.
|
||||
- Fuer produktiven Betrieb solltest du TLS im NGINX-Terminator aktivieren.
|
||||
- Das Frontend wird direkt vom FastAPI-Container ausgeliefert, es ist kein Node- oder Build-Container noetig.
|
||||
|
||||
## Naechste sinnvolle Ausbaustufen
|
||||
|
||||
- Web-Frontend fuer Watchlist und Events
|
||||
- Weitere Datenquellen neben Ticketmaster
|
||||
- Telegram oder Push-Benachrichtigungen
|
||||
- Nutzerverwaltung
|
||||
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
28
backend/app/config.py
Normal file
28
backend/app/config.py
Normal file
@ -0,0 +1,28 @@
|
||||
import os
|
||||
|
||||
|
||||
class Settings:
|
||||
app_name = os.getenv("APP_NAME", "eventlens")
|
||||
app_env = os.getenv("APP_ENV", "production")
|
||||
|
||||
db_user = os.getenv("DB_USER", "eventlens")
|
||||
db_password = os.getenv("DB_PASSWORD", "eventlens")
|
||||
db_host = os.getenv("DB_HOST", "db")
|
||||
db_port = os.getenv("DB_PORT", "3306")
|
||||
db_name = os.getenv("DB_NAME", "eventlens")
|
||||
|
||||
ticketmaster_api_key = os.getenv("TICKETMASTER_API_KEY", "")
|
||||
poll_interval_hours = int(os.getenv("POLL_INTERVAL_HOURS", "6"))
|
||||
reminder_interval_hours = int(os.getenv("REMINDER_INTERVAL_HOURS", "12"))
|
||||
|
||||
smtp_host = os.getenv("SMTP_HOST", "")
|
||||
smtp_port = int(os.getenv("SMTP_PORT", "587"))
|
||||
smtp_user = os.getenv("SMTP_USER", "")
|
||||
smtp_pass = os.getenv("SMTP_PASS", "")
|
||||
smtp_sender = os.getenv("SMTP_SENDER", "eventlens@example.local")
|
||||
notification_email_to = os.getenv("NOTIFICATION_EMAIL_TO", "")
|
||||
smtp_starttls = os.getenv("SMTP_STARTTLS", "true").lower() == "true"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
27
backend/app/database.py
Normal file
27
backend/app/database.py
Normal file
@ -0,0 +1,27 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import declarative_base, sessionmaker
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
DATABASE_URL = (
|
||||
f"mysql+pymysql://{settings.db_user}:{settings.db_password}"
|
||||
f"@{settings.db_host}:{settings.db_port}/{settings.db_name}"
|
||||
)
|
||||
|
||||
engine = create_engine(
|
||||
DATABASE_URL,
|
||||
pool_pre_ping=True,
|
||||
future=True,
|
||||
)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
133
backend/app/frontend/index.html
Normal file
133
backend/app/frontend/index.html
Normal file
@ -0,0 +1,133 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>eventlens</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=IBM+Plex+Mono:wght@400;500&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="page-shell">
|
||||
<header class="hero">
|
||||
<div class="hero-copy">
|
||||
<p class="eyebrow">eventlens</p>
|
||||
<h1>Konzerte und Events im Blick, bevor sie an dir vorbeiziehen.</h1>
|
||||
<p class="hero-text">
|
||||
Beobachte Kuenstler oder Ereignisse fuer Hamburg oder ganz Deutschland,
|
||||
synchronisiere neue Termine und markiere gekaufte Tickets fuer die
|
||||
Erinnerungsfunktion.
|
||||
</p>
|
||||
<div class="hero-actions">
|
||||
<button id="sync-button" class="primary-button">Jetzt synchronisieren</button>
|
||||
<a class="ghost-button" href="/docs" target="_blank" rel="noreferrer">API Docs</a>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="status-panel">
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Watchlist</span>
|
||||
<strong id="watch-count">0</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Gefundene Events</span>
|
||||
<strong id="event-count">0</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Mit Ticket</span>
|
||||
<strong id="ticket-count">0</strong>
|
||||
</div>
|
||||
<div class="stat-card wide">
|
||||
<span class="stat-label">Letzte Aktion</span>
|
||||
<strong id="sync-status">Noch kein Sync ausgefuehrt</strong>
|
||||
</div>
|
||||
</aside>
|
||||
</header>
|
||||
|
||||
<main class="dashboard-grid">
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="section-kicker">Watchlist</p>
|
||||
<h2>Neue Beobachtung anlegen</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="watch-form" class="watch-form">
|
||||
<label>
|
||||
Name
|
||||
<input id="watch-name" name="name" type="text" placeholder="z. B. Deichkind" required />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Typ
|
||||
<select id="watch-type" name="watch_type">
|
||||
<option value="artist">Kuenstler</option>
|
||||
<option value="event">Event</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Region
|
||||
<select id="watch-region" name="region_scope">
|
||||
<option value="hamburg">Hamburg</option>
|
||||
<option value="germany">Deutschland</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="full-width">
|
||||
Notiz
|
||||
<textarea id="watch-notes" name="notes" rows="3" placeholder="Optional, z. B. bevorzugte Venue oder Tourname"></textarea>
|
||||
</label>
|
||||
|
||||
<button type="submit" class="primary-button">Zur Watchlist hinzufuegen</button>
|
||||
</form>
|
||||
|
||||
<div id="watch-items" class="watch-list empty-state">
|
||||
Noch keine Eintraege vorhanden.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="section-kicker">Events</p>
|
||||
<h2>Gefundene Termine</h2>
|
||||
</div>
|
||||
<div class="panel-tools">
|
||||
<select id="event-filter">
|
||||
<option value="all">Alle</option>
|
||||
<option value="ticketed">Nur mit Ticket</option>
|
||||
<option value="open">Nur offen</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="events" class="event-list empty-state">
|
||||
Noch keine Events vorhanden.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel panel-wide">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="section-kicker">Benachrichtigungen</p>
|
||||
<h2>Versandprotokoll</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="notifications" class="notification-list empty-state">
|
||||
Noch keine Benachrichtigungen protokolliert.
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast" aria-live="polite"></div>
|
||||
<script src="/static/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
338
backend/app/frontend/static/app.js
Normal file
338
backend/app/frontend/static/app.js
Normal file
@ -0,0 +1,338 @@
|
||||
const state = {
|
||||
watchItems: [],
|
||||
events: [],
|
||||
notifications: [],
|
||||
};
|
||||
|
||||
const watchItemsEl = document.querySelector("#watch-items");
|
||||
const eventsEl = document.querySelector("#events");
|
||||
const notificationsEl = document.querySelector("#notifications");
|
||||
const watchForm = document.querySelector("#watch-form");
|
||||
const eventFilter = document.querySelector("#event-filter");
|
||||
const syncButton = document.querySelector("#sync-button");
|
||||
const toastEl = document.querySelector("#toast");
|
||||
|
||||
function showToast(message) {
|
||||
toastEl.textContent = message;
|
||||
toastEl.classList.add("visible");
|
||||
window.clearTimeout(showToast.timeoutId);
|
||||
showToast.timeoutId = window.setTimeout(() => {
|
||||
toastEl.classList.remove("visible");
|
||||
}, 2600);
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) {
|
||||
return "unbekannt";
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("de-DE", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
async function apiFetch(url, options = {}) {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(options.headers || {}),
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let message = "Die Anfrage ist fehlgeschlagen.";
|
||||
try {
|
||||
const payload = await response.json();
|
||||
message = payload.detail || message;
|
||||
} catch (_) {
|
||||
message = response.statusText || message;
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function renderStats() {
|
||||
document.querySelector("#watch-count").textContent = state.watchItems.length;
|
||||
document.querySelector("#event-count").textContent = state.events.length;
|
||||
document.querySelector("#ticket-count").textContent = state.events.filter(
|
||||
(event) => event.is_ticket_purchased
|
||||
).length;
|
||||
}
|
||||
|
||||
function renderWatchItems() {
|
||||
if (!state.watchItems.length) {
|
||||
watchItemsEl.className = "watch-list empty-state";
|
||||
watchItemsEl.textContent = "Noch keine Eintraege vorhanden.";
|
||||
return;
|
||||
}
|
||||
|
||||
watchItemsEl.className = "watch-list";
|
||||
watchItemsEl.innerHTML = state.watchItems
|
||||
.map(
|
||||
(item) => `
|
||||
<article class="watch-card">
|
||||
<div class="watch-header">
|
||||
<div>
|
||||
<h3>${escapeHtml(item.name)}</h3>
|
||||
<div class="pill-row">
|
||||
<span class="pill">${item.watch_type}</span>
|
||||
<span class="pill">${item.region_scope}</span>
|
||||
<span class="pill ${item.is_active ? "success" : "warning"}">
|
||||
${item.is_active ? "aktiv" : "pausiert"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<button class="action-button" data-action="toggle-watch" data-id="${item.id}">
|
||||
${item.is_active ? "Pausieren" : "Aktivieren"}
|
||||
</button>
|
||||
<button class="action-button danger" data-action="delete-watch" data-id="${item.id}">
|
||||
Loeschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p>${escapeHtml(item.notes || "Keine Notiz hinterlegt.")}</p>
|
||||
</article>
|
||||
`
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function getWatchNameById(id) {
|
||||
return state.watchItems.find((item) => item.id === id)?.name || `Watch #${id}`;
|
||||
}
|
||||
|
||||
function renderEvents() {
|
||||
const filterValue = eventFilter.value;
|
||||
const filteredEvents = state.events.filter((event) => {
|
||||
if (filterValue === "ticketed") {
|
||||
return event.is_ticket_purchased;
|
||||
}
|
||||
if (filterValue === "open") {
|
||||
return !event.is_ticket_purchased;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!filteredEvents.length) {
|
||||
eventsEl.className = "event-list empty-state";
|
||||
eventsEl.textContent = "Noch keine Events vorhanden.";
|
||||
return;
|
||||
}
|
||||
|
||||
eventsEl.className = "event-list";
|
||||
eventsEl.innerHTML = filteredEvents
|
||||
.map(
|
||||
(event) => `
|
||||
<article class="event-card">
|
||||
<div class="event-header">
|
||||
<div>
|
||||
<h3>${escapeHtml(event.title)}</h3>
|
||||
<div class="pill-row">
|
||||
<span class="pill">${escapeHtml(getWatchNameById(event.watch_item_id))}</span>
|
||||
<span class="pill">${escapeHtml(event.city || "ohne Stadt")}</span>
|
||||
<span class="pill ${event.is_ticket_purchased ? "success" : "warning"}">
|
||||
${event.is_ticket_purchased ? "Ticket markiert" : "ohne Ticket"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="event-actions">
|
||||
<button
|
||||
class="action-button ${event.is_ticket_purchased ? "" : "success"}"
|
||||
data-action="toggle-ticket"
|
||||
data-id="${event.id}"
|
||||
data-value="${event.is_ticket_purchased ? "false" : "true"}"
|
||||
>
|
||||
${event.is_ticket_purchased ? "Ticket entfernen" : "Ticket gekauft"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="event-meta">
|
||||
<span><strong>Datum:</strong> ${escapeHtml(formatDate(event.event_date))}</span>
|
||||
<span><strong>Venue:</strong> ${escapeHtml(event.venue_name || "unbekannt")}</span>
|
||||
<span><strong>Quelle:</strong> ${escapeHtml(event.source)}</span>
|
||||
</div>
|
||||
${
|
||||
event.ticket_url
|
||||
? `<p><a class="event-link" href="${escapeHtml(event.ticket_url)}" target="_blank" rel="noreferrer">Ticketlink oeffnen</a></p>`
|
||||
: ""
|
||||
}
|
||||
</article>
|
||||
`
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderNotifications() {
|
||||
if (!state.notifications.length) {
|
||||
notificationsEl.className = "notification-list empty-state";
|
||||
notificationsEl.textContent = "Noch keine Benachrichtigungen protokolliert.";
|
||||
return;
|
||||
}
|
||||
|
||||
notificationsEl.className = "notification-list";
|
||||
notificationsEl.innerHTML = state.notifications
|
||||
.slice(0, 12)
|
||||
.map(
|
||||
(entry) => `
|
||||
<article class="notification-card">
|
||||
<div class="notification-header">
|
||||
<div>
|
||||
<h3>${escapeHtml(entry.notification_type)}</h3>
|
||||
<div class="pill-row">
|
||||
<span class="pill ${
|
||||
entry.status === "sent"
|
||||
? "success"
|
||||
: entry.status === "failed"
|
||||
? "danger"
|
||||
: "warning"
|
||||
}">${escapeHtml(entry.status)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="muted">${escapeHtml(formatDate(entry.created_at))}</span>
|
||||
</div>
|
||||
<p>${escapeHtml(entry.message)}</p>
|
||||
</article>
|
||||
`
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function updateSyncStatus(message) {
|
||||
document.querySelector("#sync-status").textContent = message;
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
const [watchItems, events, notifications] = await Promise.all([
|
||||
apiFetch("/watch-items"),
|
||||
apiFetch("/events"),
|
||||
apiFetch("/notifications"),
|
||||
]);
|
||||
|
||||
state.watchItems = watchItems;
|
||||
state.events = events;
|
||||
state.notifications = notifications;
|
||||
|
||||
renderStats();
|
||||
renderWatchItems();
|
||||
renderEvents();
|
||||
renderNotifications();
|
||||
}
|
||||
|
||||
watchForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const payload = {
|
||||
name: document.querySelector("#watch-name").value.trim(),
|
||||
watch_type: document.querySelector("#watch-type").value,
|
||||
region_scope: document.querySelector("#watch-region").value,
|
||||
notes: document.querySelector("#watch-notes").value.trim() || null,
|
||||
};
|
||||
|
||||
if (!payload.name) {
|
||||
showToast("Bitte einen Namen eingeben.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await apiFetch("/watch-items", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
watchForm.reset();
|
||||
document.querySelector("#watch-region").value = "hamburg";
|
||||
document.querySelector("#watch-type").value = "artist";
|
||||
await loadData();
|
||||
showToast("Watchlist-Eintrag angelegt.");
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
}
|
||||
});
|
||||
|
||||
syncButton.addEventListener("click", async () => {
|
||||
syncButton.disabled = true;
|
||||
syncButton.textContent = "Synchronisiere...";
|
||||
|
||||
try {
|
||||
const result = await apiFetch("/sync", { method: "POST" });
|
||||
updateSyncStatus(
|
||||
`${result.new_events} neue Events, ${result.updated_events} aktualisiert, ${result.notifications_sent} Benachrichtigungen versendet`
|
||||
);
|
||||
await loadData();
|
||||
showToast("Synchronisation abgeschlossen.");
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
} finally {
|
||||
syncButton.disabled = false;
|
||||
syncButton.textContent = "Jetzt synchronisieren";
|
||||
}
|
||||
});
|
||||
|
||||
eventFilter.addEventListener("change", renderEvents);
|
||||
|
||||
document.addEventListener("click", async (event) => {
|
||||
const button = event.target.closest("button[data-action]");
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { action, id, value } = button.dataset;
|
||||
|
||||
try {
|
||||
if (action === "delete-watch") {
|
||||
await apiFetch(`/watch-items/${id}`, { method: "DELETE" });
|
||||
await loadData();
|
||||
showToast("Watchlist-Eintrag geloescht.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "toggle-watch") {
|
||||
const watchItem = state.watchItems.find((item) => item.id === Number(id));
|
||||
await apiFetch(`/watch-items/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ is_active: !watchItem.is_active }),
|
||||
});
|
||||
await loadData();
|
||||
showToast("Watchlist-Status aktualisiert.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "toggle-ticket") {
|
||||
await apiFetch(`/events/${id}/purchase`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ is_ticket_purchased: value === "true" }),
|
||||
});
|
||||
await loadData();
|
||||
showToast("Ticketstatus aktualisiert.");
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
}
|
||||
});
|
||||
|
||||
loadData().catch((error) => {
|
||||
updateSyncStatus("Fehler beim Laden der Daten");
|
||||
showToast(error.message);
|
||||
});
|
||||
439
backend/app/frontend/static/styles.css
Normal file
439
backend/app/frontend/static/styles.css
Normal file
@ -0,0 +1,439 @@
|
||||
:root {
|
||||
--bg: #f5efe3;
|
||||
--bg-accent: #efe3cf;
|
||||
--surface: rgba(255, 252, 246, 0.82);
|
||||
--surface-strong: #fffaf2;
|
||||
--line: rgba(46, 39, 30, 0.14);
|
||||
--text: #1f1a17;
|
||||
--muted: #65594d;
|
||||
--primary: #c24d2c;
|
||||
--primary-dark: #8f381f;
|
||||
--success: #245e3f;
|
||||
--warning: #8d5a13;
|
||||
--danger: #8a2f2f;
|
||||
--shadow: 0 22px 70px rgba(96, 64, 24, 0.14);
|
||||
--radius-lg: 28px;
|
||||
--radius-md: 18px;
|
||||
--radius-sm: 12px;
|
||||
--mono: "IBM Plex Mono", monospace;
|
||||
--sans: "Space Grotesk", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: var(--sans);
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(194, 77, 44, 0.18), transparent 28%),
|
||||
radial-gradient(circle at 85% 18%, rgba(36, 94, 63, 0.14), transparent 22%),
|
||||
linear-gradient(135deg, var(--bg), #f7f2ea 48%, var(--bg-accent));
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(rgba(255, 255, 255, 0.08) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.08) 1px, transparent 1px);
|
||||
background-size: 32px 32px;
|
||||
mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.22), transparent 72%);
|
||||
}
|
||||
|
||||
.page-shell {
|
||||
width: min(1280px, calc(100vw - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 32px 0 56px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: 1.6fr 1fr;
|
||||
gap: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.hero-copy,
|
||||
.status-panel,
|
||||
.panel {
|
||||
backdrop-filter: blur(12px);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
padding: 36px;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.eyebrow,
|
||||
.section-kicker,
|
||||
.stat-label,
|
||||
.meta-label {
|
||||
margin: 0 0 10px;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.76rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(2.5rem, 5vw, 4.9rem);
|
||||
line-height: 0.95;
|
||||
max-width: 11ch;
|
||||
}
|
||||
|
||||
.hero-text {
|
||||
max-width: 58ch;
|
||||
margin-top: 18px;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.6;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 26px;
|
||||
}
|
||||
|
||||
.status-panel {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
padding: 20px;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 18px;
|
||||
background: rgba(255, 250, 242, 0.86);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid rgba(46, 39, 30, 0.08);
|
||||
}
|
||||
|
||||
.stat-card strong {
|
||||
display: block;
|
||||
font-size: 2rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.stat-card.wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.stat-card.wide strong {
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 24px;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.panel-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.panel-tools {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.watch-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
font-size: 0.95rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid rgba(46, 39, 30, 0.16);
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
color: var(--text);
|
||||
transition: border-color 160ms ease, transform 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: rgba(194, 77, 44, 0.68);
|
||||
box-shadow: 0 0 0 4px rgba(194, 77, 44, 0.12);
|
||||
}
|
||||
|
||||
button,
|
||||
.ghost-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 48px;
|
||||
padding: 0 18px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: transform 180ms ease, box-shadow 180ms ease, background 180ms ease;
|
||||
}
|
||||
|
||||
button:hover,
|
||||
.ghost-button:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
background: linear-gradient(135deg, var(--primary), #d0722e);
|
||||
color: white;
|
||||
box-shadow: 0 14px 30px rgba(194, 77, 44, 0.26);
|
||||
}
|
||||
|
||||
.ghost-button {
|
||||
background: rgba(255, 255, 255, 0.62);
|
||||
color: var(--text);
|
||||
border: 1px solid rgba(46, 39, 30, 0.12);
|
||||
}
|
||||
|
||||
.watch-list,
|
||||
.event-list,
|
||||
.notification-list {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 28px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px dashed rgba(46, 39, 30, 0.18);
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.watch-card,
|
||||
.event-card,
|
||||
.notification-card {
|
||||
padding: 18px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-strong);
|
||||
border: 1px solid rgba(46, 39, 30, 0.08);
|
||||
}
|
||||
|
||||
.watch-card,
|
||||
.event-card {
|
||||
transform: translateY(6px);
|
||||
opacity: 0;
|
||||
animation: riseIn 420ms ease forwards;
|
||||
}
|
||||
|
||||
.watch-header,
|
||||
.event-header,
|
||||
.notification-header {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.pill-row,
|
||||
.action-row,
|
||||
.event-actions,
|
||||
.event-meta,
|
||||
.notification-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(194, 77, 44, 0.09);
|
||||
color: var(--primary-dark);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.pill.success {
|
||||
background: rgba(36, 94, 63, 0.12);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.pill.warning {
|
||||
background: rgba(141, 90, 19, 0.14);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.pill.danger {
|
||||
background: rgba(138, 47, 47, 0.12);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.watch-card p,
|
||||
.event-card p,
|
||||
.notification-card p {
|
||||
margin-top: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
min-height: 38px;
|
||||
padding: 0 14px;
|
||||
border-radius: 999px;
|
||||
background: rgba(46, 39, 30, 0.08);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.action-button.danger {
|
||||
background: rgba(138, 47, 47, 0.1);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.action-button.success {
|
||||
background: rgba(36, 94, 63, 0.1);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.event-meta,
|
||||
.notification-meta {
|
||||
margin-top: 14px;
|
||||
color: var(--muted);
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
|
||||
.event-link {
|
||||
color: var(--primary-dark);
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
max-width: min(420px, calc(100vw - 40px));
|
||||
padding: 14px 18px;
|
||||
border-radius: 16px;
|
||||
background: rgba(31, 26, 23, 0.92);
|
||||
color: white;
|
||||
box-shadow: 0 18px 40px rgba(31, 26, 23, 0.24);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(10px);
|
||||
transition: opacity 200ms ease, transform 200ms ease;
|
||||
}
|
||||
|
||||
.toast.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
@keyframes riseIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.hero,
|
||||
.dashboard-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.panel-wide {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.page-shell {
|
||||
width: min(100vw - 20px, 1280px);
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
.hero-copy,
|
||||
.panel,
|
||||
.status-panel {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.watch-form,
|
||||
.status-panel {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
h1 {
|
||||
max-width: none;
|
||||
font-size: clamp(2.3rem, 13vw, 3.6rem);
|
||||
}
|
||||
|
||||
.panel-header,
|
||||
.watch-header,
|
||||
.event-header,
|
||||
.notification-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
140
backend/app/main.py
Normal file
140
backend/app/main.py
Normal file
@ -0,0 +1,140 @@
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.database import Base, engine, get_db
|
||||
from app.models import TrackedEvent, WatchItem
|
||||
from app.scheduler import start_scheduler
|
||||
from app.schemas import (
|
||||
NotificationLogRead,
|
||||
PurchaseUpdate,
|
||||
SyncResult,
|
||||
TrackedEventRead,
|
||||
WatchItemCreate,
|
||||
WatchItemRead,
|
||||
WatchItemUpdate,
|
||||
)
|
||||
from app.services import list_events, list_notifications, list_watch_items, run_sync
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="eventlens",
|
||||
version="0.1.0",
|
||||
description="Beobachtet Kuenstler und Events in Hamburg oder Deutschland.",
|
||||
)
|
||||
|
||||
frontend_dir = Path(__file__).resolve().parent / "frontend"
|
||||
static_dir = frontend_dir / "static"
|
||||
app.mount("/static", StaticFiles(directory=static_dir), name="static")
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def startup():
|
||||
Base.metadata.create_all(bind=engine)
|
||||
start_scheduler()
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
return FileResponse(frontend_dir / "index.html")
|
||||
|
||||
|
||||
@app.get("/api")
|
||||
def api_info():
|
||||
return {
|
||||
"service": settings.app_name,
|
||||
"status": "running",
|
||||
"docs": "/docs",
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/watch-items", response_model=list[WatchItemRead])
|
||||
def get_watch_items(db: Session = Depends(get_db)):
|
||||
return list_watch_items(db)
|
||||
|
||||
|
||||
@app.post("/watch-items", response_model=WatchItemRead, status_code=201)
|
||||
def create_watch_item(payload: WatchItemCreate, db: Session = Depends(get_db)):
|
||||
watch_item = WatchItem(
|
||||
name=payload.name,
|
||||
watch_type=payload.watch_type,
|
||||
region_scope=payload.region_scope,
|
||||
notes=payload.notes,
|
||||
)
|
||||
db.add(watch_item)
|
||||
db.commit()
|
||||
db.refresh(watch_item)
|
||||
return watch_item
|
||||
|
||||
|
||||
@app.patch("/watch-items/{watch_item_id}", response_model=WatchItemRead)
|
||||
def update_watch_item(
|
||||
watch_item_id: int,
|
||||
payload: WatchItemUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
watch_item = db.get(WatchItem, watch_item_id)
|
||||
if watch_item is None:
|
||||
raise HTTPException(status_code=404, detail="Watch item nicht gefunden.")
|
||||
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
for field_name, value in updates.items():
|
||||
setattr(watch_item, field_name, value)
|
||||
watch_item.updated_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
db.refresh(watch_item)
|
||||
return watch_item
|
||||
|
||||
|
||||
@app.delete("/watch-items/{watch_item_id}", status_code=204)
|
||||
def delete_watch_item(watch_item_id: int, db: Session = Depends(get_db)):
|
||||
watch_item = db.get(WatchItem, watch_item_id)
|
||||
if watch_item is None:
|
||||
raise HTTPException(status_code=404, detail="Watch item nicht gefunden.")
|
||||
|
||||
db.delete(watch_item)
|
||||
db.commit()
|
||||
|
||||
|
||||
@app.get("/events", response_model=list[TrackedEventRead])
|
||||
def get_events(db: Session = Depends(get_db)):
|
||||
return list_events(db)
|
||||
|
||||
|
||||
@app.patch("/events/{event_id}/purchase", response_model=TrackedEventRead)
|
||||
def update_purchase_status(
|
||||
event_id: int,
|
||||
payload: PurchaseUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tracked_event = db.get(TrackedEvent, event_id)
|
||||
if tracked_event is None:
|
||||
raise HTTPException(status_code=404, detail="Event nicht gefunden.")
|
||||
|
||||
tracked_event.is_ticket_purchased = payload.is_ticket_purchased
|
||||
tracked_event.purchased_at = datetime.utcnow() if payload.is_ticket_purchased else None
|
||||
tracked_event.reminder_notified_at = None
|
||||
db.commit()
|
||||
db.refresh(tracked_event)
|
||||
return tracked_event
|
||||
|
||||
|
||||
@app.get("/notifications", response_model=list[NotificationLogRead])
|
||||
def get_notifications(db: Session = Depends(get_db)):
|
||||
return list_notifications(db)
|
||||
|
||||
|
||||
@app.post("/sync", response_model=SyncResult)
|
||||
def trigger_sync(db: Session = Depends(get_db)):
|
||||
return run_sync(db)
|
||||
111
backend/app/models.py
Normal file
111
backend/app/models.py
Normal file
@ -0,0 +1,111 @@
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Enum as SqlEnum, ForeignKey, Integer, JSON
|
||||
from sqlalchemy import String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class WatchType(str, Enum):
|
||||
artist = "artist"
|
||||
event = "event"
|
||||
|
||||
|
||||
class RegionScope(str, Enum):
|
||||
hamburg = "hamburg"
|
||||
germany = "germany"
|
||||
|
||||
|
||||
class NotificationType(str, Enum):
|
||||
discovery = "discovery"
|
||||
reminder = "reminder"
|
||||
|
||||
|
||||
class NotificationStatus(str, Enum):
|
||||
sent = "sent"
|
||||
skipped = "skipped"
|
||||
failed = "failed"
|
||||
|
||||
|
||||
class WatchItem(Base):
|
||||
__tablename__ = "watch_items"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
watch_type: Mapped[WatchType] = mapped_column(SqlEnum(WatchType), nullable=False)
|
||||
region_scope: Mapped[RegionScope] = mapped_column(SqlEnum(RegionScope), nullable=False)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=datetime.utcnow, nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
default=datetime.utcnow,
|
||||
onupdate=datetime.utcnow,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
tracked_events: Mapped[list["TrackedEvent"]] = relationship(
|
||||
back_populates="watch_item", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class TrackedEvent(Base):
|
||||
__tablename__ = "tracked_events"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("watch_item_id", "source", "external_id", name="uq_watch_event"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
watch_item_id: Mapped[int] = mapped_column(ForeignKey("watch_items.id"), nullable=False)
|
||||
source: Mapped[str] = mapped_column(String(50), nullable=False, default="ticketmaster")
|
||||
external_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
matched_term: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
venue_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
city: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
country_code: Mapped[str | None] = mapped_column(String(10), nullable=True)
|
||||
event_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
ticket_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||
image_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||
is_ticket_purchased: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
purchased_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
discovery_notified_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
reminder_notified_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
first_seen_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=datetime.utcnow, nullable=False
|
||||
)
|
||||
last_seen_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=datetime.utcnow, nullable=False
|
||||
)
|
||||
raw_payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
watch_item: Mapped[WatchItem] = relationship(back_populates="tracked_events")
|
||||
notifications: Mapped[list["NotificationLog"]] = relationship(
|
||||
back_populates="tracked_event", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class NotificationLog(Base):
|
||||
__tablename__ = "notification_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
tracked_event_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("tracked_events.id"), nullable=False
|
||||
)
|
||||
notification_type: Mapped[NotificationType] = mapped_column(
|
||||
SqlEnum(NotificationType), nullable=False
|
||||
)
|
||||
status: Mapped[NotificationStatus] = mapped_column(
|
||||
SqlEnum(NotificationStatus), nullable=False
|
||||
)
|
||||
message: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=datetime.utcnow, nullable=False
|
||||
)
|
||||
|
||||
tracked_event: Mapped[TrackedEvent] = relationship(back_populates="notifications")
|
||||
|
||||
75
backend/app/notifications.py
Normal file
75
backend/app/notifications.py
Normal file
@ -0,0 +1,75 @@
|
||||
import smtplib
|
||||
from email.message import EmailMessage
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.models import NotificationLog, NotificationStatus, NotificationType, TrackedEvent
|
||||
|
||||
|
||||
def _create_log(
|
||||
db: Session,
|
||||
tracked_event: TrackedEvent,
|
||||
notification_type: NotificationType,
|
||||
status: NotificationStatus,
|
||||
message: str,
|
||||
) -> NotificationLog:
|
||||
log_entry = NotificationLog(
|
||||
tracked_event=tracked_event,
|
||||
notification_type=notification_type,
|
||||
status=status,
|
||||
message=message,
|
||||
)
|
||||
db.add(log_entry)
|
||||
return log_entry
|
||||
|
||||
|
||||
def send_email_notification(
|
||||
db: Session,
|
||||
tracked_event: TrackedEvent,
|
||||
notification_type: NotificationType,
|
||||
subject: str,
|
||||
body: str,
|
||||
) -> NotificationStatus:
|
||||
if not settings.smtp_host or not settings.notification_email_to:
|
||||
_create_log(
|
||||
db,
|
||||
tracked_event,
|
||||
notification_type,
|
||||
NotificationStatus.skipped,
|
||||
"SMTP oder Empfaengeradresse nicht konfiguriert.",
|
||||
)
|
||||
return NotificationStatus.skipped
|
||||
|
||||
message = EmailMessage()
|
||||
message["Subject"] = subject
|
||||
message["From"] = settings.smtp_sender
|
||||
message["To"] = settings.notification_email_to
|
||||
message.set_content(body)
|
||||
|
||||
try:
|
||||
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=20) as smtp:
|
||||
if settings.smtp_starttls:
|
||||
smtp.starttls()
|
||||
if settings.smtp_user:
|
||||
smtp.login(settings.smtp_user, settings.smtp_pass)
|
||||
smtp.send_message(message)
|
||||
except Exception as exc:
|
||||
_create_log(
|
||||
db,
|
||||
tracked_event,
|
||||
notification_type,
|
||||
NotificationStatus.failed,
|
||||
f"E-Mail-Versand fehlgeschlagen: {exc}",
|
||||
)
|
||||
return NotificationStatus.failed
|
||||
|
||||
_create_log(
|
||||
db,
|
||||
tracked_event,
|
||||
notification_type,
|
||||
NotificationStatus.sent,
|
||||
f"E-Mail an {settings.notification_email_to} versendet.",
|
||||
)
|
||||
return NotificationStatus.sent
|
||||
|
||||
1
backend/app/providers/__init__.py
Normal file
1
backend/app/providers/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
91
backend/app/providers/ticketmaster.py
Normal file
91
backend/app/providers/ticketmaster.py
Normal file
@ -0,0 +1,91 @@
|
||||
from datetime import datetime
|
||||
|
||||
import requests
|
||||
|
||||
from app.config import settings
|
||||
from app.models import RegionScope, WatchType
|
||||
|
||||
|
||||
class TicketmasterProvider:
|
||||
base_url = "https://app.ticketmaster.com/discovery/v2/events.json"
|
||||
source_name = "ticketmaster"
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
return bool(settings.ticketmaster_api_key)
|
||||
|
||||
def search_events(
|
||||
self,
|
||||
term: str,
|
||||
watch_type: WatchType,
|
||||
region_scope: RegionScope,
|
||||
) -> list[dict]:
|
||||
if not self.is_configured():
|
||||
return []
|
||||
|
||||
params = {
|
||||
"apikey": settings.ticketmaster_api_key,
|
||||
"keyword": term,
|
||||
"locale": "*",
|
||||
"size": 50,
|
||||
"sort": "date,asc",
|
||||
"countryCode": "DE",
|
||||
}
|
||||
|
||||
if region_scope == RegionScope.hamburg:
|
||||
params["city"] = "Hamburg"
|
||||
|
||||
response = requests.get(self.base_url, params=params, timeout=30)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
items = payload.get("_embedded", {}).get("events", [])
|
||||
results: list[dict] = []
|
||||
|
||||
normalized_term = term.casefold()
|
||||
for item in items:
|
||||
title = item.get("name", "")
|
||||
attractions = item.get("_embedded", {}).get("attractions", [])
|
||||
attraction_names = [entry.get("name", "") for entry in attractions]
|
||||
|
||||
if watch_type == WatchType.artist:
|
||||
haystack = " ".join(attraction_names + [title]).casefold()
|
||||
if normalized_term not in haystack:
|
||||
continue
|
||||
elif normalized_term not in title.casefold():
|
||||
continue
|
||||
|
||||
dates = item.get("dates", {}).get("start", {})
|
||||
local_date = dates.get("localDate")
|
||||
local_time = dates.get("localTime", "20:00:00")
|
||||
|
||||
event_date = None
|
||||
if local_date:
|
||||
try:
|
||||
event_date = datetime.fromisoformat(f"{local_date}T{local_time}")
|
||||
except ValueError:
|
||||
event_date = datetime.fromisoformat(f"{local_date}T20:00:00")
|
||||
|
||||
venue = (
|
||||
item.get("_embedded", {})
|
||||
.get("venues", [{}])[0]
|
||||
)
|
||||
|
||||
results.append(
|
||||
{
|
||||
"external_id": item.get("id"),
|
||||
"title": title,
|
||||
"matched_term": term,
|
||||
"venue_name": venue.get("name"),
|
||||
"city": venue.get("city", {}).get("name"),
|
||||
"country_code": venue.get("country", {}).get("countryCode"),
|
||||
"event_date": event_date,
|
||||
"ticket_url": item.get("url"),
|
||||
"image_url": next(
|
||||
(image.get("url") for image in item.get("images", []) if image.get("url")),
|
||||
None,
|
||||
),
|
||||
"raw_payload": item,
|
||||
}
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
40
backend/app/scheduler.py
Normal file
40
backend/app/scheduler.py
Normal file
@ -0,0 +1,40 @@
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
|
||||
from app.config import settings
|
||||
from app.database import SessionLocal
|
||||
from app.services import run_sync, send_reminders
|
||||
|
||||
|
||||
scheduler = BackgroundScheduler(timezone="Europe/Berlin")
|
||||
|
||||
|
||||
def sync_job():
|
||||
with SessionLocal() as db:
|
||||
run_sync(db)
|
||||
|
||||
|
||||
def reminder_job():
|
||||
with SessionLocal() as db:
|
||||
send_reminders(db)
|
||||
|
||||
|
||||
def start_scheduler():
|
||||
if scheduler.running:
|
||||
return
|
||||
|
||||
scheduler.add_job(
|
||||
sync_job,
|
||||
"interval",
|
||||
hours=settings.poll_interval_hours,
|
||||
id="sync_job",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.add_job(
|
||||
reminder_job,
|
||||
"interval",
|
||||
hours=settings.reminder_interval_hours,
|
||||
id="reminder_job",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.start()
|
||||
|
||||
80
backend/app/schemas.py
Normal file
80
backend/app/schemas.py
Normal file
@ -0,0 +1,80 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models import NotificationStatus, NotificationType, RegionScope, WatchType
|
||||
|
||||
|
||||
class WatchItemCreate(BaseModel):
|
||||
name: str = Field(min_length=2, max_length=255)
|
||||
watch_type: WatchType
|
||||
region_scope: RegionScope
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class WatchItemUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=2, max_length=255)
|
||||
watch_type: WatchType | None = None
|
||||
region_scope: RegionScope | None = None
|
||||
notes: str | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class WatchItemRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
name: str
|
||||
watch_type: WatchType
|
||||
region_scope: RegionScope
|
||||
notes: str | None
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class PurchaseUpdate(BaseModel):
|
||||
is_ticket_purchased: bool
|
||||
|
||||
|
||||
class TrackedEventRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
watch_item_id: int
|
||||
source: str
|
||||
external_id: str
|
||||
title: str
|
||||
matched_term: str
|
||||
venue_name: str | None
|
||||
city: str | None
|
||||
country_code: str | None
|
||||
event_date: datetime | None
|
||||
ticket_url: str | None
|
||||
image_url: str | None
|
||||
is_ticket_purchased: bool
|
||||
purchased_at: datetime | None
|
||||
discovery_notified_at: datetime | None
|
||||
reminder_notified_at: datetime | None
|
||||
first_seen_at: datetime
|
||||
last_seen_at: datetime
|
||||
|
||||
|
||||
class NotificationLogRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
tracked_event_id: int
|
||||
notification_type: NotificationType
|
||||
status: NotificationStatus
|
||||
message: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class SyncResult(BaseModel):
|
||||
scanned_watch_items: int
|
||||
new_events: int
|
||||
updated_events: int
|
||||
notifications_sent: int
|
||||
notifications_skipped: int
|
||||
|
||||
175
backend/app/services.py
Normal file
175
backend/app/services.py
Normal file
@ -0,0 +1,175 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy import desc, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import NotificationStatus, NotificationType, TrackedEvent, WatchItem
|
||||
from app.notifications import send_email_notification
|
||||
from app.providers.ticketmaster import TicketmasterProvider
|
||||
from app.schemas import SyncResult
|
||||
|
||||
|
||||
def list_watch_items(db: Session) -> list[WatchItem]:
|
||||
return list(db.scalars(select(WatchItem).order_by(WatchItem.name)))
|
||||
|
||||
|
||||
def list_events(db: Session) -> list[TrackedEvent]:
|
||||
return list(db.scalars(select(TrackedEvent).order_by(desc(TrackedEvent.event_date))))
|
||||
|
||||
|
||||
def list_notifications(db: Session):
|
||||
from app.models import NotificationLog
|
||||
|
||||
return list(db.scalars(select(NotificationLog).order_by(desc(NotificationLog.created_at))))
|
||||
|
||||
|
||||
def upsert_event(
|
||||
db: Session,
|
||||
watch_item: WatchItem,
|
||||
provider_name: str,
|
||||
event_data: dict,
|
||||
) -> tuple[TrackedEvent, bool]:
|
||||
stmt = select(TrackedEvent).where(
|
||||
TrackedEvent.watch_item_id == watch_item.id,
|
||||
TrackedEvent.source == provider_name,
|
||||
TrackedEvent.external_id == event_data["external_id"],
|
||||
)
|
||||
tracked_event = db.scalar(stmt)
|
||||
is_new = tracked_event is None
|
||||
|
||||
if tracked_event is None:
|
||||
tracked_event = TrackedEvent(
|
||||
watch_item=watch_item,
|
||||
source=provider_name,
|
||||
external_id=event_data["external_id"],
|
||||
title=event_data["title"],
|
||||
matched_term=event_data["matched_term"],
|
||||
venue_name=event_data.get("venue_name"),
|
||||
city=event_data.get("city"),
|
||||
country_code=event_data.get("country_code"),
|
||||
event_date=event_data.get("event_date"),
|
||||
ticket_url=event_data.get("ticket_url"),
|
||||
image_url=event_data.get("image_url"),
|
||||
raw_payload=event_data.get("raw_payload"),
|
||||
)
|
||||
db.add(tracked_event)
|
||||
else:
|
||||
tracked_event.title = event_data["title"]
|
||||
tracked_event.matched_term = event_data["matched_term"]
|
||||
tracked_event.venue_name = event_data.get("venue_name")
|
||||
tracked_event.city = event_data.get("city")
|
||||
tracked_event.country_code = event_data.get("country_code")
|
||||
tracked_event.event_date = event_data.get("event_date")
|
||||
tracked_event.ticket_url = event_data.get("ticket_url")
|
||||
tracked_event.image_url = event_data.get("image_url")
|
||||
tracked_event.raw_payload = event_data.get("raw_payload")
|
||||
|
||||
tracked_event.last_seen_at = datetime.utcnow()
|
||||
return tracked_event, is_new
|
||||
|
||||
|
||||
def run_sync(db: Session) -> SyncResult:
|
||||
provider = TicketmasterProvider()
|
||||
active_items = list(
|
||||
db.scalars(select(WatchItem).where(WatchItem.is_active.is_(True)).order_by(WatchItem.name))
|
||||
)
|
||||
|
||||
new_events = 0
|
||||
updated_events = 0
|
||||
notifications_sent = 0
|
||||
notifications_skipped = 0
|
||||
|
||||
for watch_item in active_items:
|
||||
try:
|
||||
events = provider.search_events(
|
||||
term=watch_item.name,
|
||||
watch_type=watch_item.watch_type,
|
||||
region_scope=watch_item.region_scope,
|
||||
)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
continue
|
||||
for event_data in events:
|
||||
tracked_event, is_new = upsert_event(
|
||||
db=db,
|
||||
watch_item=watch_item,
|
||||
provider_name=provider.source_name,
|
||||
event_data=event_data,
|
||||
)
|
||||
|
||||
if is_new:
|
||||
new_events += 1
|
||||
else:
|
||||
updated_events += 1
|
||||
|
||||
if is_new and tracked_event.discovery_notified_at is None:
|
||||
status = send_email_notification(
|
||||
db=db,
|
||||
tracked_event=tracked_event,
|
||||
notification_type=NotificationType.discovery,
|
||||
subject=f"Neuer Termin fuer {watch_item.name}",
|
||||
body=(
|
||||
f"Es wurde ein neuer Termin fuer '{watch_item.name}' gefunden.\n\n"
|
||||
f"Titel: {tracked_event.title}\n"
|
||||
f"Ort: {tracked_event.venue_name or 'unbekannt'}\n"
|
||||
f"Stadt: {tracked_event.city or 'unbekannt'}\n"
|
||||
f"Datum: {tracked_event.event_date or 'unbekannt'}\n"
|
||||
f"Tickets: {tracked_event.ticket_url or 'keine URL'}\n"
|
||||
),
|
||||
)
|
||||
if status == NotificationStatus.sent:
|
||||
tracked_event.discovery_notified_at = datetime.utcnow()
|
||||
notifications_sent += 1
|
||||
else:
|
||||
notifications_skipped += 1
|
||||
|
||||
db.commit()
|
||||
|
||||
return SyncResult(
|
||||
scanned_watch_items=len(active_items),
|
||||
new_events=new_events,
|
||||
updated_events=updated_events,
|
||||
notifications_sent=notifications_sent,
|
||||
notifications_skipped=notifications_skipped,
|
||||
)
|
||||
|
||||
|
||||
def send_reminders(db: Session) -> int:
|
||||
now = datetime.utcnow()
|
||||
start = now + timedelta(days=6)
|
||||
end = now + timedelta(days=7, hours=12)
|
||||
|
||||
stmt = select(TrackedEvent).where(
|
||||
TrackedEvent.is_ticket_purchased.is_(True),
|
||||
TrackedEvent.event_date.is_not(None),
|
||||
TrackedEvent.reminder_notified_at.is_(None),
|
||||
)
|
||||
candidates = list(db.scalars(stmt))
|
||||
sent_count = 0
|
||||
|
||||
for tracked_event in candidates:
|
||||
if tracked_event.event_date is None:
|
||||
continue
|
||||
if not (start <= tracked_event.event_date <= end):
|
||||
continue
|
||||
|
||||
status = send_email_notification(
|
||||
db=db,
|
||||
tracked_event=tracked_event,
|
||||
notification_type=NotificationType.reminder,
|
||||
subject=f"Erinnerung: {tracked_event.title} in einer Woche",
|
||||
body=(
|
||||
f"Du hast Karten fuer '{tracked_event.title}' markiert.\n\n"
|
||||
f"Veranstaltung: {tracked_event.title}\n"
|
||||
f"Datum: {tracked_event.event_date}\n"
|
||||
f"Venue: {tracked_event.venue_name or 'unbekannt'}\n"
|
||||
f"Stadt: {tracked_event.city or 'unbekannt'}\n"
|
||||
f"Tickets: {tracked_event.ticket_url or 'keine URL'}\n"
|
||||
),
|
||||
)
|
||||
if status == NotificationStatus.sent:
|
||||
tracked_event.reminder_notified_at = datetime.utcnow()
|
||||
sent_count += 1
|
||||
|
||||
db.commit()
|
||||
return sent_count
|
||||
13
backend/dockerfile
Normal file
13
backend/dockerfile
Normal file
@ -0,0 +1,13 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app ./app
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
8
backend/requirements.txt
Normal file
8
backend/requirements.txt
Normal file
@ -0,0 +1,8 @@
|
||||
apscheduler==3.10.4
|
||||
fastapi==0.115.12
|
||||
pymysql==1.1.1
|
||||
python-dotenv==1.0.1
|
||||
requests==2.32.3
|
||||
sqlalchemy==2.0.40
|
||||
uvicorn[standard]==0.34.1
|
||||
|
||||
13
deploy/nginx/eventlens.conf
Normal file
13
deploy/nginx/eventlens.conf
Normal file
@ -0,0 +1,13 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name eventlens.example.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
32
docker-compose.yml
Normal file
32
docker-compose.yml
Normal file
@ -0,0 +1,32 @@
|
||||
services:
|
||||
db:
|
||||
image: mariadb:11
|
||||
container_name: eventlens_db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-rootsecret}
|
||||
MYSQL_DATABASE: ${DB_NAME:-eventlens}
|
||||
MYSQL_USER: ${DB_USER:-eventlens}
|
||||
MYSQL_PASSWORD: ${DB_PASSWORD:-eventlens}
|
||||
volumes:
|
||||
- db_data:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "mariadb-admin ping -h localhost -p$${MYSQL_ROOT_PASSWORD}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
backend:
|
||||
build: ./backend
|
||||
container_name: eventlens_backend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "127.0.0.1:8000:8000"
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
Loading…
x
Reference in New Issue
Block a user