""" 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(): # ------------------------------------------------------------------ # Job-Staffelung in 5-Minuten-Intervallen — verhindert gleichzeitige # Last-Spitzen (mehrere Jobs zur selben Sekunde 08:00 Uhr). # coalesce=True: bei verpassten Läufen nur ein Lauf nachholen. # misfire_grace_time: Mindestwert 300s, höher wo Job lange dauern kann. # ------------------------------------------------------------------ _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, coalesce=True, ) _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, coalesce=True, ) _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, coalesce=True, ) _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, coalesce=True, ) _scheduler.add_job( _job_import_events, CronTrigger(month="1,4,7,10", day=2, hour=2), # quartalsweise Jan/Apr/Jul/Okt id="import_events", replace_existing=True, misfire_grace_time=7200, coalesce=True, ) # 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, ) # 1. des Monats 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, coalesce=True, ) # 1. des Monats 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, coalesce=True, ) # Jeden Montag 09:05 — Wöchentlicher Fortschritts-Lober (staggered) _scheduler.add_job( _job_weekly_praise, CronTrigger(day_of_week='mon', hour=9, minute=5), id="weekly_praise", replace_existing=True, misfire_grace_time=3600, coalesce=True, ) # 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, coalesce=True, ) # 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, coalesce=True, ) # 1. Feb / Mai / Aug / Nov 07:10 — Quartalsbericht (staggered weg von 07:00) _scheduler.add_job( _job_quarterly_report, CronTrigger(month="2,5,8,11", day=1, hour=7, minute=10), id="quarterly_report", replace_existing=True, misfire_grace_time=7200, coalesce=True, ) # Jeden Montag 07:05 — KI-Gesundheitsberichte (staggered weg von 07:00) _scheduler.add_job( _job_ki_health_report, CronTrigger(day_of_week='mon', hour=7, minute=5), id="ki_health_report", replace_existing=True, misfire_grace_time=3600, coalesce=True, ) # 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, coalesce=True, ) # 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, coalesce=True, ) # 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, coalesce=True, ) # Täglich 08:05 Uhr — Tierfutter-Rückrufe prüfen (RASFF) (staggered weg von 08:00) _scheduler.add_job( _job_recall_check, CronTrigger(hour=8, minute=5), id="recall_check", replace_existing=True, misfire_grace_time=3600, coalesce=True, ) # Jeden Montag 08:10 Uhr — Neue Foto-Challenge anlegen (staggered weg von 08:00) _scheduler.add_job( _job_new_foto_challenge, CronTrigger(day_of_week='mon', hour=8, minute=10), id="new_foto_challenge", replace_existing=True, misfire_grace_time=3600, coalesce=True, ) # 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, coalesce=True, ) # Täglich 09:00 Uhr — Jahrestags-Erinnerungen (Tagebuch-Einträge von heute vor X Jahren) _scheduler.add_job( _job_anniversary_reminders, CronTrigger(hour=9, minute=0), id="anniversary_reminders", replace_existing=True, misfire_grace_time=3600, coalesce=True, ) # 1. des Monats 10:00 — Monatlicher Rückblick per Push _scheduler.add_job( _job_monthly_recap, CronTrigger(day=1, hour=10, minute=0), id="monthly_recap", replace_existing=True, misfire_grace_time=3600, coalesce=True, ) # Täglich 03:15 — Abo-Ablauf prüfen (staggered weg von 03:00 poison_archive) _scheduler.add_job( _job_subscription_check, CronTrigger(hour=3, minute=15), id="subscription_check", replace_existing=True, misfire_grace_time=3600, coalesce=True, ) _scheduler.add_job( _job_invoice_reminder, CronTrigger(hour=8, minute=30), # täglich 08:30 Uhr id="invoice_reminder", replace_existing=True, misfire_grace_time=3600, coalesce=True, ) # Täglich 06:30 — Error-Digest-Mail an ADMIN_EMAIL _scheduler.add_job( _job_error_digest, CronTrigger(hour=6, minute=30), id="error_digest", replace_existing=True, misfire_grace_time=1800, coalesce=True, ) _scheduler.start() logger.info("Scheduler gestartet (gestaffelt) — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import 1.+2./4./7./10. 02:00, Rassen-Seed 1. 03:00, Wikidata-Seed 1. 04:00, Status-Report 06:00, Moderation-Overdue 12:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:10, KI-Gesundheitsbericht Mo 07:05, Streak-Reminder 19:00, Rückruf-Check 08:05, Goldene-Gassi-Stunde 07:00, Jahrestags-Erinnerungen 09:00, Monatlicher-Rückblick 1. 10:00, Foto-Challenge Mo 08:10, Weekly-Praise Mo 09:05, Abo-Check 03:15, Invoice-Reminder 08:30. OSM-Cache: on-demand (kein Prewarm).") def stop(): _scheduler.shutdown(wait=False) logger.info("Scheduler gestoppt.") # ------------------------------------------------------------------ # JOB: Abo-Ablauf prüfen (täglich 03:00) # ------------------------------------------------------------------ _TIER_PRICE = {"pro": 29.00, "breeder": 49.00} async def _create_renewal_invoice_draft(user: dict, expires: date, tier_label: str): """Legt einen Rechnungs-Entwurf für die Abo-Verlängerung an, sofern noch keiner existiert.""" import os from mailer import send_email, email_html from routes.invoices import _next_invoice_number # Gekündigte Abos bekommen keine Erneuerungsrechnung if user.get("subscription_cancelled_at"): logger.info(f"Kein Erneuerungsentwurf für {user['email']} — Abo ist gekündigt.") return tier = user["subscription_tier"] price = _TIER_PRICE.get(tier, 29.00) # Verlängerungszeitraum: Folgetag nach Ablauf bis +1 Jahr start = expires + timedelta(days=1) end = start.replace(year=start.year + 1) - timedelta(days=1) period = f"{start.strftime('%d.%m.%Y')} - {end.strftime('%d.%m.%Y')}" with db() as conn: # Nur anlegen wenn noch kein Entwurf/offener Eintrag für diesen User + Zeitraum existing = conn.execute( """SELECT id FROM invoices WHERE user_id=? AND status IN ('draft','sent') AND service_period=?""", (user["id"], period) ).fetchone() if existing: logger.info(f"Erneuerungsrechnung bereits vorhanden für user {user['id']}") return # Billing-Adresse des Users laden row = conn.execute( "SELECT billing_address FROM users WHERE id=?", (user["id"],) ).fetchone() billing_address = row["billing_address"] if row else None # Rabatt berechnen (inline, da kein Admin-Import möglich) disc_row = conn.execute( """SELECT u.is_founder, u.is_founder_pending, u.referred_by, COALESCE((SELECT COUNT(*) FROM users WHERE referred_by=u.id), 0) AS referral_count FROM users u WHERE u.id=?""", (user["id"],) ).fetchone() discount_pct = 0 discount_reason = None referral_count = 0 if disc_row: referral_count = disc_row["referral_count"] if disc_row["is_founder"] or disc_row["is_founder_pending"]: discount_pct = 100 discount_reason = "founder" elif (disc_row["referred_by"] or 0) > 0: ref = conn.execute( "SELECT is_founder, is_founder_pending FROM users WHERE id=?", (disc_row["referred_by"],) ).fetchone() if ref and (ref["is_founder"] or ref["is_founder_pending"]): discount_pct = 50 discount_reason = "referred_by_founder" if not discount_reason: for thr, pct in [(50, 50), (20, 30), (10, 20)]: if referral_count >= thr: discount_pct = pct discount_reason = "referral" break discount_amt = round(price * discount_pct / 100, 2) after_disc = round(price - discount_amt, 2) _AGB = "Jahresbeitrag gem. AGB. Bei vorzeitiger Kündigung keine anteilige Rückerstattung; Zugang bleibt bis Laufzeitende bestehen." if discount_reason == "founder": notes = f"Gründer-Sonderkonditionen: {tier_label} kostenfrei als Dankeschön für deine Unterstützung als Gründer! {_AGB} (Automatisch erstellt, Ablauf: {expires.strftime('%d.%m.%Y')})" elif discount_reason == "referred_by_founder": notes = f"Willkommen in der Gründer-Community! Als persönlich von einem Gründer eingeladenes Mitglied erhältst du dauerhaft {discount_pct}% Rabatt. {_AGB} (Automatisch erstellt, Ablauf: {expires.strftime('%d.%m.%Y')})" elif discount_reason == "referral": notes = f"Herzlichen Dank für deine Unterstützung! Für {referral_count} geworbene Freunde erhältst du {discount_pct}% Rabatt. {_AGB} (Automatisch erstellt, Ablauf: {expires.strftime('%d.%m.%Y')})" else: notes = f"{_AGB} (Automatisch erstellt, Ablauf: {expires.strftime('%d.%m.%Y')})" invoice_number = _next_invoice_number(conn) description = f"{tier_label} Jahresabo (Verlängerung)" conn.execute(""" INSERT INTO invoices (invoice_number, user_id, recipient_name, recipient_email, recipient_address, description, service_period, amount_net, discount_pct, discount_amount, amount_after_discount, tax_rate, tax_amount, amount_gross, notes) VALUES (?,?,?,?,?,?,?,?,?,?,?,0,0,?,?) """, ( invoice_number, user["id"], user["name"], user["email"], billing_address, description, period, price, discount_pct, discount_amt, after_disc, after_disc, notes, )) conn.execute( "INSERT INTO invoice_items (invoice_id, description, quantity, unit_price, total) VALUES (?,?,1,?,?)", (conn.execute("SELECT last_insert_rowid()").fetchone()[0], description, price, price) ) logger.info(f"Erneuerungsrechnung {invoice_number} als Entwurf angelegt für {user['email']}") # Admin-Benachrichtigung admin_email = os.getenv("ADMIN_EMAIL", "") if admin_email: app_url = os.getenv("APP_URL", "https://banyaro.app") body = f"""

