Initial commit for eventlens

This commit is contained in:
ecki 2026-04-09 19:00:12 +02:00
commit 51aab152ff
21 changed files with 1862 additions and 0 deletions

22
.env.example Normal file
View 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
View 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
View 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
View File

@ -0,0 +1 @@

28
backend/app/config.py Normal file
View 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
View 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()

View 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>

View 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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
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);
});

View 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
View 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
View 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")

View 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

View File

@ -0,0 +1 @@

View 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
View 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
View 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
View 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
View 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
View 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

View 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
View 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: