""" 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") 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, ) _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.") # ------------------------------------------------------------------ # 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 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: 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.") # ------------------------------------------------------------------ # 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.") # ------------------------------------------------------------------ # 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).") # ------------------------------------------------------------------ # 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") except Exception as e: logger.error(f"Wikidata-Seed: Fehler: {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"""\

🗺️ City-Prewarm {subject_prefix}

{f'' if eta_str else ''}
Städte: {cities_done} / {total_cities} ({pct}%)
Tiles geladen: {total_fetched}
Laufzeit: {elapsed_str}
Verbleibend (ca.):{eta_str}
""" 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) 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): "🎂 ist X Jahr(e) alt!" - Monatsjubiläum in den ersten 11 Monaten (Geburtsmonats-Tag): "🐾 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