Für {user['name']} ({user['email']}) wurde automatisch ein Rechnungsentwurf für die Abo-Verlängerung erstellt.

Rechnung:{invoice_number}
Tarif:{tier_label}
Betrag:{price:.2f} EUR
Zeitraum:{period}
Abo läuft ab:{expires.strftime('%d.%m.%Y')} (in 30 Tagen)

Bitte prüfen, ggf. anpassen und rechtzeitig versenden.

""" html = email_html(body, cta_url=f"{app_url}/#admin", cta_label="Zur Rechnung im Admin") await send_email( admin_email, f"Erneuerungsrechnung {invoice_number} bereit — {user['name']}", html, f"Entwurf {invoice_number} für {user['name']} ({tier_label}, {price:.2f} EUR, {period}) bereit." ) async def _remind_renewal_invoice(user: dict, expires: date, tier_label: str): """7-Tage-Erinnerung an René: Entwurf noch nicht versendet.""" import os from mailer import send_email, email_html with db() as conn: draft = conn.execute( "SELECT invoice_number FROM invoices WHERE user_id=? AND status='draft' LIMIT 1", (user["id"],) ).fetchone() if not draft: return # kein offener Entwurf, nichts zu erinnern admin_email = os.getenv("ADMIN_EMAIL", "") if not admin_email: return app_url = os.getenv("APP_URL", "https://banyaro.app") body = f"""

Achtung: Das Abo von {user['name']} ({user['email']}) läuft in 7 Tagen (am {expires.strftime('%d.%m.%Y')}) ab.

Rechnungsentwurf {draft['invoice_number']} wurde noch nicht versendet. Bitte jetzt versenden damit der Kunde rechtzeitig bezahlen kann.

""" html = email_html(body, cta_url=f"{app_url}/#admin", cta_label="Rechnung jetzt senden") await send_email( admin_email, f"⚠ Noch 7 Tage — Erneuerungsrechnung {draft['invoice_number']} nicht versendet", html, f"Entwurf {draft['invoice_number']} für {user['name']} noch nicht versendet. Abo läuft in 7 Tagen ab." ) logger.info(f"7-Tage-Erinnerung an Admin für {user['email']}: {draft['invoice_number']}") async def _job_invoice_reminder(): """ Unbezahlte Rechnungen (status='sent'): - Nach 21 Tagen: Zahlungsmahnung mit 14-Tage-Frist (§286/314 BGB) - Nach 35 Tagen (21+14): Fristlose Abo-Kündigung """ from database import db as _db from mailer import send_email, email_html from routes.invoices import _next_invoice_number import html as _html import os APP_URL = os.getenv("APP_URL", "https://banyaro.app") IBAN = os.getenv("RECHNUNG_IBAN", "") ADMIN_MAIL = os.getenv("ADMIN_EMAIL", "") today = datetime.now(_TZ).date() with db() as conn: open_invoices = conn.execute( """SELECT i.*, u.name AS user_name, u.subscription_tier, u.id AS uid FROM invoices i LEFT JOIN users u ON u.id = i.user_id WHERE i.status = 'sent' AND i.sent_at IS NOT NULL""" ).fetchall() for inv in open_invoices: try: sent_date = datetime.fromisoformat(inv["sent_at"].replace("Z", "+00:00")).date() days_open = (today - sent_date).days rg = inv["invoice_number"] name = inv["recipient_name"] email = inv["recipient_email"] amount = inv["amount_gross"] frist = (today + timedelta(days=14)).strftime("%d.%m.%Y") # ── 21 Tage: Zahlungsmahnung mit 14-Tage-Frist ─────────── if days_open == 21: iban_line = f"

IBAN: {IBAN} · Verwendungszweck: {rg}

" if IBAN else "" body = f"""

