Files
eventlens/backend/app/services.py
T
2026-04-18 14:23:24 +02:00

489 lines
17 KiB
Python

from datetime import datetime, timedelta
import logging
import re
from sqlalchemy import desc, select
from sqlalchemy.orm import Session
from app.models import (
NotificationStatus,
NotificationType,
ProviderStatus,
ProviderStatusType,
SourceStatusType,
TrackedEvent,
WatchItem,
WatchSource,
)
from app.notifications import send_email_notification
from app.providers.registry import get_providers
from app.schemas import SyncResult
from app.source_scanner import SourceScanner
logger = logging.getLogger(__name__)
PROVIDER_PRIORITY = {
"ticketmaster": 3,
"eventim": 2,
"bandsintown": 1,
}
PROVIDER_STATUS_PRIORITY = {
ProviderStatusType.error: 3,
ProviderStatusType.blocked: 2,
ProviderStatusType.ok: 1,
}
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]:
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):
from app.models import NotificationLog
return list(db.scalars(select(NotificationLog).order_by(desc(NotificationLog.created_at))))
def list_provider_statuses(db: Session) -> list[ProviderStatus]:
return list(db.scalars(select(ProviderStatus).order_by(ProviderStatus.provider_name)))
def list_watch_sources(db: Session, watch_item_id: int | None = None) -> list[WatchSource]:
stmt = select(WatchSource).order_by(WatchSource.created_at)
if watch_item_id is not None:
stmt = stmt.where(WatchSource.watch_item_id == watch_item_id)
return list(db.scalars(stmt))
def update_provider_status(
db: Session,
provider_name: str,
status: ProviderStatusType,
message: str,
):
provider_status = db.scalar(
select(ProviderStatus).where(ProviderStatus.provider_name == provider_name)
)
now = datetime.utcnow()
if provider_status is None:
provider_status = ProviderStatus(
provider_name=provider_name,
status=status,
message=message,
last_checked_at=now,
last_success_at=now if status == ProviderStatusType.ok else None,
)
db.add(provider_status)
return
provider_status.status = status
provider_status.message = message
provider_status.last_checked_at = now
if status == ProviderStatusType.ok:
provider_status.last_success_at = now
def init_provider_sync_state(provider_name: str) -> dict:
return {
"provider_name": provider_name,
"status": ProviderStatusType.ok,
"ok_count": 0,
"blocked_terms": [],
"error_terms": [],
"last_message": "",
}
def record_provider_sync_state(
state: dict,
status: ProviderStatusType,
term: str,
message: str,
):
if PROVIDER_STATUS_PRIORITY[status] > PROVIDER_STATUS_PRIORITY[state["status"]]:
state["status"] = status
if status == ProviderStatusType.ok:
state["ok_count"] += 1
elif status == ProviderStatusType.blocked:
if term not in state["blocked_terms"]:
state["blocked_terms"].append(term)
elif status == ProviderStatusType.error:
if term not in state["error_terms"]:
state["error_terms"].append(term)
state["last_message"] = message
def build_provider_status_message(state: dict) -> str:
if state["status"] == ProviderStatusType.error:
terms = ", ".join(state["error_terms"][:3])
return f"Fehler bei {state['provider_name']} fuer: {terms}."
if state["status"] == ProviderStatusType.blocked:
terms = ", ".join(state["blocked_terms"][:3])
return f"{state['provider_name']} wurde geblockt fuer: {terms}."
return (
f"{state['provider_name']} erfolgreich fuer {state['ok_count']} "
f"Watchlist-Eintraege geprueft."
)
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(
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)
db.flush()
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:
providers = get_providers()
source_scanner = SourceScanner()
provider_states = {
provider.source_name: init_provider_sync_state(provider.source_name)
for provider in providers
}
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:
active_sources = [source for source in watch_item.sources if source.is_active]
for source in active_sources:
try:
events = source_scanner.scan(watch_item, source)
source.last_status = (
SourceStatusType.ok if events else SourceStatusType.no_match
)
source.last_message = (
f"{len(events)} passende Events gefunden."
if events
else "Keine passenden Events auf dieser Quelle gefunden."
)
source.last_checked_at = datetime.utcnow()
except Exception as exc:
logger.exception(
"Source scan failed for watch_item=%s source=%s",
watch_item.name,
source.url,
)
db.rollback()
source.last_status = SourceStatusType.error
source.last_message = f"Scan fehlgeschlagen: {exc}"
source.last_checked_at = datetime.utcnow()
db.add(source)
db.commit()
continue
for event_data in events:
tracked_event, is_new = upsert_event(
db=db,
watch_item=watch_item,
provider_name=f"source:{source.id}",
event_data=event_data,
)
if is_new:
new_events += 1
else:
updated_events += 1
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(
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"Quelle: {source.label or source.url}\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.add(source)
db.commit()
for provider in providers:
try:
events = provider.search_events(
term=watch_item.name,
watch_type=watch_item.watch_type,
region_scope=watch_item.region_scope,
)
provider_status_name = getattr(provider, "last_status", "ok")
provider_message = getattr(
provider,
"last_message",
f"{provider.source_name} completed successfully for '{watch_item.name}'.",
)
record_provider_sync_state(
state=provider_states[provider.source_name],
status=ProviderStatusType(provider_status_name),
term=watch_item.name,
message=provider_message,
)
except Exception:
logger.exception(
"Provider sync failed for provider=%s watch_item=%s",
provider.source_name,
watch_item.name,
)
db.rollback()
record_provider_sync_state(
state=provider_states[provider.source_name],
status=ProviderStatusType.error,
term=watch_item.name,
message=f"Provider sync failed for '{watch_item.name}'.",
)
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
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(
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"Quelle: {tracked_event.source}\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()
for provider_name, state in provider_states.items():
update_provider_status(
db=db,
provider_name=provider_name,
status=state["status"],
message=build_provider_status_message(state),
)
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