""" 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, ) # 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, ) # Alle 4 Wochen Di 03:00 — Rassen aus TheDogAPI aktualisieren _scheduler.add_job( _job_seed_breeds, CronTrigger(day=1, hour=3, minute=0), # 1. jedes Monats id="seed_breeds", replace_existing=True, misfire_grace_time=3600, ) # Alle 4 Wochen Di 04:00 — fehlende Rassen aus Wikidata ergänzen _scheduler.add_job( _job_seed_wikidata_breeds, CronTrigger(day=1, hour=4, minute=0), # 1. jedes Monats id="seed_wikidata", replace_existing=True, misfire_grace_time=3600, ) # 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, ) # Täglich 06:00 Uhr Status-Report per Mail _scheduler.add_job( _job_status_report, CronTrigger(hour=6, minute=0), id="status_report", replace_existing=True, misfire_grace_time=1800, ) # Täglich 12:00 — Moderation-Overdue-Check _scheduler.add_job( _job_moderation_overdue, CronTrigger(hour=12, minute=0), id="moderation_overdue", replace_existing=True, misfire_grace_time=1800, ) # 1. Feb / Mai / Aug / Nov 07:00 — Quartalsbericht _scheduler.add_job( _job_quarterly_report, CronTrigger(month="2,5,8,11", day=1, hour=7, minute=0), id="quarterly_report", replace_existing=True, misfire_grace_time=7200, ) # Jeden Montag 07:00 — KI-Gesundheitsberichte (alle 2 Wochen) _scheduler.add_job( _job_ki_health_report, CronTrigger(day_of_week='mon', hour=7, minute=0), id="ki_health_report", replace_existing=True, misfire_grace_time=3600, ) # Täglich 06:30 — Wiederkehrende Ausgaben anlegen _scheduler.add_job( _job_recurring_expenses, CronTrigger(hour=6, minute=30), id="recurring_expenses", replace_existing=True, misfire_grace_time=3600, ) # 1. des Monats 00:05 — Hund des Monats Sieger festlegen _scheduler.add_job( _job_hdm_winner, CronTrigger(day=1, hour=0, minute=5), id="hdm_winner", replace_existing=True, misfire_grace_time=3600, ) # Täglich 19:00 Uhr — Streak-Erinnerung _scheduler.add_job( _job_streak_reminder, CronTrigger(hour=19, minute=0), id="streak_reminder", replace_existing=True, misfire_grace_time=3600, ) # Täglich 08:00 Uhr — Tierfutter-Rückrufe prüfen (RASFF) _scheduler.add_job( _job_recall_check, CronTrigger(hour=8, minute=0), id="recall_check", replace_existing=True, misfire_grace_time=3600, ) # Täglich 07:00 Uhr — Goldene Gassi-Stunde _scheduler.add_job( _job_golden_gassi_hour, CronTrigger(hour=7, minute=0), id="golden_gassi_hour", replace_existing=True, misfire_grace_time=3600, ) _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 monatlich 1. des Monats, Status-Report täglich 06:00, Moderation-Overdue 12:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00, Streak-Reminder 19:00, Rückruf-Check 08:00, Goldene-Gassi-Stunde 07:00. OSM-Cache: on-demand (kein Prewarm).") 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", "ok", f"{count} Rassen, {mirrored}+{wp_count} Fotos") except Exception as e: logger.error(f"Wikidata-Seed: Fehler: {e}") _log_job("seed_wikidata", "error", str(e)) # ------------------------------------------------------------------ # 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: KI-Gesundheitsberichte (alle 2 Wochen, jeden Montag 07:00) # ------------------------------------------------------------------ async def _job_ki_health_report(): """ Erstellt für jeden Hund, der seit mehr als 13 Tagen keinen KI-Gesundheitsbericht hat (oder noch keinen hatte), einen neuen Bericht via ki.health_summary() und schickt eine Push-Notification an den Besitzer. Maximal 20 Hunde pro Lauf. """ import ki as KI with db() as conn: dogs = conn.execute(""" SELECT d.id AS dog_id, d.name, d.rasse, d.geburtstag, d.gewicht_kg, d.user_id FROM dogs d WHERE d.id NOT IN ( SELECT dog_id FROM ki_health_reports WHERE erstellt_at >= datetime('now', '-13 days') ) ORDER BY d.id LIMIT 20 """).fetchall() dogs = [dict(d) for d in dogs] if not dogs: logger.info("KI-Gesundheitsbericht: Keine fälligen Hunde.") _log_job("ki_health_report", "ok", "0 Berichte erstellt") return count = 0 for dog in dogs: try: with db() as conn: health_rows = conn.execute( "SELECT * FROM health WHERE dog_id=? ORDER BY datum DESC", (dog["dog_id"],) ).fetchall() health_data = [dict(r) for r in health_rows] dog_info = { "name": dog["name"], "rasse": dog.get("rasse"), "geburtstag": dog.get("geburtstag"), "gewicht_kg": dog.get("gewicht_kg"), } bericht = await KI.health_summary(health_data=health_data, dog_info=dog_info) with db() as conn: conn.execute( "INSERT INTO ki_health_reports (dog_id, user_id, bericht) VALUES (?, ?, ?)", (dog["dog_id"], dog["user_id"], bericht) ) send_push_to_user(dog["user_id"], { "type": "ki_health_report", "title": f"Gesundheitsbericht für {dog['name']}", "body": "Dein KI-Assistent hat einen neuen Bericht erstellt.", "data": {"page": "health"}, }) count += 1 logger.info(f"KI-Gesundheitsbericht: Bericht für Hund {dog['dog_id']} ({dog['name']}) erstellt.") except Exception as e: logger.error(f"KI-Gesundheitsbericht: Fehler für Hund {dog['dog_id']} ({dog['name']}): {e}") logger.info(f"KI-Gesundheitsbericht Job fertig — {count}/{len(dogs)} Berichte erstellt.") _log_job("ki_health_report", "ok", f"{count} Berichte erstellt") # ------------------------------------------------------------------ async def _job_moderation_overdue(): """Sendet Alarm-Mail wenn Moderations-Einträge seit >24h offen sind.""" import os from mailer import send_email admin = os.getenv("ADMIN_EMAIL", "") if not admin: return SLA_H = 24 threshold = f"datetime('now', '-{SLA_H} hours')" overdue = {} try: with db() as conn: n = conn.execute(f"SELECT COUNT(*) FROM job_applications WHERE status IN ('pending','reviewing') AND created_at < {threshold}").fetchone()[0] if n: overdue["Bewerbungen"] = n n = conn.execute(f"SELECT COUNT(*) FROM users WHERE breeder_status='pending' AND created_at < {threshold}").fetchone()[0] if n: overdue["Züchter-Anträge"] = n n = conn.execute(f"SELECT COUNT(*) FROM forum_reports WHERE resolved=0 AND created_at < {threshold}").fetchone()[0] if n: overdue["Forum-Meldungen"] = n n = conn.execute(f"SELECT COUNT(*) FROM wiki_foto_submissions WHERE status='pending' AND created_at < {threshold}").fetchone()[0] if n: overdue["Foto-Einreichungen"] = n n = conn.execute(f"SELECT COUNT(*) FROM osm_poi_edits WHERE status='pending' AND created_at < {threshold}").fetchone()[0] if n: overdue["POI-Korrekturen"] = n n = conn.execute(f"SELECT COUNT(*) FROM wiki_zuchter WHERE verified=0 AND created_at < {threshold}").fetchone()[0] if n: overdue["Züchter-Einreichungen (Wiki)"] = n except Exception as e: logger.error(f"Moderation-Overdue-Check: DB-Fehler: {e}") return if not overdue: logger.info("Moderation-Overdue-Check: Alles im SLA.") return now_str = datetime.now(tz=_TZ).strftime("%d.%m.%Y %H:%M") rows_html = "".join( f'
')
in_code = True
continue
if in_code:
lines_out.append(_h.escape(line))
continue
if line.startswith("#### "):
lines_out.append(f'{line[5:]}
')
elif line.startswith("### "):
lines_out.append(f'{line[4:]}
')
elif line.startswith("## "):
lines_out.append(f'{line[3:]}
')
elif line.startswith("# "):
pass # Haupttitel kommt vom äußeren Template
elif line.startswith("---"):
pass # Trennlinie überspringen
elif line.startswith("| "):
if not in_table:
lines_out.append('')
in_table = True
if set(line.replace("|","").replace("-","").replace(" ","")) == set():
continue # Trenn-Zeile
cells = [c.strip() for c in line.split("|")[1:-1]]
row_html = "".join(f'{_h.escape(c)} ' for c in cells)
lines_out.append(f"{row_html} ")
continue
elif line.startswith("- ") or line.startswith("* "):
if in_table:
lines_out.append("
")
in_table = False
lines_out.append(f'{line[2:]} ')
elif line.startswith("> "):
if in_table:
lines_out.append("")
in_table = False
lines_out.append(f'{line[2:]}
')
elif line.strip() == "":
if in_table:
lines_out.append("")
in_table = False
lines_out.append("")
else:
if in_table:
lines_out.append("")
in_table = False
styled = line.replace("**", "", 1).replace("**", "", 1)
lines_out.append(f'{styled}
')
if in_table:
lines_out.append("")
if in_code:
lines_out.append("")
return "\n".join(lines_out)
# Body aus allen Sections zusammensetzen
body_parts = []
plain_parts = [f"Ban Yaro Quartalsbericht Q{quarter} {now_str}\n", "=" * 50]
for title, fn in sections:
try:
md = fn()
body_parts.append(
f'Fehler in Section {title}: {e}
') plain_parts.append(f"\n=== {title.upper()} ===\nFehler: {e}\n") full_body = "\n".join(body_parts) full_plain = "\n".join(plain_parts) subject = f"Ban Yaro Quartalsbericht Q{quarter}/{datetime.now(tz=_TZ).year} — {now_str}" html = email_html(full_body, footer_text=f"Ban Yaro · banyaro.app · Quartalsbericht Q{quarter}") await send_email(admin, subject, html, full_plain) logger.info(f"Quartalsbericht Q{quarter} gesendet an {admin}.") _log_job("quarterly_report", "ok", f"Q{quarter} → {admin}") except Exception as e: logger.error(f"Quartalsbericht: Fehler: {e}") _log_job("quarterly_report", "error", str(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): "🎂