Hallo {_html.escape(name)},

unsere Rechnung {rg} vom {datetime.fromisoformat(inv['created_at'][:10]).strftime('%d.%m.%Y')} über {amount:.2f} EUR ist leider noch offen.

Bitte überweisen Sie den Betrag bis zum {frist}. {iban_line}

Sollte die Zahlung bis zu diesem Datum nicht eingehen, sind wir leider gezwungen, Ihr Abonnement fristlos zu kündigen (§ 314 BGB).

""" html = email_html(body, cta_url=APP_URL, cta_label="Ban Yaro öffnen") await send_email( email, f"Zahlungserinnerung: Rechnung {rg} — Ban Yaro", html, f"Hallo {name},\n\nRechnung {rg} über {amount:.2f} EUR ist noch offen.\n" f"Bitte bis {frist} überweisen. Andernfalls kündigen wir fristlos.\n" + (f"IBAN: {IBAN}, Verwendungszweck: {rg}\n" if IBAN else "") ) logger.info(f"Zahlungsmahnung gesendet: {rg} an {email} (21 Tage offen)") # ── 35 Tage: Fristlose Kündigung ───────────────────────── elif days_open == 35: # Abo kündigen wenn Nutzer zugeordnet und aktives Abo if inv["uid"] and inv["subscription_tier"] not in (None, "standard", "standard_test"): with db() as conn2: conn2.execute( """UPDATE users SET subscription_tier='standard', subscription_expires_at=NULL, subscription_cancelled_at=strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE id=?""", (inv["uid"],) ) logger.info(f"Fristlose Kündigung: user {inv['uid']} wegen unbezahlter Rechnung {rg}") body = f"""

Hallo {_html.escape(name)},

da die Zahlung für Rechnung {rg} ({amount:.2f} EUR) trotz unserer Zahlungserinnerung nicht eingegangen ist, haben wir Ihr Abonnement gemäß § 314 BGB fristlos gekündigt.

Ihre Daten bleiben vollständig erhalten. Sie können jederzeit ein neues Abonnement abschließen.

Bei Rückfragen antworten Sie einfach auf diese E-Mail.

""" html = email_html(body, cta_url=APP_URL, cta_label="Ban Yaro öffnen") await send_email( email, f"Ihr Ban Yaro Abonnement wurde gekündigt — Rechnung {rg}", html, f"Hallo {name},\n\nIhr Abo wurde wegen unbezahlter Rechnung {rg} fristlos gekündigt.\n" f"Ihre Daten sind erhalten. Neue Buchung jederzeit möglich.\n" ) if ADMIN_MAIL: await send_email( ADMIN_MAIL, f"Fristlose Kündigung: {name} — {rg} ({amount:.2f} EUR unbezahlt)", email_html(f"

Abo von {_html.escape(name)} ({email}) wurde automatisch fristlos gekündigt (§314 BGB). Rechnung {rg} seit 35 Tagen offen.

"), f"Abo {name} gekündigt wegen unbezahlter Rechnung {rg}." ) except Exception as e: logger.warning(f"Invoice-Reminder Fehler für {inv.get('invoice_number','?')}: {e}") async def _job_subscription_check(): """Abgelaufene Abos auf Standard setzen; Warnmails 30 und 7 Tage vorher.""" from database import db as _db from mailer import send_email, email_html import html as _html now = datetime.now(_TZ) today = now.date() with _db() as conn: users = conn.execute( """SELECT id, name, email, subscription_tier, subscription_expires_at, subscription_cancelled_at FROM users WHERE subscription_tier IN ('pro','breeder') AND subscription_expires_at IS NOT NULL""" ).fetchall() for u in users: try: expires = datetime.fromisoformat(u["subscription_expires_at"].replace('Z', '+00:00')).date() days_left = (expires - today).days tier_label = {"pro": "Ban Yaro Pro", "breeder": "Züchter"}.get(u["subscription_tier"], u["subscription_tier"]) # Abgelaufen → auf Standard setzen if days_left < 0: with _db() as conn: dog_count = conn.execute( "SELECT COUNT(*) FROM dogs WHERE user_id=? AND is_active!=0", (u["id"],) ).fetchone()[0] needs_sel = 1 if dog_count > 1 else 0 conn.execute( """UPDATE users SET subscription_tier='standard', needs_dog_selection=? WHERE id=?""", (needs_sel, u["id"]) ) logger.info(f"Abo abgelaufen: {u['email']} → standard (needs_dog_selection={needs_sel})") body = f"""

Hallo {_html.escape(u['name'])},

dein {tier_label}-Abo ist heute abgelaufen. Dein Account wurde auf den kostenlosen Tarif gesetzt.

Deine Daten sind vollständig erhalten. Du kannst jederzeit wieder upgraden.

""" if needs_sel: body += "

Wichtig: Du hattest mehrere Hunde. Öffne die App und wähle deinen Haupthund aus — alle anderen Profile bleiben gespeichert.

" html = email_html(body, cta_url="https://banyaro.app", cta_label="Ban Yaro öffnen") await send_email(u["email"], f"Dein {tier_label}-Abo ist abgelaufen", html, f"Hallo {u['name']},\ndein {tier_label}-Abo ist abgelaufen. Daten bleiben erhalten.") # 30 Tage Warnung + Erneuerungsrechnung als Entwurf anlegen elif days_left == 30: body = f"""

Hallo {_html.escape(u['name'])},

dein {tier_label}-Abo läuft in 30 Tagen (am {expires.strftime('%d.%m.%Y')}) ab.

Um weiterzumachen, überweise einfach den Jahresbetrag und schreib uns kurz — wir verlängern deinen Zugang sofort.

""" html = email_html(body, cta_url="https://banyaro.app", cta_label="Abo verlängern") await send_email(u["email"], f"Dein {tier_label}-Abo läuft in 30 Tagen ab", html, f"Hallo {u['name']},\ndein {tier_label}-Abo läuft in 30 Tagen ab ({expires}).") # Erneuerungsrechnung als Entwurf anlegen (nur wenn noch keine existiert) await _create_renewal_invoice_draft(u, expires, tier_label) # 7 Tage — Warnung an User + Erinnerung an René falls Entwurf noch nicht versendet elif days_left == 7: body = f"""

