Tagebuch:
- Day-One-Listenansicht: Wochentag + Tageszahl + Meta-Zeile (Zeit/Ort/Wetter)
- 4 Ansichten: Liste, Medien-Mosaik, Kalender (mit Sprungbuttons), Karte (GPS-Marker)
- Detail-Ansicht inline im Content-Bereich (kein Fullscreen-Overlay mehr)
- Hero-Bild vollständig sichtbar (object-fit:contain), Lightbox mit Safe-Area
- 2-Spalten-Layout Desktop: Text + Leaflet-Karte + POI-Liste
- EXIF-GPS-Extraktion bei Foto-Upload, historisches Wetter via Archive-API
- NoteStation-Import: Fotos in diary_media (80 Einträge migriert, 94 Medien)
- Stats-Endpoints: /diary/stats, /diary/calendar, /diary/locations
Notiz-Feature:
- Generische notes-Tabelle (parent_type + parent_id + meta_json)
- 📝-Button in 8 Bereichen, Notizblock-Seite mit KI-Analyse
- KI-Toggle in Einstellungen, notes_ki_enabled in User-Profil
Icons & Design:
- fill:currentColor Fix für welcome/onboarding/friends.js
- --c-icon Variable, --c-text-muted Dark Mode aufgehellt
- 15+ neue Phosphor-Icons aus lokaler Kopie
- CSS Network-First im SW, Cache-Control-Middleware
Infrastruktur:
- Wiki-Anreicherungs-Scheduler-Jobs entfernt (abgeschlossen)
- auth.py: notes_ki_enabled + is_social_media im User-Response
933 lines
37 KiB
Python
933 lines
37 KiB
Python
"""
|
||
BAN YARO — Hintergrund-Scheduler
|
||
Täglich: Gesundheits-Erinnerungen per Push versenden.
|
||
"""
|
||
|
||
import logging
|
||
from datetime import date, datetime, timedelta
|
||
from zoneinfo import ZoneInfo
|
||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||
from apscheduler.triggers.cron import CronTrigger
|
||
|
||
_TZ = ZoneInfo("Europe/Berlin")
|
||
|
||
from database import db
|
||
from routes.push import send_push_to_user, send_push_to_all
|
||
import weather
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
_scheduler = AsyncIOScheduler(timezone="Europe/Berlin")
|
||
|
||
# In-Memory Job-Protokoll: {job_id: {"last_run": datetime, "result": str, "status": "ok"|"error"}}
|
||
_job_log: dict = {}
|
||
|
||
|
||
def start():
|
||
_scheduler.add_job(
|
||
_job_health_reminders,
|
||
CronTrigger(hour=8, minute=0), # täglich 08:00 Uhr
|
||
id="health_reminders",
|
||
replace_existing=True,
|
||
misfire_grace_time=3600,
|
||
)
|
||
_scheduler.add_job(
|
||
_job_poison_archive,
|
||
CronTrigger(hour=3, minute=0), # täglich 03:00 Uhr (ruhige Zeit)
|
||
id="poison_archive",
|
||
replace_existing=True,
|
||
misfire_grace_time=3600,
|
||
)
|
||
_scheduler.add_job(
|
||
_job_weather_alert,
|
||
CronTrigger(hour=7, minute=30), # täglich 07:30 Uhr
|
||
id="weather_alert",
|
||
replace_existing=True,
|
||
misfire_grace_time=3600,
|
||
)
|
||
_scheduler.add_job(
|
||
_job_milestone_check,
|
||
CronTrigger(hour=0, minute=5), # täglich 00:05 Uhr
|
||
id="milestone_check",
|
||
replace_existing=True,
|
||
misfire_grace_time=3600,
|
||
)
|
||
_scheduler.add_job(
|
||
_job_import_events,
|
||
CronTrigger(day_of_week='sun', hour=2), # jeden Sonntag 02:00 Uhr
|
||
id="import_events",
|
||
replace_existing=True,
|
||
misfire_grace_time=7200,
|
||
)
|
||
_scheduler.add_job(
|
||
_job_prewarm_cities,
|
||
CronTrigger(hour=2, minute=0), # täglich 02:00 Uhr
|
||
id="prewarm_cities",
|
||
replace_existing=True,
|
||
misfire_grace_time=7200,
|
||
)
|
||
# Einmalig beim Start (nach 10s Verzögerung) für sofortige Befüllung
|
||
_scheduler.add_job(
|
||
_job_import_events,
|
||
'date',
|
||
run_date=datetime.now(tz=_TZ) + timedelta(seconds=10),
|
||
id="import_events_startup",
|
||
replace_existing=True,
|
||
)
|
||
# Einmalig beim Start (nach 15s Verzögerung) — Rassen aus TheDogAPI befüllen
|
||
_scheduler.add_job(
|
||
_job_seed_breeds,
|
||
'date',
|
||
run_date=datetime.now(tz=_TZ) + timedelta(seconds=15),
|
||
id="seed_breeds_startup",
|
||
replace_existing=True,
|
||
)
|
||
# Einmalig beim Start (nach 45s Verzögerung) — fehlende Rassen aus Wikidata ergänzen
|
||
_scheduler.add_job(
|
||
_job_seed_wikidata_breeds,
|
||
'date',
|
||
run_date=datetime.now(tz=_TZ) + timedelta(seconds=45),
|
||
id="seed_wikidata_startup",
|
||
replace_existing=True,
|
||
)
|
||
# Jeden Montag 09:00 — Wöchentlicher Fortschritts-Lober
|
||
_scheduler.add_job(
|
||
_job_weekly_praise,
|
||
CronTrigger(day_of_week='mon', hour=9, minute=0),
|
||
id="weekly_praise",
|
||
replace_existing=True,
|
||
misfire_grace_time=3600,
|
||
)
|
||
# Alle 2 Stunden Status-Report per Mail
|
||
_scheduler.add_job(
|
||
_job_status_report,
|
||
CronTrigger(minute=0, hour="*/2"),
|
||
id="status_report",
|
||
replace_existing=True,
|
||
misfire_grace_time=1800,
|
||
)
|
||
_scheduler.start()
|
||
logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed beim Start.")
|
||
|
||
|
||
def stop():
|
||
_scheduler.shutdown(wait=False)
|
||
logger.info("Scheduler gestoppt.")
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# JOB: Gesundheits-Erinnerungen
|
||
# ------------------------------------------------------------------
|
||
async def _job_health_reminders():
|
||
"""
|
||
Findet alle Health-Einträge mit `naechstes`-Datum:
|
||
- genau heute → sofortige Erinnerung
|
||
- in 7 Tagen → Vorwarnung
|
||
- gestern → Überfällig-Erinnerung (nur einmal, 1 Tag nach Fälligkeit)
|
||
Schickt jeweils eine Push-Notification an den Hundebesitzer.
|
||
"""
|
||
today = date.today()
|
||
in7 = today + timedelta(days=7)
|
||
yesterday = today - timedelta(days=1)
|
||
|
||
logger.info(f"Health-Reminder Job läuft für {today}")
|
||
|
||
with db() as conn:
|
||
# Alle fälligen Einträge der nächsten 7 Tage + gestrige (überfällig)
|
||
rows = conn.execute("""
|
||
SELECT h.id, h.typ, h.bezeichnung, h.naechstes,
|
||
d.user_id, d.name AS hund_name
|
||
FROM health h
|
||
JOIN dogs d ON d.id = h.dog_id
|
||
WHERE h.naechstes IN (?, ?, ?)
|
||
AND h.typ IN ('impfung', 'entwurmung', 'medikament')
|
||
""", (str(today), str(in7), str(yesterday))).fetchall()
|
||
|
||
sent_total = 0
|
||
for r in rows:
|
||
naechstes = date.fromisoformat(r["naechstes"])
|
||
delta = (naechstes - today).days
|
||
|
||
if delta == 7:
|
||
title = f"⏰ Erinnerung: {r['bezeichnung']}"
|
||
body = f"In 7 Tagen fällig für {r['hund_name']}."
|
||
elif delta == 0:
|
||
title = f"📅 Heute fällig: {r['bezeichnung']}"
|
||
body = f"Bitte heute erledigen — {r['hund_name']} wartet."
|
||
else: # delta == -1 → gestern überfällig
|
||
title = f"⚠️ Überfällig: {r['bezeichnung']}"
|
||
body = f"War gestern fällig für {r['hund_name']} — bitte bald erledigen."
|
||
|
||
sent = send_push_to_user(r["user_id"], {
|
||
"type": "health_reminder",
|
||
"title": title,
|
||
"body": body,
|
||
"data": {"page": "health"},
|
||
})
|
||
sent_total += sent
|
||
if sent:
|
||
logger.info(f"Reminder Push: user={r['user_id']} entry={r['id']} delta={delta}d")
|
||
|
||
logger.info(f"Health-Reminder Job fertig — {len(rows)} Einträge, {sent_total} Push gesendet.")
|
||
_log_job("health_reminders", "ok", f"{len(rows)} Einträge, {sent_total} Push")
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# JOB: Abgelaufene Giftköder-Meldungen archivieren
|
||
# Abgelaufene, aber noch nicht manuell aufgelöste Einträge werden
|
||
# sauber als geloest=1 markiert — für spätere KI-Musteranalyse.
|
||
# Die Zeilen selbst werden NIE gelöscht.
|
||
# ------------------------------------------------------------------
|
||
async def _job_poison_archive():
|
||
"""
|
||
Findet Giftköder-Meldungen deren expires_at verstrichen ist
|
||
und die noch nicht als geloest markiert wurden.
|
||
Setzt geloest=1, geloest_grund='automatisch_abgelaufen'.
|
||
"""
|
||
from datetime import datetime
|
||
now = datetime.utcnow().isoformat()
|
||
with db() as conn:
|
||
result = conn.execute("""
|
||
UPDATE poison
|
||
SET geloest = 1,
|
||
geloest_at = datetime('now'),
|
||
geloest_grund = 'automatisch_abgelaufen'
|
||
WHERE geloest = 0
|
||
AND expires_at < ?
|
||
""", (now,))
|
||
count = result.rowcount
|
||
_log_job("poison_archive", "ok", f"{count} Meldungen archiviert")
|
||
if count:
|
||
logger.info(f"Giftköder-Archiv: {count} abgelaufene Meldungen archiviert.")
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# JOB: Wetter-Alarm (Hitzepfoten / Gewitter)
|
||
# ------------------------------------------------------------------
|
||
async def _job_weather_alert():
|
||
"""
|
||
Holt Tagesprognose für mehrere deutsche Städte.
|
||
Sendet Push-Notification wenn:
|
||
- Temperatur >= 28°C (Asphalt-Warnung für Pfoten)
|
||
- Gewitter wahrscheinlich
|
||
Hitze hat Vorrang: Bei Hitze wird kein Gewitter-Push mehr gesendet.
|
||
"""
|
||
logger.info("Wetter-Alert Job läuft")
|
||
try:
|
||
summary = await weather.get_weather_summary()
|
||
except Exception as e:
|
||
logger.error(f"Wetter-Alert: Fehler beim Abruf: {e}")
|
||
return
|
||
|
||
max_temp = summary["max_temp_c"]
|
||
thunderstorm = summary["thunderstorm"]
|
||
|
||
if max_temp >= 28:
|
||
_log_job("weather_alert", "ok", f"Hitze-Push: {max_temp:.0f}°C")
|
||
sent = send_push_to_all({
|
||
"type": "weather_heat",
|
||
"title": "☀️ Heißer Asphalt heute",
|
||
"body": f"Bis {max_temp:.0f}°C heute — Asphalt kann über 50°C heiß werden. Frühmorgens oder abends gassi gehen!",
|
||
"data": {"tag": "weather-heat"},
|
||
})
|
||
logger.info(f"Wetter-Alert Hitze: {max_temp:.1f}°C — {sent} Push gesendet.")
|
||
return # Kein Gewitter-Push mehr nötig wenn Hitze bereits gemeldet
|
||
|
||
if thunderstorm:
|
||
sent = send_push_to_all({
|
||
"type": "weather_thunder",
|
||
"title": "⛈️ Gewitter möglich",
|
||
"body": "Heute Gewitter wahrscheinlich. Gassi-Tour früh einplanen und Hund beruhigen.",
|
||
"data": {"tag": "weather-thunder"},
|
||
})
|
||
logger.info(f"Wetter-Alert Gewitter — {sent} Push gesendet.")
|
||
return
|
||
|
||
logger.info("Wetter-Alert: Keine Warnung nötig heute.")
|
||
_log_job("weather_alert", "ok", "Keine Warnung")
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# JOB: Geburtstags- und Monats-Meilensteine
|
||
# Läuft täglich um 00:05 Uhr (Europe/Berlin).
|
||
# Prüft alle Hunde mit gesetztem Geburtstag und erstellt bei Treffern
|
||
# einen Tagebucheintrag (is_milestone=1) + Push-Notification.
|
||
# ------------------------------------------------------------------
|
||
async def _job_milestone_check():
|
||
"""
|
||
Prüft für jeden Hund mit bekanntem Geburtstag ob heute ein
|
||
Meilenstein-Tag ist:
|
||
- Jahrestag (1. Geburtstag, 2. Geburtstag, …)
|
||
- Monatsjubiläum in den ersten 12 Monaten (1 Monat, 2 Monate, …, 11 Monate)
|
||
Doppelt-Schutz: Wenn bereits ein Meilenstein-Eintrag mit demselben
|
||
Titel für heute existiert, wird kein zweiter erstellt.
|
||
"""
|
||
today = date.today()
|
||
logger.info(f"Meilenstein-Check läuft für {today}")
|
||
|
||
with db() as conn:
|
||
dogs = conn.execute("""
|
||
SELECT d.id, d.name, d.user_id, d.geburtstag
|
||
FROM dogs d
|
||
WHERE d.geburtstag IS NOT NULL
|
||
AND d.geburtstag != ''
|
||
""").fetchall()
|
||
|
||
created_total = 0
|
||
|
||
for dog in dogs:
|
||
try:
|
||
bday = date.fromisoformat(dog["geburtstag"])
|
||
except ValueError:
|
||
logger.warning(f"Meilenstein: ungültiges Geburtstag für Hund {dog['id']}: {dog['geburtstag']!r}")
|
||
continue
|
||
|
||
milestone = _compute_milestone(today, bday, dog["name"])
|
||
if milestone is None:
|
||
continue
|
||
|
||
titel, text = milestone
|
||
|
||
with db() as conn:
|
||
# Doppelt-Schutz: kein zweiter Eintrag am selben Tag mit gleichem Titel
|
||
exists = conn.execute("""
|
||
SELECT id FROM diary
|
||
WHERE dog_id = ? AND datum = ? AND titel = ? AND is_milestone = 1
|
||
""", (dog["id"], str(today), titel)).fetchone()
|
||
|
||
if exists:
|
||
logger.info(f"Meilenstein bereits vorhanden: Hund {dog['id']} '{titel}'")
|
||
continue
|
||
|
||
# Tagebucheintrag anlegen
|
||
cur = conn.execute("""
|
||
INSERT INTO diary (dog_id, datum, typ, titel, text, is_milestone)
|
||
VALUES (?, ?, 'milestone', ?, ?, 1)
|
||
""", (dog["id"], str(today), titel, text))
|
||
entry_id = cur.lastrowid
|
||
|
||
# Junction-Tabelle befüllen
|
||
conn.execute("""
|
||
INSERT OR IGNORE INTO diary_dogs (diary_id, dog_id) VALUES (?, ?)
|
||
""", (entry_id, dog["id"]))
|
||
|
||
# Push an Besitzer
|
||
send_push_to_user(dog["user_id"], {
|
||
"type": "milestone",
|
||
"title": titel,
|
||
"body": text,
|
||
"data": {"page": "diary"},
|
||
"tag": f"milestone-{dog['id']}-{today}",
|
||
})
|
||
|
||
logger.info(f"Meilenstein erstellt: Hund {dog['id']} '{titel}' → diary_id={entry_id}")
|
||
created_total += 1
|
||
|
||
logger.info(f"Meilenstein-Check fertig — {created_total} Einträge erstellt.")
|
||
_log_job("milestone_check", "ok", f"{created_total} Meilensteine erstellt")
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# JOB: VDH-Events importieren
|
||
# ------------------------------------------------------------------
|
||
async def _job_import_events():
|
||
"""
|
||
Scrapt Veranstaltungen von vdh.de und importiert neue Events in die DB.
|
||
Bereits vorhandene external_ids werden übersprungen (Upsert-Logik).
|
||
"""
|
||
try:
|
||
from scraper.events_vdh import fetch_vdh_events
|
||
except ImportError as e:
|
||
logger.error(f"Event-Import: Scraper konnte nicht geladen werden: {e}")
|
||
return
|
||
|
||
try:
|
||
events = await fetch_vdh_events()
|
||
except Exception as e:
|
||
logger.error(f"Event-Import: Fehler beim Scrapen: {e}")
|
||
return
|
||
|
||
imported = 0
|
||
with db() as conn:
|
||
for ev in events:
|
||
try:
|
||
exists = conn.execute(
|
||
"SELECT id FROM events WHERE external_id = ?",
|
||
(ev['external_id'],)
|
||
).fetchone()
|
||
if not exists:
|
||
conn.execute("""
|
||
INSERT INTO events (user_id, titel, datum, ort_name, typ, link, quelle, external_id, status)
|
||
VALUES (NULL, ?, ?, ?, ?, ?, 'vdh', ?, 'aktiv')
|
||
""", (
|
||
ev['titel'],
|
||
ev['datum'],
|
||
ev.get('ort_name'),
|
||
ev['typ'],
|
||
ev.get('link'),
|
||
ev['external_id'],
|
||
))
|
||
imported += 1
|
||
except Exception as e:
|
||
logger.warning(f"Event-Import: Fehler beim Speichern von '{ev.get('titel')}': {e}")
|
||
|
||
logger.info(f"Event-Import: {imported} neue Events importiert (von {len(events)} geparsten).")
|
||
_log_job("import_events", "ok", f"{imported} neue von {len(events)} Events")
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# JOB: Rassen aus TheDogAPI seeden
|
||
# ------------------------------------------------------------------
|
||
async def _job_seed_breeds():
|
||
"""Lädt alle Hunderassen von TheDogAPI und speichert sie in wiki_rassen."""
|
||
try:
|
||
from scraper.breeds import fetch_and_seed_breeds, mirror_breed_photos
|
||
except ImportError as e:
|
||
logger.error(f"Breed-Seed: Scraper konnte nicht geladen werden: {e}")
|
||
return
|
||
|
||
try:
|
||
count = await fetch_and_seed_breeds()
|
||
logger.info(f"Breed seed job done: {count} breeds")
|
||
mirrored = await mirror_breed_photos()
|
||
logger.info(f"Breed photo mirror done: {mirrored} photos")
|
||
except Exception as e:
|
||
logger.error(f"Breed-Seed: Fehler: {e}")
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# JOB: Fehlende Rassen aus Wikidata ergänzen
|
||
# ------------------------------------------------------------------
|
||
async def _job_seed_wikidata_breeds():
|
||
"""Lädt fehlende Hunderassen von Wikidata und spiegelt Fotos lokal."""
|
||
try:
|
||
from scraper.wikidata_breeds import fetch_and_seed_wikidata_breeds, mirror_wikidata_photos
|
||
except ImportError as e:
|
||
logger.error(f"Wikidata-Seed: Scraper konnte nicht geladen werden: {e}")
|
||
return
|
||
|
||
try:
|
||
count = await fetch_and_seed_wikidata_breeds()
|
||
logger.info(f"Wikidata breed seed done: {count} neue Rassen")
|
||
mirrored = await mirror_wikidata_photos()
|
||
logger.info(f"Wikidata photo mirror done: {mirrored} Fotos")
|
||
# Wikipedia-Fotos für Rassen die noch kein Bild haben
|
||
from scraper.wikipedia_photos import fetch_wikipedia_photos
|
||
wp_count = await fetch_wikipedia_photos()
|
||
logger.info(f"Wikipedia photo fetch done: {wp_count} Fotos")
|
||
_log_job("seed_wikidata_startup", "ok", f"{count} Rassen, {mirrored}+{wp_count} Fotos")
|
||
except Exception as e:
|
||
logger.error(f"Wikidata-Seed: Fehler: {e}")
|
||
_log_job("seed_wikidata_startup", "error", str(e))
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# JOB: OSM-Tiles für deutsche Großstädte vorwärmen
|
||
# Läuft einmalig 90s nach Start + wöchentlich So 01:00 Uhr.
|
||
# Nur stale Tiles werden abgerufen (bereits gecachte werden übersprungen).
|
||
# ------------------------------------------------------------------
|
||
|
||
# Deutsche Städte mit >50.000 Einwohnern (lat, lon, name)
|
||
_CITIES_DE = [
|
||
(52.5200, 13.4050, "Berlin"),
|
||
(53.5753, 10.0153, "Hamburg"),
|
||
(48.1372, 11.5755, "München"),
|
||
(51.2217, 6.7762, "Düsseldorf"),
|
||
(50.9333, 6.9500, "Köln"),
|
||
(50.1109, 8.6821, "Frankfurt"),
|
||
(48.7775, 9.1800, "Stuttgart"),
|
||
(51.4566, 7.0116, "Dortmund"),
|
||
(51.5136, 7.4653, "Dortmund-Ost"),
|
||
(51.4508, 7.0131, "Essen"),
|
||
(51.3388, 12.3799, "Leipzig"),
|
||
(51.2254, 6.7762, "Düsseldorf"),
|
||
(51.0534, 13.7373, "Dresden"),
|
||
(52.3759, 9.7320, "Hannover"),
|
||
(51.4818, 7.2162, "Bochum"),
|
||
(51.9607, 7.6261, "Münster"),
|
||
(51.3670, 7.4595, "Hagen"),
|
||
(50.7753, 6.0839, "Aachen"),
|
||
(51.2563, 7.1500, "Wuppertal"),
|
||
(49.4521, 11.0767, "Nürnberg"),
|
||
(53.0758, 8.8072, "Bremen"),
|
||
(50.7323, 7.0955, "Bonn"),
|
||
(49.0069, 8.4037, "Karlsruhe"),
|
||
(51.9607, 7.6261, "Münster"),
|
||
(51.4344, 6.7623, "Duisburg"),
|
||
(51.6667, 6.1667, "Moers"),
|
||
(48.3705, 10.8978, "Augsburg"),
|
||
(52.2689, 10.5268, "Braunschweig"),
|
||
(50.9287, 11.5861, "Jena"),
|
||
(53.8655, 10.6866, "Lübeck"),
|
||
(54.3233, 10.1394, "Kiel"),
|
||
(53.1435, 8.2146, "Oldenburg"),
|
||
(52.0302, 8.5325, "Bielefeld"),
|
||
(51.3167, 9.5000, "Kassel"),
|
||
(50.0000, 8.2731, "Mainz"),
|
||
(49.8728, 8.6512, "Darmstadt"),
|
||
(49.0047, 12.0949, "Regensburg"),
|
||
(48.9960, 8.4025, "Pforzheim"),
|
||
(53.4706, 9.9817, "Hamburg-Süd"),
|
||
(50.8283, 12.9209, "Chemnitz"),
|
||
(51.7227, 8.7559, "Paderborn"),
|
||
(52.1205, 11.6276, "Magdeburg"),
|
||
(52.6367, 11.8683, "Magdeburg-Ost"),
|
||
(50.3569, 7.5890, "Koblenz"),
|
||
(48.4010, 9.9876, "Ulm"),
|
||
(51.0504, 13.7373, "Dresden-Mitte"),
|
||
(49.4875, 8.4660, "Mannheim"),
|
||
(49.2354, 7.0038, "Kaiserslautern"),
|
||
(50.1155, 8.6782, "Frankfurt-Mitte"),
|
||
(50.0782, 8.2398, "Wiesbaden"),
|
||
(52.4227, 10.7865, "Wolfsburg"),
|
||
(51.9607, 8.8693, "Gütersloh"),
|
||
(53.5753, 9.8500, "Hamburg-West"),
|
||
(48.5216, 9.0576, "Reutlingen"),
|
||
(48.9522, 9.4358, "Heilbronn"),
|
||
(49.4478, 7.7691, "Kaiserslautern-W"),
|
||
(53.6333, 9.9833, "Hamburg-Nord"),
|
||
(52.3905, 13.0645, "Potsdam"),
|
||
(54.0924, 12.1407, "Rostock"),
|
||
(53.4339, 14.5508, "Szczecin-grenze"),
|
||
(51.7563, 14.3329, "Cottbus"),
|
||
(50.4782, 12.3598, "Zwickau"),
|
||
(53.5507, 9.9967, "Hamburg-Mitte"),
|
||
(51.8127, 10.3354, "Goslar"),
|
||
(48.6843, 9.0061, "Böblingen"),
|
||
(48.7761, 9.1775, "Stuttgart-Mitte"),
|
||
(49.4521, 8.4660, "Heidelberg"),
|
||
(50.8088, 8.7667, "Marburg"),
|
||
(51.9607, 7.6261, "Münster-Mitte"),
|
||
(52.2763, 8.0479, "Osnabrück"),
|
||
(53.8755, 10.7000, "Lübeck-Ost"),
|
||
(51.9333, 6.8667, "Borken"),
|
||
# München Umland
|
||
(48.0734, 11.9661, "Ebersberg"),
|
||
(47.9947, 11.6612, "Holzkirchen"),
|
||
(48.0628, 11.6574, "Ottobrunn"),
|
||
(48.2456, 11.3712, "Dachau"),
|
||
(48.1667, 11.7833, "Vaterstetten"),
|
||
(48.2667, 11.6667, "Garching"),
|
||
(48.0667, 11.4667, "Gauting"),
|
||
(47.9833, 11.3000, "Starnberg"),
|
||
]
|
||
|
||
async def _job_prewarm_cities():
|
||
import os, asyncio, time
|
||
from routes.osm import _covering_tiles, _stale_tiles, _fetch_and_store_tile, OSM_QUERIES, CACHE_ZOOM
|
||
from mailer import send_email
|
||
|
||
ADMIN = os.getenv("ADMIN_EMAIL", "")
|
||
REPORT_INTERVAL = 5 * 3600 # alle 5 Stunden
|
||
|
||
logger.info("City-Prewarm Job startet…")
|
||
sem = asyncio.Semaphore(1)
|
||
total_fetched = 0
|
||
cities_done = 0
|
||
start_time = time.monotonic()
|
||
last_report = start_time
|
||
|
||
async def _fetch(poi_type, x, y):
|
||
nonlocal total_fetched
|
||
async with sem:
|
||
await _fetch_and_store_tile(poi_type, x, y)
|
||
total_fetched += 1
|
||
await asyncio.sleep(5)
|
||
|
||
async def _send_progress(subject_prefix, cities_done, total_cities, eta_str=""):
|
||
if not ADMIN:
|
||
return
|
||
elapsed = int(time.monotonic() - start_time)
|
||
h, m = divmod(elapsed // 60, 60)
|
||
elapsed_str = f"{h}h {m:02d}min" if h else f"{m}min"
|
||
pct = round(cities_done / total_cities * 100)
|
||
body_plain = (
|
||
f"City-Prewarm Fortschritt\n\n"
|
||
f"Städte: {cities_done}/{total_cities} ({pct}%)\n"
|
||
f"Tiles geladen: {total_fetched}\n"
|
||
f"Laufzeit: {elapsed_str}\n"
|
||
f"{('Verbleibend (ca.): ' + eta_str) if eta_str else ''}"
|
||
)
|
||
body_html = f"""\
|
||
<div style="font-family:sans-serif;max-width:480px;margin:0 auto;padding:24px">
|
||
<h2 style="color:#C4843A;margin:0 0 16px">🗺️ City-Prewarm {subject_prefix}</h2>
|
||
<table style="border-collapse:collapse;width:100%">
|
||
<tr><td style="padding:6px 0;color:#666">Städte:</td>
|
||
<td style="padding:6px 0;font-weight:600">{cities_done} / {total_cities} ({pct}%)</td></tr>
|
||
<tr><td style="padding:6px 0;color:#666">Tiles geladen:</td>
|
||
<td style="padding:6px 0;font-weight:600">{total_fetched}</td></tr>
|
||
<tr><td style="padding:6px 0;color:#666">Laufzeit:</td>
|
||
<td style="padding:6px 0;font-weight:600">{elapsed_str}</td></tr>
|
||
{f'<tr><td style="padding:6px 0;color:#666">Verbleibend (ca.):</td><td style="padding:6px 0;font-weight:600">{eta_str}</td></tr>' if eta_str else ''}
|
||
</table>
|
||
</div>"""
|
||
try:
|
||
await send_email(ADMIN, f"Ban Yaro — City-Prewarm {subject_prefix}", body_html, body_plain)
|
||
except Exception as e:
|
||
logger.warning(f"City-Prewarm Mail fehlgeschlagen: {e}")
|
||
|
||
total_cities = len(_CITIES_DE)
|
||
for lat, lon, city in _CITIES_DE:
|
||
dlat = 0.18
|
||
dlon = 0.25
|
||
south, west, north, east = lat - dlat, lon - dlon, lat + dlat, lon + dlon
|
||
tiles = _covering_tiles(south, west, north, east, CACHE_ZOOM)
|
||
|
||
tasks = []
|
||
for poi_type in OSM_QUERIES:
|
||
stale = _stale_tiles(poi_type, tiles)
|
||
for (x, y) in stale:
|
||
tasks.append(_fetch(poi_type, x, y))
|
||
|
||
if tasks:
|
||
logger.info(f"City-Prewarm: {city} — {len(tasks)} Tiles zu laden")
|
||
await asyncio.gather(*tasks)
|
||
else:
|
||
logger.debug(f"City-Prewarm: {city} — alle Tiles frisch")
|
||
|
||
cities_done += 1
|
||
|
||
# Fortschritts-Mail alle 5 Stunden
|
||
now = time.monotonic()
|
||
if ADMIN and (now - last_report) >= REPORT_INTERVAL:
|
||
elapsed = now - start_time
|
||
rate = cities_done / elapsed if elapsed > 0 else 0
|
||
remaining = int((total_cities - cities_done) / rate) if rate > 0 else 0
|
||
rh, rm = divmod(remaining // 60, 60)
|
||
eta_str = f"{rh}h {rm:02d}min" if rh else f"{rm}min"
|
||
await _send_progress("Fortschritt", cities_done, total_cities, eta_str)
|
||
last_report = now
|
||
|
||
logger.info(f"City-Prewarm Job fertig — {total_fetched} Tiles geladen.")
|
||
await _send_progress("abgeschlossen ✓", cities_done, total_cities)
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# Hilfsfunktion: Job-Protokoll aktualisieren
|
||
# ------------------------------------------------------------------
|
||
def _log_job(job_id: str, status: str, result: str):
|
||
_job_log[job_id] = {
|
||
"last_run": datetime.now(tz=_TZ),
|
||
"status": status,
|
||
"result": result,
|
||
}
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# Hilfsfunktion: Lob-Text für einen Hund generieren
|
||
# ------------------------------------------------------------------
|
||
async def _generate_praise_for_dog(dog: dict, user_id: int) -> str:
|
||
"""Generiert einen Lob-Text für einen Hund basierend auf der letzten Woche."""
|
||
from ki import complete, KIUnavailableError
|
||
import json as _json
|
||
from datetime import date, timedelta
|
||
|
||
since = (date.today() - timedelta(days=7)).isoformat()
|
||
name = dog["name"]
|
||
rasse = dog.get("rasse") or "Hund"
|
||
|
||
stats = {}
|
||
try:
|
||
with db() as conn:
|
||
stats["diary"] = conn.execute("SELECT COUNT(*) FROM diary WHERE dog_id=? AND datum>=?", (dog["id"], since)).fetchone()[0]
|
||
stats["training"] = conn.execute("SELECT COUNT(*) FROM training_sessions WHERE dog_id=? AND datum>=?", (dog["id"], since)).fetchone()[0]
|
||
stats["top_training"] = conn.execute("SELECT COUNT(*) FROM training_sessions WHERE dog_id=? AND datum>=? AND ist_top=1", (dog["id"], since)).fetchone()[0]
|
||
stats["health"] = conn.execute("SELECT COUNT(*) FROM health WHERE dog_id=? AND datum>=?", (dog["id"], since)).fetchone()[0]
|
||
stats["days_active"] = conn.execute(
|
||
"SELECT COUNT(DISTINCT datum) FROM diary WHERE dog_id=? AND datum>=?", (dog["id"], since)
|
||
).fetchone()[0]
|
||
# Wie viele Wochen ist der User dabei?
|
||
first = conn.execute("SELECT MIN(datum) FROM diary WHERE dog_id=?", (dog["id"],)).fetchone()[0]
|
||
if first:
|
||
weeks_total = max(1, (date.today() - date.fromisoformat(first)).days // 7)
|
||
else:
|
||
weeks_total = 1
|
||
stats["weeks_total"] = weeks_total
|
||
except Exception:
|
||
pass
|
||
|
||
# Prompt aufbauen
|
||
aktivitaet_parts = []
|
||
if stats.get("diary", 0):
|
||
aktivitaet_parts.append(f"{stats['diary']} Tagebuch-Eintr\u00e4ge")
|
||
if stats.get("training", 0):
|
||
t = f"{stats['training']} Trainingseinheiten"
|
||
if stats.get("top_training", 0):
|
||
t += f" (davon {stats['top_training']} Top-Training)"
|
||
aktivitaet_parts.append(t)
|
||
if stats.get("health", 0):
|
||
aktivitaet_parts.append(f"{stats['health']} Gesundheitseintr\u00e4ge")
|
||
|
||
if not aktivitaet_parts:
|
||
aktivitaet_text = "Diese Woche war ruhig \u2014 keine erfassten Aktivit\u00e4ten."
|
||
else:
|
||
aktivitaet_text = ", ".join(aktivitaet_parts)
|
||
|
||
prompt = f"""Du bist ein warmer, wohlwollender Begleiter f\u00fcr Hundebesitzer. Schreibe eine kurze pers\u00f6nliche Lob-Nachricht (2-3 S\u00e4tze) f\u00fcr die vergangene Woche.
|
||
|
||
Hund: {name} ({rasse})
|
||
Letzte 7 Tage: {aktivitaet_text}
|
||
Dabei seit: {stats.get('weeks_total', 1)} Wochen
|
||
|
||
Regeln (unbedingt einhalten):
|
||
- Nur loben, NIEMALS Ratschl\u00e4ge geben oder auf Fehlendes hinweisen
|
||
- Sprich \u00fcber den Hund: "{name} hatte eine tolle Woche" \u2014 nicht \u00fcber den Besitzer
|
||
- Auch bei 0 Aktivit\u00e4ten: positive Formulierung (\u201eAuch ruhige Wochen geh\u00f6ren dazu\u201c)
|
||
- Maximal 3 kurze S\u00e4tze
|
||
- Warm, pers\u00f6nlich, keine Floskeln
|
||
- Kein "Du solltest...", kein "Vergiss nicht...", keine Empfehlungen"""
|
||
|
||
try:
|
||
text = await complete(
|
||
prompt,
|
||
system="Du schreibst kurze, warme Lob-Nachrichten f\u00fcr Hundebesitzer. Nur Lob, keine Ratschl\u00e4ge.",
|
||
max_tokens=150,
|
||
)
|
||
return text.strip()
|
||
except Exception:
|
||
# Fallback wenn KI nicht verfügbar
|
||
if aktivitaet_parts:
|
||
return f"{name} hatte eine aktive Woche \u2014 {aktivitaet_text}. Das ist toll! \U0001f43e"
|
||
else:
|
||
return f"Auch ruhige Wochen geh\u00f6ren dazu. {name} wei\u00df, dass du f\u00fcr ihn da bist. \U0001f43e"
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# JOB: Wöchentlicher Fortschritts-Lober
|
||
# ------------------------------------------------------------------
|
||
async def _job_weekly_praise():
|
||
"""Jeden Montag: Lob-Text f\u00fcr alle aktiven Hunde generieren + Push senden."""
|
||
from datetime import date
|
||
import json as _json
|
||
|
||
today = date.today()
|
||
d = today.isocalendar()
|
||
week_key = f"{d[0]}-W{d[1]:02d}"
|
||
|
||
logger.info(f"Weekly Praise Job startet f\u00fcr Woche {week_key}")
|
||
|
||
# Alle Hunde laden, für die noch kein Lob diese Woche existiert
|
||
with db() as conn:
|
||
dogs = conn.execute("""
|
||
SELECT d.id, d.name, d.rasse, d.user_id, d.foto_url
|
||
FROM dogs d
|
||
WHERE NOT EXISTS (
|
||
SELECT 1 FROM weekly_praise wp
|
||
WHERE wp.dog_id=d.id AND wp.week_key=?
|
||
)
|
||
ORDER BY d.id
|
||
""", (week_key,)).fetchall()
|
||
|
||
dogs = [dict(d) for d in dogs]
|
||
logger.info(f"Weekly Praise: {len(dogs)} Hunde ohne Lob diese Woche.")
|
||
|
||
import asyncio
|
||
generated = 0
|
||
for dog in dogs:
|
||
try:
|
||
praise = await _generate_praise_for_dog(dog, dog["user_id"])
|
||
with db() as conn:
|
||
conn.execute("""
|
||
INSERT OR IGNORE INTO weekly_praise (user_id, dog_id, week_key, praise_text)
|
||
VALUES (?,?,?,?)
|
||
""", (dog["user_id"], dog["id"], week_key, praise))
|
||
|
||
# Push-Notification — erste 100 Zeichen als Preview
|
||
preview = praise[:100] + "\u2026" if len(praise) > 100 else praise
|
||
send_push_to_user(dog["user_id"], {
|
||
"type": "weekly_praise",
|
||
"title": f"\U0001f43e R\u00fcckblick f\u00fcr {dog['name']}",
|
||
"body": preview,
|
||
"data": {"page": "diary"},
|
||
"tag": f"weekly-praise-{dog['id']}-{week_key}",
|
||
})
|
||
|
||
generated += 1
|
||
await asyncio.sleep(2) # Rate limiting für KI
|
||
except Exception as e:
|
||
logger.error(f"Weekly Praise: Fehler f\u00fcr Hund {dog['id']}: {e}")
|
||
|
||
logger.info(f"Weekly Praise Job fertig \u2014 {generated}/{len(dogs)} Lob-Texte generiert.")
|
||
_log_job("weekly_praise", "ok", f"{generated} Lob-Texte f\u00fcr KW {d[1]}")
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# JOB: Status-Report per Mail (4× täglich)
|
||
# ------------------------------------------------------------------
|
||
async def _job_status_report():
|
||
"""Sendet einen HTML-Status-Report an ADMIN_EMAIL."""
|
||
import os
|
||
from mailer import send_email
|
||
|
||
admin = os.getenv("ADMIN_EMAIL", "")
|
||
if not admin:
|
||
logger.info("Status-Report: ADMIN_EMAIL nicht gesetzt, übersprungen.")
|
||
return
|
||
|
||
now_str = datetime.now(tz=_TZ).strftime("%d.%m.%Y %H:%M")
|
||
|
||
# --- DB-Metriken abrufen ---
|
||
metrics = {}
|
||
try:
|
||
with db() as conn:
|
||
# Züchter
|
||
try:
|
||
metrics["zuchter_pending"] = conn.execute("SELECT COUNT(*) FROM wiki_zuchter WHERE verified=0").fetchone()[0]
|
||
metrics["zuchter_verified"] = conn.execute("SELECT COUNT(*) FROM wiki_zuchter WHERE verified=1").fetchone()[0]
|
||
except Exception:
|
||
metrics["zuchter_pending"] = metrics["zuchter_verified"] = 0
|
||
|
||
# Community
|
||
metrics["users"] = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||
metrics["dogs"] = conn.execute("SELECT COUNT(*) FROM dogs").fetchone()[0]
|
||
metrics["diary_entries"] = conn.execute("SELECT COUNT(*) FROM diary").fetchone()[0]
|
||
metrics["poison_active"] = conn.execute("SELECT COUNT(*) FROM poison WHERE geloest=0").fetchone()[0]
|
||
try:
|
||
metrics["lost_active"] = conn.execute("SELECT COUNT(*) FROM lost WHERE gefunden=0").fetchone()[0]
|
||
except Exception:
|
||
metrics["lost_active"] = 0
|
||
|
||
# Wiki-Interesse
|
||
try:
|
||
metrics["interesse_hat"] = conn.execute("SELECT COUNT(*) FROM wiki_breed_interest WHERE typ='hat'").fetchone()[0]
|
||
metrics["interesse_will"] = conn.execute("SELECT COUNT(*) FROM wiki_breed_interest WHERE typ='will'").fetchone()[0]
|
||
except Exception:
|
||
metrics["interesse_hat"] = metrics["interesse_will"] = 0
|
||
except Exception as e:
|
||
logger.error(f"Status-Report: DB-Fehler: {e}")
|
||
return
|
||
|
||
# --- Job-Log-Tabelle ---
|
||
job_labels = {
|
||
"health_reminders": "Gesundheits-Erinnerungen",
|
||
"poison_archive": "Giftköder-Archiv",
|
||
"weather_alert": "Wetter-Alert",
|
||
"milestone_check": "Meilenstein-Check",
|
||
"import_events": "Event-Import (VDH)",
|
||
"seed_breeds_startup": "Rassen-Seed (TheDogAPI)",
|
||
"seed_wikidata_startup":"Rassen-Seed (Wikidata)",
|
||
"weekly_praise": "Wöchentlicher Lober (Mo 09:00)",
|
||
}
|
||
job_rows_html = ""
|
||
job_rows_txt = ""
|
||
for jid, label in job_labels.items():
|
||
log = _job_log.get(jid)
|
||
if log:
|
||
ts = log["last_run"].strftime("%d.%m. %H:%M")
|
||
status = "✅" if log["status"] == "ok" else "❌"
|
||
result = log["result"]
|
||
color = "#16a34a" if log["status"] == "ok" else "#dc2626"
|
||
job_rows_html += f'<tr><td style="padding:5px 10px;color:#555">{label}</td><td style="padding:5px 10px;font-family:monospace;font-size:12px">{ts}</td><td style="padding:5px 10px;color:{color}">{status} {result}</td></tr>'
|
||
job_rows_txt += f" {status} {label}: {ts} — {result}\n"
|
||
else:
|
||
job_rows_html += f'<tr><td style="padding:5px 10px;color:#555">{label}</td><td style="padding:5px 10px;color:#aaa" colspan="2">— noch nicht gelaufen</td></tr>'
|
||
job_rows_txt += f" — {label}: noch nicht gelaufen\n"
|
||
|
||
html = f"""\
|
||
<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head><meta charset="utf-8"></head>
|
||
<body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f5f0ea;margin:0;padding:0">
|
||
<div style="max-width:600px;margin:24px auto;background:#fff;border-radius:14px;overflow:hidden;box-shadow:0 2px 16px rgba(0,0,0,.08)">
|
||
|
||
<!-- Header -->
|
||
<div style="background:linear-gradient(135deg,#C4843A,#e8a857);padding:24px 28px;color:#fff">
|
||
<div style="font-size:22px;font-weight:800;margin-bottom:2px">🐾 Ban Yaro — Status-Report</div>
|
||
<div style="opacity:.88;font-size:13px">{now_str} Uhr</div>
|
||
</div>
|
||
|
||
<!-- Scheduler-Status -->
|
||
<div style="padding:20px 28px;border-bottom:1px solid #f0e8dc">
|
||
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#C4843A;margin-bottom:10px">Scheduler-Jobs</div>
|
||
<table style="width:100%;border-collapse:collapse;font-size:13px">
|
||
{job_rows_html}
|
||
</table>
|
||
</div>
|
||
|
||
<!-- Community-Metriken -->
|
||
<div style="padding:20px 28px;border-bottom:1px solid #f0e8dc">
|
||
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#C4843A;margin-bottom:10px">Community</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
|
||
{"".join(f'<div style="background:#fdf6ef;border-radius:8px;padding:10px 14px"><div style="font-size:20px;font-weight:800;color:#C4843A">{v}</div><div style="font-size:11px;color:#888">{k}</div></div>' for k,v in [
|
||
("Nutzer",metrics["users"]),
|
||
("Hunde",metrics["dogs"]),
|
||
("Tagebuch-Einträge",metrics["diary_entries"]),
|
||
("Aktive Giftköder",metrics["poison_active"]),
|
||
("Vermisste Hunde",metrics["lost_active"]),
|
||
("'So einen hab ich'",metrics["interesse_hat"]),
|
||
("'Interessiert mich'",metrics["interesse_will"]),
|
||
("Züchter (pending)",metrics["zuchter_pending"]),
|
||
])}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Footer -->
|
||
<div style="padding:14px 28px;background:#fdf6ef;font-size:11px;color:#aaa;text-align:center">
|
||
Ban Yaro · banyaro.app · Nächster Report in ~6h
|
||
</div>
|
||
|
||
</div>
|
||
</body>
|
||
</html>"""
|
||
|
||
plain = f"""Ban Yaro Status-Report — {now_str}
|
||
|
||
=== Scheduler-Jobs ===
|
||
{job_rows_txt}
|
||
=== Community ===
|
||
Nutzer: {metrics['users']}
|
||
Hunde: {metrics['dogs']}
|
||
Tagebuch-Einträge: {metrics['diary_entries']}
|
||
Aktive Giftköder: {metrics['poison_active']}
|
||
Vermisste Hunde: {metrics['lost_active']}
|
||
'So einen hab ich': {metrics['interesse_hat']}
|
||
'Interessiert mich': {metrics['interesse_will']}
|
||
Züchter (pending): {metrics['zuchter_pending']}
|
||
"""
|
||
|
||
try:
|
||
await send_email(admin, f"Ban Yaro Status {now_str}", html, plain)
|
||
logger.info(f"Status-Report gesendet an {admin}.")
|
||
except Exception as e:
|
||
logger.error(f"Status-Report: Mail-Fehler: {e}")
|
||
|
||
|
||
def _compute_milestone(today: date, bday: date, dog_name: str):
|
||
"""
|
||
Gibt (titel, text) zurück wenn heute ein Meilenstein-Tag ist,
|
||
sonst None.
|
||
|
||
Regeln:
|
||
- Jahrestag (Monat + Tag stimmen überein, Jahrgang ≥ 1):
|
||
"🎂 <Name> ist X Jahr(e) alt!"
|
||
- Monatsjubiläum in den ersten 11 Monaten (Geburtsmonats-Tag):
|
||
"🐾 <Name> ist heute X Monat(e) alt!"
|
||
"""
|
||
# Jahrestag?
|
||
if today.month == bday.month and today.day == bday.day:
|
||
years = today.year - bday.year
|
||
if years <= 0:
|
||
return None # Geburtstag selbst (Tag 0) → kein Eintrag
|
||
years_label = f"{years} Jahr" if years == 1 else f"{years} Jahre"
|
||
titel = f"🎂 {dog_name} ist {years_label} alt!"
|
||
text = (
|
||
f"Heute feiern wir {dog_name}s {years}. Geburtstag! 🐾🎉 "
|
||
f"Herzlichen Glückwunsch zum {years_label}!"
|
||
)
|
||
return titel, text
|
||
|
||
# Monatsjubiläum (nur innerhalb des ersten Lebensjahres)?
|
||
# today liegt im selben Monatstag wie der Geburtstag aber in einem anderen Monat.
|
||
if today.day == bday.day:
|
||
# Vollständige Monate seit Geburt berechnen
|
||
months = (today.year - bday.year) * 12 + (today.month - bday.month)
|
||
if 1 <= months <= 11:
|
||
months_label = f"{months} Monat" if months == 1 else f"{months} Monate"
|
||
titel = f"🐾 {dog_name} ist heute {months_label} alt!"
|
||
text = (
|
||
f"{dog_name} wird heute {months_label} alt — "
|
||
f"was für ein tolles kleines Hundeleben! 🥳"
|
||
)
|
||
return titel, text
|
||
|
||
return None
|