Add multi-provider event discovery and improve sync UI

This commit is contained in:
ecki
2026-04-13 18:50:09 +02:00
parent 51aab152ff
commit d77d9c0c0f
13 changed files with 548 additions and 58 deletions
+2 -1
View File
@@ -9,6 +9,8 @@ DB_NAME=eventlens
MYSQL_ROOT_PASSWORD=rootsecret MYSQL_ROOT_PASSWORD=rootsecret
TICKETMASTER_API_KEY= TICKETMASTER_API_KEY=
BANDSINTOWN_APP_ID=eventlens
EVENTIM_ENABLED=true
POLL_INTERVAL_HOURS=6 POLL_INTERVAL_HOURS=6
REMINDER_INTERVAL_HOURS=12 REMINDER_INTERVAL_HOURS=12
@@ -19,4 +21,3 @@ SMTP_PASS=
SMTP_SENDER=eventlens@example.local SMTP_SENDER=eventlens@example.local
NOTIFICATION_EMAIL_TO= NOTIFICATION_EMAIL_TO=
SMTP_STARTTLS=true SMTP_STARTTLS=true
+29 -2
View File
@@ -6,7 +6,7 @@
- Watchlist fuer Kuenstler oder Events - Watchlist fuer Kuenstler oder Events
- Regionen `hamburg` und `germany` - Regionen `hamburg` und `germany`
- Geplanter Event-Abgleich ueber die Ticketmaster Discovery API - Geplanter Event-Abgleich ueber mehrere Quellen
- E-Mail-Benachrichtigung bei neu gefundenen Terminen - E-Mail-Benachrichtigung bei neu gefundenen Terminen
- Markierung, ob Tickets gekauft wurden - Markierung, ob Tickets gekauft wurden
- Erinnerung etwa eine Woche vor dem Termin - Erinnerung etwa eine Woche vor dem Termin
@@ -32,7 +32,9 @@ API-Statusinfo findest du unter `http://127.0.0.1:8000/api`.
## Wichtige Umgebungsvariablen ## Wichtige Umgebungsvariablen
- `TICKETMASTER_API_KEY`: noetig fuer die externe Eventsuche - `TICKETMASTER_API_KEY`: Ticketmaster Discovery API
- `BANDSINTOWN_APP_ID`: Bandsintown App-ID fuer Artist-Events
- `EVENTIM_ENABLED`: aktiviert den Eventim-Website-Provider
- `NOTIFICATION_EMAIL_TO`: Empfaenger fuer Benachrichtigungen - `NOTIFICATION_EMAIL_TO`: Empfaenger fuer Benachrichtigungen
- `SMTP_HOST`, `SMTP_USER`, `SMTP_PASS`: SMTP-Zugang fuer E-Mails - `SMTP_HOST`, `SMTP_USER`, `SMTP_PASS`: SMTP-Zugang fuer E-Mails
@@ -77,9 +79,34 @@ curl -X PATCH http://127.0.0.1:8000/events/1/purchase \
- Fuer produktiven Betrieb solltest du TLS im NGINX-Terminator aktivieren. - 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. - Das Frontend wird direkt vom FastAPI-Container ausgeliefert, es ist kein Node- oder Build-Container noetig.
## Bekannte Betriebsfalle
Wenn MariaDB bereits mit aelteren Zugangsdaten initialisiert wurde, reicht eine Aenderung in `.env` allein nicht aus. In dem Fall bleibt das Docker-Volume bestehen und der App-User in MariaDB hat noch das alte Passwort.
Zum Angleichen auf die aktuellen Werte aus `.env`:
```bash
cd /opt/eventlens
sudo bash scripts/fix-db-user.sh
```
Wenn dir die Datenbank egal ist und du komplett frisch starten willst:
```bash
cd /opt/eventlens
sudo docker compose down -v
sudo docker compose up -d --build
```
## Naechste sinnvolle Ausbaustufen ## Naechste sinnvolle Ausbaustufen
- Web-Frontend fuer Watchlist und Events - Web-Frontend fuer Watchlist und Events
- Weitere Datenquellen neben Ticketmaster - Weitere Datenquellen neben Ticketmaster
- Telegram oder Push-Benachrichtigungen - Telegram oder Push-Benachrichtigungen
- Nutzerverwaltung - Nutzerverwaltung
## Provider-Hinweise
- Ticketmaster nutzt die offizielle Discovery API.
- Bandsintown nutzt die offizielle Artist-Events-API und arbeitet deshalb vor allem fuer Watchlist-Eintraege vom Typ `artist`.
- Eventim nutzt eine beobachtete oeffentliche JSON-Web-API von eventim.de. Das ist keine offiziell dokumentierte Developer-API, aber stabiler als HTML-Scraping. Aenderungen an diesem Web-Endpoint koennen trotzdem Anpassungen noetig machen.
+2 -1
View File
@@ -12,6 +12,8 @@ class Settings:
db_name = os.getenv("DB_NAME", "eventlens") db_name = os.getenv("DB_NAME", "eventlens")
ticketmaster_api_key = os.getenv("TICKETMASTER_API_KEY", "") ticketmaster_api_key = os.getenv("TICKETMASTER_API_KEY", "")
bandsintown_app_id = os.getenv("BANDSINTOWN_APP_ID", "eventlens")
eventim_enabled = os.getenv("EVENTIM_ENABLED", "true").lower() == "true"
poll_interval_hours = int(os.getenv("POLL_INTERVAL_HOURS", "6")) poll_interval_hours = int(os.getenv("POLL_INTERVAL_HOURS", "6"))
reminder_interval_hours = int(os.getenv("REMINDER_INTERVAL_HOURS", "12")) reminder_interval_hours = int(os.getenv("REMINDER_INTERVAL_HOURS", "12"))
@@ -25,4 +27,3 @@ class Settings:
settings = Settings() settings = Settings()
+8 -5
View File
@@ -1,12 +1,16 @@
from sqlalchemy import create_engine from sqlalchemy import URL, create_engine
from sqlalchemy.orm import declarative_base, sessionmaker from sqlalchemy.orm import declarative_base, sessionmaker
from app.config import settings from app.config import settings
DATABASE_URL = ( DATABASE_URL = URL.create(
f"mysql+pymysql://{settings.db_user}:{settings.db_password}" "mysql+pymysql",
f"@{settings.db_host}:{settings.db_port}/{settings.db_name}" username=settings.db_user,
password=settings.db_password,
host=settings.db_host,
port=int(settings.db_port),
database=settings.db_name,
) )
engine = create_engine( engine = create_engine(
@@ -24,4 +28,3 @@ def get_db():
yield db yield db
finally: finally:
db.close() db.close()
+12
View File
@@ -45,6 +45,12 @@
<span class="stat-label">Letzte Aktion</span> <span class="stat-label">Letzte Aktion</span>
<strong id="sync-status">Noch kein Sync ausgefuehrt</strong> <strong id="sync-status">Noch kein Sync ausgefuehrt</strong>
</div> </div>
<div class="stat-card wide">
<span class="stat-label">Quellen</span>
<div id="provider-summary" class="provider-summary">
<span class="provider-chip">Noch keine Daten</span>
</div>
</div>
</aside> </aside>
</header> </header>
@@ -104,6 +110,12 @@
<option value="ticketed">Nur mit Ticket</option> <option value="ticketed">Nur mit Ticket</option>
<option value="open">Nur offen</option> <option value="open">Nur offen</option>
</select> </select>
<select id="provider-filter">
<option value="all">Alle Quellen</option>
<option value="ticketmaster">Ticketmaster</option>
<option value="bandsintown">Bandsintown</option>
<option value="eventim">Eventim</option>
</select>
</div> </div>
</div> </div>
+62 -3
View File
@@ -9,8 +9,10 @@ const eventsEl = document.querySelector("#events");
const notificationsEl = document.querySelector("#notifications"); const notificationsEl = document.querySelector("#notifications");
const watchForm = document.querySelector("#watch-form"); const watchForm = document.querySelector("#watch-form");
const eventFilter = document.querySelector("#event-filter"); const eventFilter = document.querySelector("#event-filter");
const providerFilter = document.querySelector("#provider-filter");
const syncButton = document.querySelector("#sync-button"); const syncButton = document.querySelector("#sync-button");
const toastEl = document.querySelector("#toast"); const toastEl = document.querySelector("#toast");
const providerSummaryEl = document.querySelector("#provider-summary");
function showToast(message) { function showToast(message) {
toastEl.textContent = message; toastEl.textContent = message;
@@ -79,6 +81,51 @@ function renderStats() {
document.querySelector("#ticket-count").textContent = state.events.filter( document.querySelector("#ticket-count").textContent = state.events.filter(
(event) => event.is_ticket_purchased (event) => event.is_ticket_purchased
).length; ).length;
renderProviderSummary();
}
function prettifyProviderName(value) {
const names = {
ticketmaster: "Ticketmaster",
bandsintown: "Bandsintown",
eventim: "Eventim",
};
return names[value] || value;
}
function getProviderClass(value) {
const classes = {
ticketmaster: "provider-ticketmaster",
bandsintown: "provider-bandsintown",
eventim: "provider-eventim",
};
return classes[value] || "";
}
function renderProviderSummary() {
if (!state.events.length) {
providerSummaryEl.innerHTML = '<span class="provider-chip">Noch keine Daten</span>';
return;
}
const counts = state.events.reduce((accumulator, event) => {
const key = event.source || "unknown";
accumulator[key] = (accumulator[key] || 0) + 1;
return accumulator;
}, {});
providerSummaryEl.innerHTML = Object.entries(counts)
.sort((left, right) => right[1] - left[1])
.map(
([provider, count]) => `
<span class="provider-chip ${getProviderClass(provider)}">
${escapeHtml(prettifyProviderName(provider))}: ${count}
</span>
`
)
.join("");
} }
function renderWatchItems() { function renderWatchItems() {
@@ -126,13 +173,21 @@ function getWatchNameById(id) {
function renderEvents() { function renderEvents() {
const filterValue = eventFilter.value; const filterValue = eventFilter.value;
const providerValue = providerFilter.value;
const filteredEvents = state.events.filter((event) => { const filteredEvents = state.events.filter((event) => {
if (filterValue === "ticketed") { if (filterValue === "ticketed") {
return event.is_ticket_purchased; if (!event.is_ticket_purchased) {
return false;
} }
if (filterValue === "open") {
return !event.is_ticket_purchased;
} }
if (filterValue === "open" && event.is_ticket_purchased) {
return false;
}
if (providerValue !== "all" && event.source !== providerValue) {
return false;
}
return true; return true;
}); });
@@ -153,6 +208,9 @@ function renderEvents() {
<div class="pill-row"> <div class="pill-row">
<span class="pill">${escapeHtml(getWatchNameById(event.watch_item_id))}</span> <span class="pill">${escapeHtml(getWatchNameById(event.watch_item_id))}</span>
<span class="pill">${escapeHtml(event.city || "ohne Stadt")}</span> <span class="pill">${escapeHtml(event.city || "ohne Stadt")}</span>
<span class="pill ${getProviderClass(event.source)}">
${escapeHtml(prettifyProviderName(event.source))}
</span>
<span class="pill ${event.is_ticket_purchased ? "success" : "warning"}"> <span class="pill ${event.is_ticket_purchased ? "success" : "warning"}">
${event.is_ticket_purchased ? "Ticket markiert" : "ohne Ticket"} ${event.is_ticket_purchased ? "Ticket markiert" : "ohne Ticket"}
</span> </span>
@@ -291,6 +349,7 @@ syncButton.addEventListener("click", async () => {
}); });
eventFilter.addEventListener("change", renderEvents); eventFilter.addEventListener("change", renderEvents);
providerFilter.addEventListener("change", renderEvents);
document.addEventListener("click", async (event) => { document.addEventListener("click", async (event) => {
const button = event.target.closest("button[data-action]"); const button = event.target.closest("button[data-action]");
+33
View File
@@ -143,6 +143,12 @@ h1 {
line-height: 1.5; line-height: 1.5;
} }
.provider-summary {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.dashboard-grid { .dashboard-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -324,6 +330,33 @@ button:hover,
color: var(--danger); color: var(--danger);
} }
.provider-chip {
display: inline-flex;
align-items: center;
min-height: 32px;
padding: 0 12px;
border-radius: 999px;
background: rgba(46, 39, 30, 0.08);
color: var(--text);
font-family: var(--mono);
font-size: 0.78rem;
}
.provider-ticketmaster {
background: rgba(36, 94, 63, 0.12);
color: var(--success);
}
.provider-bandsintown {
background: rgba(194, 77, 44, 0.12);
color: var(--primary-dark);
}
.provider-eventim {
background: rgba(86, 60, 153, 0.14);
color: #4f3790;
}
.muted { .muted {
color: var(--muted); color: var(--muted);
} }
+85
View File
@@ -0,0 +1,85 @@
from datetime import datetime
from urllib.parse import quote
import requests
from app.config import settings
from app.models import RegionScope, WatchType
class BandsintownProvider:
base_url = "https://rest.bandsintown.com"
source_name = "bandsintown"
def is_configured(self) -> bool:
return bool(settings.bandsintown_app_id)
def search_events(
self,
term: str,
watch_type: WatchType,
region_scope: RegionScope,
) -> list[dict]:
if not self.is_configured():
return []
if watch_type != WatchType.artist:
return []
artist_name = quote(term, safe="")
url = f"{self.base_url}/artists/{artist_name}/events"
response = requests.get(
url,
params={"app_id": settings.bandsintown_app_id, "date": "upcoming"},
timeout=30,
)
if response.status_code == 404:
return []
response.raise_for_status()
payload = response.json()
if not isinstance(payload, list):
return []
results: list[dict] = []
for item in payload:
venue = item.get("venue") or {}
city = venue.get("city")
country = venue.get("country")
if region_scope == RegionScope.hamburg and (city or "").casefold() != "hamburg":
continue
if region_scope == RegionScope.germany and (country or "").casefold() != "germany":
continue
datetime_value = item.get("datetime")
event_date = None
if datetime_value:
try:
event_date = datetime.fromisoformat(
datetime_value.replace("Z", "+00:00")
).replace(tzinfo=None)
except ValueError:
event_date = None
offers = item.get("offers") or []
ticket_url = next((offer.get("url") for offer in offers if offer.get("url")), None)
results.append(
{
"external_id": str(item.get("id")),
"title": item.get("title") or f"{term} live",
"matched_term": term,
"venue_name": venue.get("name"),
"city": city,
"country_code": "DE" if country == "Germany" else country,
"event_date": event_date,
"ticket_url": ticket_url or item.get("url"),
"image_url": item.get("artist", {}).get("thumb_url"),
"raw_payload": item,
}
)
return results
+115
View File
@@ -0,0 +1,115 @@
from datetime import datetime
import requests
from app.config import settings
from app.models import RegionScope, WatchType
class EventimProvider:
base_url = "https://public-api.eventim.com/websearch/search/api/exploration/v1/products"
source_name = "eventim"
def is_configured(self) -> bool:
return settings.eventim_enabled
def search_events(
self,
term: str,
watch_type: WatchType,
region_scope: RegionScope,
) -> list[dict]:
if not self.is_configured():
return []
params = {
"webId": "web__eventim-de",
"language": "de",
"page": 1,
"sort": "DateAsc",
"top": 50,
"search_term": term,
}
if region_scope == RegionScope.hamburg:
params["city_names"] = "Hamburg"
response = requests.get(
self.base_url,
params=params,
headers={
"Accept": "application/json",
"User-Agent": (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
),
},
timeout=30,
)
response.raise_for_status()
payload = response.json()
products = payload.get("products") or []
results: list[dict] = []
normalized_term = term.casefold()
for product in products:
title = product.get("name") or ""
attractions = product.get("attractions") or []
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
live_data = product.get("typeAttributes", {}).get("liveEntertainment", {})
location = live_data.get("location") or {}
city = location.get("city")
if region_scope == RegionScope.hamburg and (city or "").casefold() != "hamburg":
continue
event_date = None
start_date = live_data.get("startDate")
if start_date:
try:
event_date = datetime.fromisoformat(
start_date.replace("Z", "+00:00")
).replace(tzinfo=None)
except ValueError:
event_date = None
url = self._build_url(product)
country_code = "DE" if region_scope == RegionScope.germany or city else None
results.append(
{
"external_id": str(product.get("productId") or url or title),
"title": title,
"matched_term": term,
"venue_name": location.get("name"),
"city": city,
"country_code": country_code,
"event_date": event_date,
"ticket_url": url,
"image_url": None,
"raw_payload": product,
}
)
return results
def _build_url(self, product: dict) -> str | None:
direct_link = product.get("link")
if direct_link:
return direct_link
url = product.get("url") or {}
domain = url.get("domain")
path = url.get("path")
if domain and path:
return f"{domain}{path}"
return None
+12
View File
@@ -0,0 +1,12 @@
from app.providers.bandsintown import BandsintownProvider
from app.providers.eventim import EventimProvider
from app.providers.ticketmaster import TicketmasterProvider
def get_providers():
return [
TicketmasterProvider(),
BandsintownProvider(),
EventimProvider(),
]
+105 -5
View File
@@ -1,20 +1,47 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
import re
from sqlalchemy import desc, select from sqlalchemy import desc, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models import NotificationStatus, NotificationType, TrackedEvent, WatchItem from app.models import NotificationStatus, NotificationType, TrackedEvent, WatchItem
from app.notifications import send_email_notification from app.notifications import send_email_notification
from app.providers.ticketmaster import TicketmasterProvider from app.providers.registry import get_providers
from app.schemas import SyncResult from app.schemas import SyncResult
PROVIDER_PRIORITY = {
"ticketmaster": 3,
"eventim": 2,
"bandsintown": 1,
}
def list_watch_items(db: Session) -> list[WatchItem]: def list_watch_items(db: Session) -> list[WatchItem]:
return list(db.scalars(select(WatchItem).order_by(WatchItem.name))) return list(db.scalars(select(WatchItem).order_by(WatchItem.name)))
def list_events(db: Session) -> list[TrackedEvent]: def list_events(db: Session) -> list[TrackedEvent]:
return list(db.scalars(select(TrackedEvent).order_by(desc(TrackedEvent.event_date)))) events = list(db.scalars(select(TrackedEvent).order_by(desc(TrackedEvent.event_date))))
deduped: list[TrackedEvent] = []
for event in events:
duplicate_index = next(
(
index
for index, existing in enumerate(deduped)
if events_are_equivalent(existing, event)
),
None,
)
if duplicate_index is None:
deduped.append(event)
continue
if is_preferred_event(event, deduped[duplicate_index]):
deduped[duplicate_index] = event
return deduped
def list_notifications(db: Session): def list_notifications(db: Session):
@@ -23,6 +50,72 @@ def list_notifications(db: Session):
return list(db.scalars(select(NotificationLog).order_by(desc(NotificationLog.created_at)))) return list(db.scalars(select(NotificationLog).order_by(desc(NotificationLog.created_at))))
def normalize_event_text(value: str | None) -> str:
if not value:
return ""
cleaned = re.sub(r"[^a-z0-9]+", " ", value.casefold())
return " ".join(cleaned.split())
def titles_match(left: str | None, right: str | None) -> bool:
normalized_left = normalize_event_text(left)
normalized_right = normalize_event_text(right)
if not normalized_left or not normalized_right:
return False
return (
normalized_left == normalized_right
or normalized_left in normalized_right
or normalized_right in normalized_left
)
def get_event_date_key(value: datetime | None):
return value.date() if value else None
def events_are_equivalent(left: TrackedEvent, right: TrackedEvent) -> bool:
if left.watch_item_id != right.watch_item_id:
return False
if get_event_date_key(left.event_date) != get_event_date_key(right.event_date):
return False
left_city = normalize_event_text(left.city)
right_city = normalize_event_text(right.city)
if left_city and right_city and left_city != right_city:
return False
title_matches = titles_match(left.title, right.title)
venue_matches = titles_match(left.venue_name, right.venue_name)
return title_matches or venue_matches
def is_preferred_event(candidate: TrackedEvent, current: TrackedEvent) -> bool:
candidate_score = (
1 if candidate.is_ticket_purchased else 0,
PROVIDER_PRIORITY.get(candidate.source, 0),
1 if candidate.ticket_url else 0,
candidate.last_seen_at or datetime.min,
)
current_score = (
1 if current.is_ticket_purchased else 0,
PROVIDER_PRIORITY.get(current.source, 0),
1 if current.ticket_url else 0,
current.last_seen_at or datetime.min,
)
return candidate_score > current_score
def has_equivalent_existing_event(db: Session, tracked_event: TrackedEvent) -> bool:
stmt = select(TrackedEvent).where(
TrackedEvent.watch_item_id == tracked_event.watch_item_id,
TrackedEvent.id != tracked_event.id,
)
existing_events = list(db.scalars(stmt))
return any(events_are_equivalent(tracked_event, existing) for existing in existing_events)
def upsert_event( def upsert_event(
db: Session, db: Session,
watch_item: WatchItem, watch_item: WatchItem,
@@ -53,6 +146,7 @@ def upsert_event(
raw_payload=event_data.get("raw_payload"), raw_payload=event_data.get("raw_payload"),
) )
db.add(tracked_event) db.add(tracked_event)
db.flush()
else: else:
tracked_event.title = event_data["title"] tracked_event.title = event_data["title"]
tracked_event.matched_term = event_data["matched_term"] tracked_event.matched_term = event_data["matched_term"]
@@ -69,7 +163,7 @@ def upsert_event(
def run_sync(db: Session) -> SyncResult: def run_sync(db: Session) -> SyncResult:
provider = TicketmasterProvider() providers = get_providers()
active_items = list( active_items = list(
db.scalars(select(WatchItem).where(WatchItem.is_active.is_(True)).order_by(WatchItem.name)) db.scalars(select(WatchItem).where(WatchItem.is_active.is_(True)).order_by(WatchItem.name))
) )
@@ -80,6 +174,7 @@ def run_sync(db: Session) -> SyncResult:
notifications_skipped = 0 notifications_skipped = 0
for watch_item in active_items: for watch_item in active_items:
for provider in providers:
try: try:
events = provider.search_events( events = provider.search_events(
term=watch_item.name, term=watch_item.name,
@@ -102,7 +197,12 @@ def run_sync(db: Session) -> SyncResult:
else: else:
updated_events += 1 updated_events += 1
if is_new and tracked_event.discovery_notified_at is None: should_notify = (
is_new
and tracked_event.discovery_notified_at is None
and not has_equivalent_existing_event(db, tracked_event)
)
if should_notify:
status = send_email_notification( status = send_email_notification(
db=db, db=db,
tracked_event=tracked_event, tracked_event=tracked_event,
@@ -110,6 +210,7 @@ def run_sync(db: Session) -> SyncResult:
subject=f"Neuer Termin fuer {watch_item.name}", subject=f"Neuer Termin fuer {watch_item.name}",
body=( body=(
f"Es wurde ein neuer Termin fuer '{watch_item.name}' gefunden.\n\n" f"Es wurde ein neuer Termin fuer '{watch_item.name}' gefunden.\n\n"
f"Quelle: {tracked_event.source}\n"
f"Titel: {tracked_event.title}\n" f"Titel: {tracked_event.title}\n"
f"Ort: {tracked_event.venue_name or 'unbekannt'}\n" f"Ort: {tracked_event.venue_name or 'unbekannt'}\n"
f"Stadt: {tracked_event.city or 'unbekannt'}\n" f"Stadt: {tracked_event.city or 'unbekannt'}\n"
@@ -122,7 +223,6 @@ def run_sync(db: Session) -> SyncResult:
notifications_sent += 1 notifications_sent += 1
else: else:
notifications_skipped += 1 notifications_skipped += 1
db.commit() db.commit()
return SyncResult( return SyncResult(
+3 -1
View File
@@ -3,6 +3,8 @@ services:
image: mariadb:11 image: mariadb:11
container_name: eventlens_db container_name: eventlens_db
restart: unless-stopped restart: unless-stopped
env_file:
- .env
environment: environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-rootsecret} MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-rootsecret}
MYSQL_DATABASE: ${DB_NAME:-eventlens} MYSQL_DATABASE: ${DB_NAME:-eventlens}
@@ -26,7 +28,7 @@ services:
env_file: env_file:
- .env - .env
ports: ports:
- "127.0.0.1:8000:8000" - "127.0.0.1:8001:8000"
volumes: volumes:
db_data: db_data:
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
if [[ ! -f "${PROJECT_DIR}/.env" ]]; then
echo ".env wurde in ${PROJECT_DIR} nicht gefunden." >&2
exit 1
fi
read_env_value() {
local key="$1"
local line
line="$(grep -E "^${key}=" "${PROJECT_DIR}/.env" | tail -n 1 || true)"
if [[ -z "${line}" ]]; then
echo "Wert ${key} wurde in ${PROJECT_DIR}/.env nicht gefunden." >&2
exit 1
fi
printf '%s' "${line#*=}"
}
DB_NAME="$(read_env_value DB_NAME)"
DB_USER="$(read_env_value DB_USER)"
DB_PASSWORD="$(read_env_value DB_PASSWORD)"
MYSQL_ROOT_PASSWORD="$(read_env_value MYSQL_ROOT_PASSWORD)"
docker compose -f "${PROJECT_DIR}/docker-compose.yml" exec -T db \
mariadb -uroot "-p${MYSQL_ROOT_PASSWORD}" <<SQL
CREATE DATABASE IF NOT EXISTS \`${DB_NAME}\`;
CREATE USER IF NOT EXISTS '${DB_USER}'@'%' IDENTIFIED BY '${DB_PASSWORD}';
ALTER USER '${DB_USER}'@'%' IDENTIFIED BY '${DB_PASSWORD}';
GRANT ALL PRIVILEGES ON \`${DB_NAME}\`.* TO '${DB_USER}'@'%';
FLUSH PRIVILEGES;
SQL
echo "MariaDB-App-User '${DB_USER}' wurde fuer '${DB_NAME}' aktualisiert."