Hallo {_html.escape(u['name'])},

dein {tier_label}-Abo läuft in 7 Tagen (am {expires.strftime('%d.%m.%Y')}) ab.

Jetzt verlängern und nahtlos weitermachen!

""" html = email_html(body, cta_url="https://banyaro.app", cta_label="Abo verlängern") await send_email(u["email"], f"Nur noch 7 Tage — {tier_label}-Abo läuft ab", html, f"Hallo {u['name']},\nnur noch 7 Tage für dein {tier_label}-Abo.") await _remind_renewal_invoice(u, expires, tier_label) except Exception as e: logger.warning(f"subscription_check Fehler für {u['email']}: {e}") # ------------------------------------------------------------------ # 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}") in3 = today + timedelta(days=3) with db() as conn: # Alle fälligen Einträge der nächsten 7 Tage + gestrige (überfällig) # erinnerung=0 → User hat diese Erinnerung deaktiviert 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') AND (h.erinnerung IS NULL OR h.erinnerung = 1) """, (str(today), str(in7), str(in3), 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 == 3: title = f"⏰ Erinnerung: {r['bezeichnung']}" body = f"In 3 Tagen fällig für {r['hund_name']} — bald vorbereiten." 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'{label}' f'{count}' for label, count in overdue.items() ) html = f"""\
⚠️ Moderation überfällig
{now_str} · SLA: {SLA_H}h

Folgende Einträge warten seit mehr als {SLA_H} Stunden auf Bearbeitung:

{rows_html}
Bereich Anzahl
→ Admin-Panel öffnen
Ban Yaro · banyaro.app
""" plain = f"Ban Yaro — Moderation überfällig ({now_str})\n\nSeit >{SLA_H}h offen:\n" + \ "\n".join(f" • {l}: {c}" for l, c in overdue.items()) + \ "\n\nhttps://banyaro.app/app/admin" try: await send_email(admin, f"⚠️ Ban Yaro — Moderation überfällig ({', '.join(overdue)})", html, plain) logger.info(f"Moderation-Overdue-Mail gesendet: {overdue}") except Exception as e: logger.error(f"Moderation-Overdue-Mail fehlgeschlagen: {e}") def _action_items_html(metrics: dict) -> str: items = [ ("jobs_pending", "Bewerbungen offen"), ("breeder_pending", "Züchter-Anträge"), ("reports_open", "Forum-Meldungen"), ("fotos_pending", "Foto-Einreichungen"), ("poi_edits_pending", "POI-Korrekturen"), ] open_items = [(label, metrics.get(key, 0)) for key, label in items if metrics.get(key, 0) > 0] if not open_items: body = '✅ Alles erledigt — nichts offen' else: pills = "".join( f'' f'{label} {count}' for label, count in open_items ) body = f'
⚠️ {len(open_items)} Punkt{"e" if len(open_items)!=1 else ""} brauchen deine Aufmerksamkeit
{pills}' link = '
→ Admin-Panel öffnen
' return f'
' \ f'
Heute zu erledigen
' \ f'{body}{link}
' # JOB: Status-Report per Mail (täglich 06:00 Uhr) # ------------------------------------------------------------------ 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["users_today"] = conn.execute("SELECT COUNT(*) FROM users WHERE DATE(created_at)=DATE('now')").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 # Action Items try: metrics["jobs_pending"] = conn.execute("SELECT COUNT(*) FROM job_applications WHERE status IN ('pending','reviewing')").fetchone()[0] except Exception: metrics["jobs_pending"] = 0 try: metrics["breeder_pending"] = conn.execute("SELECT COUNT(*) FROM users WHERE breeder_status='pending'").fetchone()[0] except Exception: metrics["breeder_pending"] = 0 try: metrics["reports_open"] = conn.execute("SELECT COUNT(*) FROM forum_reports WHERE resolved=0").fetchone()[0] except Exception: metrics["reports_open"] = 0 try: metrics["fotos_pending"] = conn.execute("SELECT COUNT(*) FROM wiki_foto_submissions WHERE status='pending'").fetchone()[0] except Exception: metrics["fotos_pending"] = 0 try: metrics["poi_edits_pending"] = conn.execute("SELECT COUNT(*) FROM osm_poi_edits WHERE status='pending'").fetchone()[0] except Exception: metrics["poi_edits_pending"] = 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": "Rassen-Seed (TheDogAPI, monatlich)", "seed_wikidata": "Rassen-Seed (Wikidata, monatlich)", "weekly_praise": "Wöchentlicher Lober (Mo 09:00)", "ki_health_report": "KI-Gesundheitsberichte", "quarterly_report": "Quartalsbericht (1. Feb/Mai/Aug/Nov)", "streak_reminder": "Streak-Erinnerung (täglich 19:00)", "recall_check": "Tierfutter-Rückrufe (RASFF, täglich 08:00)", "golden_gassi_hour": "Goldene Gassi-Stunde (täglich 07:00)", "anniversary_reminders": "Jahrestags-Erinnerungen (täglich 09:00)", "monthly_recap": "Monatlicher Rückblick (1. des Monats 10: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'{label}{ts}{status} {result}' job_rows_txt += f" {status} {label}: {ts} — {result}\n" else: job_rows_html += f'{label}— noch nicht gelaufen' job_rows_txt += f" — {label}: noch nicht gelaufen\n" html = f"""\
🐾 Ban Yaro — Status-Report
{now_str} Uhr
{_action_items_html(metrics)}
Scheduler-Jobs
{job_rows_html}
Community
{"".join(f'
{v}
{k}
' for k,v in [ ("Nutzer gesamt",metrics["users"]), ("Neue Nutzer heute",metrics["users_today"]), ("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"]), ])}
Ban Yaro · banyaro.app · Nächster Report in ~6h
""" action_open = [l for k,l in [ ("jobs_pending","Bewerbungen"),("breeder_pending","Züchter-Anträge"), ("reports_open","Meldungen"),("fotos_pending","Fotos"),("poi_edits_pending","POI-Korrekturen"), ] if metrics.get(k,0) > 0] plain = f"""Ban Yaro Status-Report — {now_str} === HEUTE ZU ERLEDIGEN === {"✅ Alles erledigt" if not action_open else "⚠️ " + ", ".join(f"{l} ({metrics[k]})" for k,l in [ ("jobs_pending","Bewerbungen"),("breeder_pending","Züchter-Anträge"), ("reports_open","Meldungen"),("fotos_pending","Fotos"),("poi_edits_pending","POI-Korrekturen"), ] if metrics.get(k,0) > 0)} === Scheduler-Jobs === {job_rows_txt} === Community === Nutzer gesamt: {metrics['users']} (+{metrics['users_today']} heute) 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']} """ 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}") async def _job_quarterly_report(): """Quartalsbericht: alle Report-Sections per Mail an ADMIN_EMAIL.""" import os, sys from mailer import send_email, email_html admin = os.getenv("ADMIN_EMAIL", "") if not admin: logger.info("Quartalsbericht: ADMIN_EMAIL nicht gesetzt, übersprungen.") _log_job("quarterly_report", "ok", "ADMIN_EMAIL nicht gesetzt") return now_str = datetime.now(tz=_TZ).strftime("%d.%m.%Y") quarter = (datetime.now(tz=_TZ).month - 1) // 3 + 1 try: # Report-Script importieren und alle Sections aufrufen sys.path.insert(0, "/app/scripts") import importlib, generate_reports as gr importlib.reload(gr) # sicherstellen dass aktuelle Version sections = [ ("Sicherheit", gr.report_sicherheit), ("Funktionsumfang", gr.report_funktionsumfang), ("Dateien", gr.report_dateien), ("Nutzerübersicht", gr.report_nutzer), ("Partnerliste", gr.report_partner), ("Server & Speicher", gr.report_server), ] def md_to_html_simple(text: str) -> str: """Minimale Markdown→HTML-Konvertierung für E-Mail.""" import html as _h lines_out = [] in_code = False in_table = False for line in text.split("\n"): if line.startswith("```"): if in_code: lines_out.append("") in_code = False else: lines_out.append('
')
                        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'' for c in cells) lines_out.append(f"{row_html}") continue elif line.startswith("- ") or line.startswith("* "): if in_table: lines_out.append("
{_h.escape(c)}
") 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'
    ' f'

    {title}

    ' f'{md_to_html_simple(md)}' f'
    ' ) plain_parts.append(f"\n=== {title.upper()} ===\n{md}\n") except Exception as e: 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): "🎂 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 # ------------------------------------------------------------------ # JOB: Hund des Monats — Sieger des Vormonats festlegen # ------------------------------------------------------------------ async def _job_hdm_winner(): """Läuft am 1. des Monats 00:05 und schreibt den Sieger des Vormonats.""" today = datetime.now(tz=_TZ) # Vormonat berechnen first_this = today.replace(day=1) last_month = (first_this - timedelta(days=1)).replace(day=1) monat = last_month.strftime("%Y-%m") with db() as conn: # Schon eingetragen? existing = conn.execute( "SELECT id FROM hund_des_monats_wins WHERE monat=?", (monat,) ).fetchone() if existing: logger.info(f"HdM-Winner {monat}: bereits eingetragen, übersprungen.") _log_job("hdm_winner", "ok", f"bereits vorhanden für {monat}") return winner = conn.execute(""" SELECT v.dog_id, d.name, d.user_id, COUNT(v.id) AS stimmen FROM hund_des_monats_votes v JOIN dogs d ON d.id = v.dog_id WHERE v.monat = ? GROUP BY v.dog_id ORDER BY stimmen DESC LIMIT 1 """, (monat,)).fetchone() if not winner: logger.info(f"HdM-Winner {monat}: keine Stimmen, kein Sieger.") _log_job("hdm_winner", "ok", f"keine Stimmen für {monat}") return conn.execute( "INSERT OR IGNORE INTO hund_des_monats_wins (dog_id, monat, stimmen) VALUES (?, ?, ?)", (winner["dog_id"], monat, winner["stimmen"]), ) month_label = last_month.strftime("%B %Y") send_push_to_user(winner["user_id"], { "type": "hdm_winner", "title": f"🏆 {winner['name']} ist Hund des Monats!", "body": f"{winner['name']} hat den {month_label} gewonnen — herzlichen Glückwunsch!", "data": {"page": "forum"}, "tag": f"hdm-{monat}", }) logger.info(f"HdM-Winner {monat}: Hund {winner['dog_id']} ('{winner['name']}', {winner['stimmen']} Stimmen) eingetragen.") _log_job("hdm_winner", "ok", f"{monat}: {winner['name']} ({winner['stimmen']} Stimmen)") # ------------------------------------------------------------------ # JOB: Streak-Erinnerung (täglich 19:00) # ------------------------------------------------------------------ async def _job_streak_reminder(): """ Findet alle User die heute noch nicht trainiert haben (last_training_date < heute) und deren current_streak > 0. Sendet einen motivierenden Push pro Hund. """ today = str(date.today()) logger.info(f"Streak-Reminder Job läuft für {today}") with db() as conn: rows = conn.execute(""" SELECT ts.user_id, ts.dog_id, ts.current_streak, d.name AS dog_name FROM training_streaks ts JOIN dogs d ON d.id = ts.dog_id WHERE ts.current_streak > 0 AND (ts.last_training_date IS NULL OR ts.last_training_date < ?) """, (today,)).fetchall() sent_total = 0 for r in rows: n = r["current_streak"] sent = send_push_to_user(r["user_id"], { "type": "streak_reminder", "title": f"🔥 {r['dog_name']} wartet auf sein Training!", "body": f"Streak: {n} {'Tag' if n == 1 else 'Tage'} — nicht jetzt aufhören.", "data": {"page": "uebungen"}, "tag": f"streak-{r['dog_id']}-{today}", }) sent_total += sent logger.info(f"Streak-Reminder Job fertig — {len(rows)} Hunde geprüft, {sent_total} Push gesendet.") _log_job("streak_reminder", "ok", f"{sent_total} Push an {len(rows)} Hunde") # ------------------------------------------------------------------ # JOB: Tierfutter-Rückrufe prüfen (RASFF, täglich 08:00) # ------------------------------------------------------------------ async def _job_recall_check(): """ Fragt täglich die RASFF EU-API nach neuen Tierfutter-Rückrufen ab. Neue Einträge werden in DB gespeichert, für jeden wird ein Push an alle abonnierten User gesendet. """ logger.info("Rückruf-Check Job läuft") try: from routes.recalls import fetch_rasff_recalls, save_new_recalls entries = await fetch_rasff_recalls() if not entries: logger.info("Rückruf-Check: Keine Einträge von RASFF erhalten (API-Fehler oder leer).") _log_job("recall_check", "ok", "0 neue Rückrufe (API leer)") return new_entries = save_new_recalls(entries) logger.info(f"Rückruf-Check: {len(new_entries)} neue von {len(entries)} geprüften Einträgen.") for entry in new_entries: produkt = entry.get("produkt") or entry.get("titel") or "Unbekanntes Produkt" gefahr = entry.get("gefahr") or "Bitte Produktdetails prüfen" ext_id = entry["external_id"] body = f"{produkt} — {gefahr[:80]}" send_push_to_all({ "title": "⚠️ Tierfutter-Rückruf", "body": body, "data": {"page": "recalls"}, "tag": f"recall-{ext_id}", }) logger.info(f"Rückruf-Push gesendet: {ext_id} — {produkt}") _log_job("recall_check", "ok", f"{len(new_entries)} neue Rückrufe") except Exception as e: logger.error(f"Rückruf-Check: unerwarteter Fehler: {e}") _log_job("recall_check", "error", str(e)) # ------------------------------------------------------------------ # JOB: Wiederkehrende Ausgaben anlegen # ------------------------------------------------------------------ async def _job_recurring_expenses(): try: from routes.expenses import process_due_recurring count = process_due_recurring() logger.info(f"Daueraufträge: {count} Einträge angelegt.") _log_job("recurring_expenses", "ok", f"{count} Einträge") except Exception as e: logger.error(f"Daueraufträge-Job Fehler: {e}") _log_job("recurring_expenses", "error", str(e)) # ------------------------------------------------------------------ # JOB: Goldene Gassi-Stunde (täglich 07:00 Uhr) # ------------------------------------------------------------------ async def _job_golden_gassi_hour(): """ Berechnet für jeden User mit aktivierter Einstellung (gassi_stunde_push=1) das beste 2h-Wetterfenster des Tages und schickt eine Push-Notification. Score-Logik pro Stunde (max. 10 Punkte): - Temperatur 10–20°C → +3 - Temperatur 5–10°C → +1 - Niederschlagswahrsch. <20% → +3, <40% → +1 - Windgeschwindigkeit <20 km/h → +2, <30 km/h → +1 - Stunden 07–19 Uhr (Tageslicht) → +2 Bestes fortlaufendes 2h-Fenster (Summe zweier aufeinanderfolgender Stunden). """ import httpx from datetime import date as _date logger.info("Goldene-Gassi-Stunde Job läuft") # Alle User mit aktivierter Einstellung + mindestens einer Push-Subscription with db() as conn: users = conn.execute(""" SELECT DISTINCT u.id AS user_id, ps.last_lat, ps.last_lon FROM users u JOIN push_subscriptions ps ON ps.user_id = u.id WHERE u.gassi_stunde_push = 1 """).fetchall() users = [dict(u) for u in users] logger.info(f"Goldene-Gassi-Stunde: {len(users)} User mit aktivierter Einstellung.") if not users: _log_job("golden_gassi_hour", "ok", "0 User mit Einstellung aktiv") return sent_total = 0 for u in users: lat = u["last_lat"] or 48.1351 # Fallback: München lon = u["last_lon"] or 11.5820 try: hourly = await _fetch_hourly_weather(lat, lon) except Exception as e: logger.warning(f"Goldene-Gassi-Stunde: Wetter-Fehler für user {u['user_id']}: {e}") continue if not hourly: continue best_start, best_score, best_temp, best_wind = _find_best_gassi_window(hourly) if best_score < 3: # Heute kein gutes Wetterfenster → kein Push logger.info(f"Goldene-Gassi-Stunde: user {u['user_id']} — kein gutes Fenster (score={best_score})") continue hour_end = (best_start + 2) % 24 temp_str = f"{best_temp:.0f}°C" if best_temp is not None else "–" wind_str = "Kaum Wind" if (best_wind is not None and best_wind < 20) else ( f"{best_wind:.0f} km/h Wind" if best_wind is not None else "") body_parts = [f"Bestes Wetter zwischen {best_start:02d}:00–{hour_end:02d}:00 Uhr", f"· {temp_str}"] if wind_str: body_parts.append(f"· {wind_str}") sent = send_push_to_user(u["user_id"], { "type": "golden_gassi_hour", "title": "☀️ Goldene Gassi-Stunde heute!", "body": " ".join(body_parts), "data": {"page": "wetter"}, "tag": f"gassi-{_date.today()}", }) sent_total += sent logger.info(f"Goldene-Gassi-Stunde: user {u['user_id']} → {best_start:02d}:00 (score={best_score}, {temp_str}) — Push: {sent}") logger.info(f"Goldene-Gassi-Stunde Job fertig — {len(users)} User, {sent_total} Push gesendet.") _log_job("golden_gassi_hour", "ok", f"{sent_total} Push an {len(users)} User") # ------------------------------------------------------------------ # JOB: Jahrestags-Erinnerungen (täglich 09:00) # ------------------------------------------------------------------ async def _job_anniversary_reminders(): """Prüft ob heute ein Jahrestag für diary-Einträge vorliegt und sendet Push.""" today = datetime.now(tz=_TZ) today_md = today.strftime('%m-%d') # Monat-Tag ohne Jahr logger.info(f"Jahrestags-Erinnerungen Job läuft für {today_md}") with db() as conn: entries = conn.execute(""" SELECT d.id, d.titel, d.datum, d.user_id, d.dog_id, (SELECT dm.url FROM diary_media dm WHERE dm.diary_id=d.id LIMIT 1) AS foto_url FROM diary d WHERE strftime('%m-%d', d.datum) = ? AND d.datum < date('now') AND d.titel IS NOT NULL AND d.is_milestone = 0 """, (today_md,)).fetchall() sent_total = 0 for e in entries: try: jahre = today.year - int(e['datum'][:4]) if jahre < 1: continue jahre_label = f"{jahre} Jahr" if jahre == 1 else f"{jahre} Jahren" send_push_to_user(e['user_id'], { 'type': 'anniversary_reminder', 'title': f'📅 Vor {jahre_label}: {(e["titel"] or "")[:40]}', 'body': 'Erinnerung an diesen besonderen Tag mit deinem Hund', 'data': {'page': 'diary'}, 'tag': f'anniversary-{e["id"]}-{today.year}', }) sent_total += 1 except Exception as ex: logger.warning(f"Jahrestag-Reminder: Fehler für Eintrag {e['id']}: {ex}") logger.info(f"Jahrestags-Erinnerungen Job fertig — {len(entries)} Einträge geprüft, {sent_total} Push gesendet.") _log_job("anniversary_reminders", "ok", f"{sent_total} Push von {len(entries)} Einträgen") # ------------------------------------------------------------------ # JOB: Monatlicher Rückblick (1. des Monats 10:00) # ------------------------------------------------------------------ async def _job_monthly_recap(): """Sendet jedem User am 1. des Monats einen Rückblick des Vormonats.""" today = datetime.now(tz=_TZ) first_this = today.replace(day=1) last_month_end = first_this - timedelta(days=1) last_month_start = last_month_end.replace(day=1) year_str = last_month_start.strftime('%Y') month_str = last_month_start.strftime('%m') month_label = last_month_start.strftime('%B %Y') logger.info(f"Monatlicher Rückblick Job läuft für {month_label}") with db() as conn: # Alle User mit mindestens einem Hund users = conn.execute( "SELECT DISTINCT user_id FROM dogs" ).fetchall() sent_total = 0 for u in users: user_id = u["user_id"] try: with db() as conn: # Hunde des Users dog_rows = conn.execute( "SELECT id, name FROM dogs WHERE user_id=?", (user_id,) ).fetchall() if not dog_rows: continue dog_ids = [d["id"] for d in dog_rows] placeholders = ','.join('?' * len(dog_ids)) # km (Routen des Users im Vormonat) km_row = conn.execute( "SELECT ROUND(COALESCE(SUM(distanz_km),0),1) AS km FROM routes " "WHERE user_id=? AND strftime('%Y',created_at)=? AND strftime('%m',created_at)=?", (user_id, year_str, month_str) ).fetchone() gesamt_km = km_row["km"] or 0.0 # Tagebucheinträge eintraege = conn.execute( f"SELECT COUNT(*) AS n FROM diary " f"WHERE dog_id IN ({placeholders}) AND strftime('%Y',datum)=? AND strftime('%m',datum)=?", (*dog_ids, year_str, month_str) ).fetchone()["n"] # Training-Sessions training = conn.execute( f"SELECT COUNT(*) AS n FROM training_sessions " f"WHERE dog_id IN ({placeholders}) AND strftime('%Y',created_at)=? AND strftime('%m',created_at)=?", (*dog_ids, year_str, month_str) ).fetchone()["n"] # Lieblingsfoto (erstes Foto im Vormonat) foto_row = conn.execute( f"SELECT dm.url FROM diary_media dm " f"JOIN diary d ON d.id=dm.diary_id " f"WHERE d.dog_id IN ({placeholders}) AND dm.media_type='image' " f"AND strftime('%Y',d.datum)=? AND strftime('%m',d.datum)=? " f"ORDER BY d.datum ASC LIMIT 1", (*dog_ids, year_str, month_str) ).fetchone() foto_url = foto_row["url"] if foto_row else None # Nur senden wenn mindestens eine Aktivität vorhanden if eintraege == 0 and training == 0 and gesamt_km == 0: continue dog_name = dog_rows[0]["name"] parts = [] if gesamt_km > 0: parts.append(f"{gesamt_km} km gelaufen") if eintraege > 0: parts.append(f"{eintraege} Tagebucheintr{'ä' if True else 'a'}ge") if training > 0: parts.append(f"{training} Training-Sessions") body_text = " · ".join(parts) send_push_to_user(user_id, { 'type': 'monthly_recap', 'title': f'📅 {month_label}: Rückblick für {dog_name}', 'body': body_text, 'data': {'page': 'diary'}, 'tag': f'monthly-recap-{year_str}-{month_str}', }) sent_total += 1 except Exception as ex: logger.error(f"Monatlicher Rückblick: Fehler für user {user_id}: {ex}") logger.info(f"Monatlicher Rückblick Job fertig — {len(users)} User geprüft, {sent_total} Push gesendet.") _log_job("monthly_recap", "ok", f"{sent_total} Push für {month_label}") async def _job_new_foto_challenge(): """Jeden Montag 08:00 — neue Foto-Challenge für die aktuelle Woche anlegen.""" from datetime import date, timedelta from routes.challenges import _CHALLENGE_THEMEN, _current_week_monday, _current_week_sunday monday = _current_week_monday() sunday = _current_week_sunday() week_num = date.today().isocalendar()[1] thema = _CHALLENGE_THEMEN[week_num % len(_CHALLENGE_THEMEN)] with db() as conn: existing = conn.execute( "SELECT id FROM foto_challenge WHERE start_date = ?", (monday,) ).fetchone() if existing: logger.info(f"Foto-Challenge: Woche {monday} bereits vorhanden (id={existing['id']}).") _log_job("new_foto_challenge", "ok", f"Bereits vorhanden für {monday}") return cur = conn.execute( "INSERT INTO foto_challenge (thema, beschreibung, start_date, end_date, created_by) " "VALUES (?, ?, ?, ?, NULL)", (thema, f"Diese Woche: {thema}", monday, sunday) ) challenge_id = cur.lastrowid # Push an alle User send_push_to_all({ "type": "foto_challenge", "title": "📸 Neue Foto-Challenge!", "body": f"Diese Woche: {thema} — mach mit!", "data": {"page": "walks", "tab": "challenge"}, "tag": f"challenge-{monday}", }) logger.info(f"Foto-Challenge angelegt: '{thema}' für {monday}–{sunday} (id={challenge_id}).") _log_job("new_foto_challenge", "ok", f"'{thema}' für {monday}") async def _fetch_hourly_weather(lat: float, lon: float) -> list[dict]: """Holt stündliche Wetterdaten für heute von Open-Meteo.""" import httpx from datetime import date as _date today = _date.today().isoformat() url = ( "https://api.open-meteo.com/v1/forecast" f"?latitude={lat}&longitude={lon}" "&hourly=temperature_2m,precipitation_probability,windspeed_10m" "&timezone=Europe%2FBerlin&forecast_days=1" ) async with httpx.AsyncClient(timeout=8.0) as client: resp = await client.get(url) resp.raise_for_status() raw = resp.json() hourly = raw.get("hourly", {}) times = hourly.get("time", []) temps = hourly.get("temperature_2m", []) precips = hourly.get("precipitation_probability", []) winds = hourly.get("windspeed_10m", []) result = [] for i, ts in enumerate(times): if not ts.startswith(today): continue hour = int(ts[11:13]) result.append({ "hour": hour, "temp": temps[i] if i < len(temps) else None, "precip": precips[i] if i < len(precips) else None, "wind": winds[i] if i < len(winds) else None, }) return result def _score_hour(h: dict) -> int: """Berechnet Gassi-Score für eine einzelne Stunde (0–10 Punkte).""" score = 0 temp = h.get("temp") precip = h.get("precip") wind = h.get("wind") hour = h.get("hour", 12) # Temperatur if temp is not None: if 10 <= temp <= 20: score += 3 elif 5 <= temp < 10 or 20 < temp <= 25: score += 1 # Niederschlagswahrscheinlichkeit if precip is not None: if precip < 20: score += 3 elif precip < 40: score += 1 # Wind if wind is not None: if wind < 20: score += 2 elif wind < 30: score += 1 # Tageslicht (07–19 Uhr) if 7 <= hour <= 19: score += 2 return score def _find_best_gassi_window(hourly: list[dict]) -> tuple[int, int, float | None, float | None]: """ Findet das beste aufeinanderfolgende 2h-Fenster. Gibt (start_hour, total_score, avg_temp, avg_wind) zurück. """ best_start = 8 best_score = -1 best_temp = None best_wind = None for i in range(len(hourly) - 1): h1 = hourly[i] h2 = hourly[i + 1] combined = _score_hour(h1) + _score_hour(h2) if combined > best_score: best_score = combined best_start = h1["hour"] # Durchschnittswerte für Anzeige temps = [x for x in [h1.get("temp"), h2.get("temp")] if x is not None] winds = [x for x in [h1.get("wind"), h2.get("wind")] if x is not None] best_temp = sum(temps) / len(temps) if temps else None best_wind = sum(winds) / len(winds) if winds else None return best_start, best_score, best_temp, best_wind # ------------------------------------------------------------------ # JOB: Error-Digest (täglich 06:30 Uhr) # ------------------------------------------------------------------ # Quellen fuer Fehler: # 1. _job_log dieser Datei — Status der einzelnen Scheduler-Jobs # 2. main.log_buffer — In-Memory-Buffer der letzten 500 Log-Zeilen # Limitierung: log_buffer ist nicht persistent. Ein vollstaendiger 24h-Digest # wuerde eine externe Log-Senke (Datei oder DB) brauchen. Bis dahin liefert # der Job das was im Memory verfuegbar ist plus alle Scheduler-Errors. # ------------------------------------------------------------------ async def _job_error_digest(): """Schickt eine Zusammenfassung aller bekannten ERROR/EXCEPTION-Eintraege der letzten 24h an ADMIN_EMAIL.""" import os import html as _html from collections import Counter from mailer import send_email, email_html admin = os.getenv("ADMIN_EMAIL", "") if not admin: logger.info("Error-Digest: ADMIN_EMAIL nicht gesetzt, uebersprungen.") _log_job("error_digest", "ok", "ADMIN_EMAIL nicht gesetzt") return now = datetime.now(tz=_TZ) cutoff = now - timedelta(hours=24) # ── 1. Scheduler-Job-Errors einsammeln ─────────────────────── scheduler_errors = [] for jid, log in _job_log.items(): last_run = log.get("last_run") if last_run and last_run >= cutoff and log.get("status") == "error": scheduler_errors.append({ "job": jid, "ts": last_run.strftime("%d.%m. %H:%M"), "result": log.get("result", ""), }) # ── 2. In-Memory-Log-Buffer einsammeln (best-effort) ───────── log_errors = [] try: from main import log_buffer # type: ignore for entry in list(log_buffer): lvl = entry.get("l", "") if lvl in ("ERROR", "CRITICAL", "EXCEPTION"): log_errors.append({ "ts": entry.get("t", ""), "lvl": lvl, "name": entry.get("n", ""), "msg": entry.get("m", ""), }) except Exception as e: logger.debug(f"Error-Digest: log_buffer nicht verfuegbar: {e}") # Gruppieren: Fehler-Meldungen mit gleichem msg-Prefix zusammenfassen grouped: Counter = Counter() for e in log_errors: # Erste 80 Zeichen als Schluessel — schluckt Variablenwerte am Ende key = (e["name"], e["msg"][:80]) grouped[key] += 1 # ── Wenn nichts zu melden ist: leise raus ──────────────────── if not scheduler_errors and not grouped: logger.info("Error-Digest: Keine Errors in den letzten 24h.") _log_job("error_digest", "ok", "0 Errors") return # ── HTML-Body bauen ────────────────────────────────────────── parts = [] parts.append(f'

    Fehler-Zusammenfassung der letzten 24h ({now.strftime("%d.%m.%Y %H:%M")}).

    ') if scheduler_errors: rows_html = "".join( f'' f'{_html.escape(e["job"])}' f'{e["ts"]}' f'{_html.escape(e["result"])}' f'' for e in scheduler_errors ) parts.append( '

    Scheduler-Job-Fehler ({0})

    '.format(len(scheduler_errors)) + f'{rows_html}
    ' ) if grouped: sorted_groups = sorted(grouped.items(), key=lambda kv: -kv[1]) rows_html = "".join( f'' f'{count}x' f'{_html.escape(name)}' f'{_html.escape(msg)}' f'' for (name, msg), count in sorted_groups[:30] ) parts.append( '

    In-Memory-Log-Errors ({0} Typen, {1} Eintraege)

    '.format(len(grouped), sum(grouped.values())) + f'{rows_html}
    ' ) parts.append('

    Quellen: scheduler._job_log (24h) + main.log_buffer (Memory, max. 500 Eintraege). Fuer vollstaendige 24h-Historie waere eine persistente Log-Senke noetig.

    ') body = "\n".join(parts) html = email_html(body, footer_text="Ban Yaro · Error-Digest") plain_lines = [f"Ban Yaro Error-Digest — {now.strftime('%d.%m.%Y %H:%M')}", ""] if scheduler_errors: plain_lines.append("Scheduler-Job-Fehler:") for e in scheduler_errors: plain_lines.append(f" - {e['ts']} {e['job']}: {e['result']}") plain_lines.append("") if grouped: plain_lines.append("Log-Errors (Top 30 Typen, gruppiert):") for (name, msg), count in sorted(grouped.items(), key=lambda kv: -kv[1])[:30]: plain_lines.append(f" {count}x [{name}] {msg}") plain = "\n".join(plain_lines) try: await send_email( admin, f"Ban Yaro Error-Digest ({len(scheduler_errors)} Scheduler + {len(grouped)} Log-Errors)", html, plain, ) logger.info(f"Error-Digest gesendet an {admin} — {len(scheduler_errors)} scheduler, {len(grouped)} log-types.") _log_job("error_digest", "ok", f"{len(scheduler_errors)} scheduler / {len(grouped)} log-types") except Exception as e: logger.error(f"Error-Digest: Mail-Fehler: {e}") _log_job("error_digest", "error", str(e))