- Filme-Seite: Suchfeld (filtert live nach Titel, Rasse, Genre, Beschreibung)
- Filme-Seite: Tab "Hund des Monats" entfernt
- Forum: kompakte HdM-Kachel über der Suche (Sieger + Stimmen), Klick öffnet Abstimmungs-Modal
- Hundeprofil: goldene Badges für jeden gewonnenen Monat (🏆 Mai 2026 …)
- DB: Tabelle hund_des_monats_wins (dauerhaft, dog_id + monat + stimmen)
- Scheduler: Job am 1. des Monats 00:05 — schreibt Vormonats-Sieger, Push an Besitzer
- Dogs-API: liefert hdm_wins[] pro Hund mit
1174 lines
50 KiB
Python
1174 lines
50 KiB
Python
"""
|
|
BAN YARO — Hintergrund-Scheduler
|
|
Täglich: Gesundheits-Erinnerungen per Push versenden.
|
|
"""
|
|
|
|
import logging
|
|
from datetime import date, datetime, timedelta
|
|
from zoneinfo import ZoneInfo
|
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|
from apscheduler.triggers.cron import CronTrigger
|
|
|
|
_TZ = ZoneInfo("Europe/Berlin")
|
|
|
|
from database import db
|
|
from routes.push import send_push_to_user, send_push_to_all
|
|
import weather
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_scheduler = AsyncIOScheduler(timezone="Europe/Berlin")
|
|
|
|
# In-Memory Job-Protokoll: {job_id: {"last_run": datetime, "result": str, "status": "ok"|"error"}}
|
|
_job_log: dict = {}
|
|
|
|
|
|
def start():
|
|
_scheduler.add_job(
|
|
_job_health_reminders,
|
|
CronTrigger(hour=8, minute=0), # täglich 08:00 Uhr
|
|
id="health_reminders",
|
|
replace_existing=True,
|
|
misfire_grace_time=3600,
|
|
)
|
|
_scheduler.add_job(
|
|
_job_poison_archive,
|
|
CronTrigger(hour=3, minute=0), # täglich 03:00 Uhr (ruhige Zeit)
|
|
id="poison_archive",
|
|
replace_existing=True,
|
|
misfire_grace_time=3600,
|
|
)
|
|
_scheduler.add_job(
|
|
_job_weather_alert,
|
|
CronTrigger(hour=7, minute=30), # täglich 07:30 Uhr
|
|
id="weather_alert",
|
|
replace_existing=True,
|
|
misfire_grace_time=3600,
|
|
)
|
|
_scheduler.add_job(
|
|
_job_milestone_check,
|
|
CronTrigger(hour=0, minute=5), # täglich 00:05 Uhr
|
|
id="milestone_check",
|
|
replace_existing=True,
|
|
misfire_grace_time=3600,
|
|
)
|
|
_scheduler.add_job(
|
|
_job_import_events,
|
|
CronTrigger(day_of_week='sun', hour=2), # jeden Sonntag 02:00 Uhr
|
|
id="import_events",
|
|
replace_existing=True,
|
|
misfire_grace_time=7200,
|
|
)
|
|
|
|
# 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,
|
|
)
|
|
# 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,
|
|
)
|
|
_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. 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'<tr><td style="padding:6px 12px;font-weight:600;color:#c45000">{label}</td>'
|
|
f'<td style="padding:6px 12px;font-size:18px;font-weight:800;color:#c45000">{count}</td></tr>'
|
|
for label, count in overdue.items()
|
|
)
|
|
html = f"""\
|
|
<!DOCTYPE html><html lang="de"><head><meta charset="utf-8"></head>
|
|
<body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f5f0ea;margin:0;padding:0">
|
|
<div style="max-width:560px;margin:24px auto;background:#fff;border-radius:14px;overflow:hidden;box-shadow:0 2px 16px rgba(0,0,0,.08)">
|
|
<div style="background:linear-gradient(135deg,#c45000,#e8733a);padding:22px 28px;color:#fff">
|
|
<div style="font-size:20px;font-weight:800;margin-bottom:2px">⚠️ Moderation überfällig</div>
|
|
<div style="opacity:.88;font-size:13px">{now_str} · SLA: {SLA_H}h</div>
|
|
</div>
|
|
<div style="padding:22px 28px">
|
|
<p style="color:#444;margin:0 0 16px">Folgende Einträge warten seit mehr als {SLA_H} Stunden auf Bearbeitung:</p>
|
|
<table style="width:100%;border-collapse:collapse;font-size:14px">
|
|
<thead><tr style="border-bottom:2px solid #f0e8dc">
|
|
<th style="text-align:left;padding:6px 12px;color:#888;font-size:11px;text-transform:uppercase;letter-spacing:.06em">Bereich</th>
|
|
<th style="text-align:left;padding:6px 12px;color:#888;font-size:11px;text-transform:uppercase;letter-spacing:.06em">Anzahl</th>
|
|
</tr></thead>
|
|
<tbody>{rows_html}</tbody>
|
|
</table>
|
|
<div style="margin-top:20px">
|
|
<a href="https://banyaro.app/app/admin" style="display:inline-block;background:#c45000;color:#fff;
|
|
text-decoration:none;padding:10px 22px;border-radius:8px;font-weight:700;font-size:14px">
|
|
→ Admin-Panel öffnen
|
|
</a>
|
|
</div>
|
|
</div>
|
|
<div style="padding:12px 28px;background:#fdf6ef;font-size:11px;color:#aaa;text-align:center">
|
|
Ban Yaro · banyaro.app
|
|
</div>
|
|
</div></body></html>"""
|
|
|
|
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 = '<span style="color:#16a34a;font-weight:700">✅ Alles erledigt — nichts offen</span>'
|
|
else:
|
|
pills = "".join(
|
|
f'<span style="display:inline-block;background:#fff3e0;color:#c45000;border:1px solid #e8a857;'
|
|
f'border-radius:999px;padding:3px 12px;font-size:12px;font-weight:700;margin:2px 4px 2px 0">'
|
|
f'{label} <strong style="background:#c45000;color:#fff;border-radius:999px;'
|
|
f'padding:0 7px;margin-left:4px">{count}</strong></span>'
|
|
for label, count in open_items
|
|
)
|
|
body = f'<div style="font-size:13px;font-weight:600;color:#c45000;margin-bottom:8px">⚠️ {len(open_items)} Punkt{"e" if len(open_items)!=1 else ""} brauchen deine Aufmerksamkeit</div>{pills}'
|
|
|
|
link = '<div style="margin-top:10px"><a href="https://banyaro.app/app/admin" style="font-size:12px;color:#C4843A">→ Admin-Panel öffnen</a></div>'
|
|
return f'<div style="padding:20px 28px;border-bottom:2px solid #e8a857;background:#fffbf5">' \
|
|
f'<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#C4843A;margin-bottom:10px">Heute zu erledigen</div>' \
|
|
f'{body}{link}</div>'
|
|
|
|
|
|
# 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)",
|
|
}
|
|
job_rows_html = ""
|
|
job_rows_txt = ""
|
|
for jid, label in job_labels.items():
|
|
log = _job_log.get(jid)
|
|
if log:
|
|
ts = log["last_run"].strftime("%d.%m. %H:%M")
|
|
status = "✅" if log["status"] == "ok" else "❌"
|
|
result = log["result"]
|
|
color = "#16a34a" if log["status"] == "ok" else "#dc2626"
|
|
job_rows_html += f'<tr><td style="padding:5px 10px;color:#555">{label}</td><td style="padding:5px 10px;font-family:monospace;font-size:12px">{ts}</td><td style="padding:5px 10px;color:{color}">{status} {result}</td></tr>'
|
|
job_rows_txt += f" {status} {label}: {ts} — {result}\n"
|
|
else:
|
|
job_rows_html += f'<tr><td style="padding:5px 10px;color:#555">{label}</td><td style="padding:5px 10px;color:#aaa" colspan="2">— noch nicht gelaufen</td></tr>'
|
|
job_rows_txt += f" — {label}: noch nicht gelaufen\n"
|
|
|
|
html = f"""\
|
|
<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head><meta charset="utf-8"></head>
|
|
<body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f5f0ea;margin:0;padding:0">
|
|
<div style="max-width:600px;margin:24px auto;background:#fff;border-radius:14px;overflow:hidden;box-shadow:0 2px 16px rgba(0,0,0,.08)">
|
|
|
|
<!-- Header -->
|
|
<div style="background:linear-gradient(135deg,#C4843A,#e8a857);padding:24px 28px;color:#fff">
|
|
<div style="font-size:22px;font-weight:800;margin-bottom:2px">🐾 Ban Yaro — Status-Report</div>
|
|
<div style="opacity:.88;font-size:13px">{now_str} Uhr</div>
|
|
</div>
|
|
|
|
<!-- Action Items -->
|
|
{_action_items_html(metrics)}
|
|
|
|
<!-- Scheduler-Status -->
|
|
<div style="padding:20px 28px;border-bottom:1px solid #f0e8dc">
|
|
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#C4843A;margin-bottom:10px">Scheduler-Jobs</div>
|
|
<table style="width:100%;border-collapse:collapse;font-size:13px">
|
|
{job_rows_html}
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Community-Metriken -->
|
|
<div style="padding:20px 28px;border-bottom:1px solid #f0e8dc">
|
|
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#C4843A;margin-bottom:10px">Community</div>
|
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
|
|
{"".join(f'<div style="background:#fdf6ef;border-radius:8px;padding:10px 14px"><div style="font-size:20px;font-weight:800;color:#C4843A">{v}</div><div style="font-size:11px;color:#888">{k}</div></div>' for k,v in [
|
|
("Nutzer 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"]),
|
|
])}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Footer -->
|
|
<div style="padding:14px 28px;background:#fdf6ef;font-size:11px;color:#aaa;text-align:center">
|
|
Ban Yaro · banyaro.app · Nächster Report in ~6h
|
|
</div>
|
|
|
|
</div>
|
|
</body>
|
|
</html>"""
|
|
|
|
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("</code></pre>")
|
|
in_code = False
|
|
else:
|
|
lines_out.append('<pre style="background:#f5f0ea;padding:10px;border-radius:6px;font-size:12px;overflow-x:auto"><code>')
|
|
in_code = True
|
|
continue
|
|
if in_code:
|
|
lines_out.append(_h.escape(line))
|
|
continue
|
|
if line.startswith("#### "):
|
|
lines_out.append(f'<h4 style="margin:12px 0 4px;color:#333">{line[5:]}</h4>')
|
|
elif line.startswith("### "):
|
|
lines_out.append(f'<h3 style="margin:16px 0 6px;color:#555;font-size:14px;text-transform:uppercase;letter-spacing:.04em">{line[4:]}</h3>')
|
|
elif line.startswith("## "):
|
|
lines_out.append(f'<h2 style="margin:20px 0 8px;color:#C4843A;font-size:16px;border-bottom:1px solid #f0e8dc;padding-bottom:4px">{line[3:]}</h2>')
|
|
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('<table style="width:100%;border-collapse:collapse;font-size:13px;margin:8px 0">')
|
|
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'<td style="padding:4px 8px;border-bottom:1px solid #f0e8dc">{_h.escape(c)}</td>' for c in cells)
|
|
lines_out.append(f"<tr>{row_html}</tr>")
|
|
continue
|
|
elif line.startswith("- ") or line.startswith("* "):
|
|
if in_table:
|
|
lines_out.append("</table>")
|
|
in_table = False
|
|
lines_out.append(f'<li style="margin:2px 0;color:#444">{line[2:]}</li>')
|
|
elif line.startswith("> "):
|
|
if in_table:
|
|
lines_out.append("</table>")
|
|
in_table = False
|
|
lines_out.append(f'<blockquote style="border-left:3px solid #C4843A;margin:8px 0;padding:6px 12px;background:#fdf6ef;color:#555;font-size:13px">{line[2:]}</blockquote>')
|
|
elif line.strip() == "":
|
|
if in_table:
|
|
lines_out.append("</table>")
|
|
in_table = False
|
|
lines_out.append("")
|
|
else:
|
|
if in_table:
|
|
lines_out.append("</table>")
|
|
in_table = False
|
|
styled = line.replace("**", "<b>", 1).replace("**", "</b>", 1)
|
|
lines_out.append(f'<p style="margin:4px 0;color:#444;font-size:14px">{styled}</p>')
|
|
if in_table:
|
|
lines_out.append("</table>")
|
|
if in_code:
|
|
lines_out.append("</code></pre>")
|
|
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'<div style="margin-bottom:32px">'
|
|
f'<h1 style="font-size:18px;font-weight:800;color:#C4843A;margin:0 0 12px;'
|
|
f'border-bottom:2px solid #f0e8dc;padding-bottom:6px">{title}</h1>'
|
|
f'{md_to_html_simple(md)}'
|
|
f'</div>'
|
|
)
|
|
plain_parts.append(f"\n=== {title.upper()} ===\n{md}\n")
|
|
except Exception as e:
|
|
body_parts.append(f'<p style="color:#dc2626">Fehler in Section {title}: {e}</p>')
|
|
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):
|
|
"🎂 <Name> ist X Jahr(e) alt!"
|
|
- Monatsjubiläum in den ersten 11 Monaten (Geburtsmonats-Tag):
|
|
"🐾 <Name> ist heute X Monat(e) alt!"
|
|
"""
|
|
# Jahrestag?
|
|
if today.month == bday.month and today.day == bday.day:
|
|
years = today.year - bday.year
|
|
if years <= 0:
|
|
return None # Geburtstag selbst (Tag 0) → kein Eintrag
|
|
years_label = f"{years} Jahr" if years == 1 else f"{years} Jahre"
|
|
titel = f"🎂 {dog_name} ist {years_label} alt!"
|
|
text = (
|
|
f"Heute feiern wir {dog_name}s {years}. Geburtstag! 🐾🎉 "
|
|
f"Herzlichen Glückwunsch zum {years_label}!"
|
|
)
|
|
return titel, text
|
|
|
|
# Monatsjubiläum (nur innerhalb des ersten Lebensjahres)?
|
|
# today liegt im selben Monatstag wie der Geburtstag aber in einem anderen Monat.
|
|
if today.day == bday.day:
|
|
# Vollständige Monate seit Geburt berechnen
|
|
months = (today.year - bday.year) * 12 + (today.month - bday.month)
|
|
if 1 <= months <= 11:
|
|
months_label = f"{months} Monat" if months == 1 else f"{months} Monate"
|
|
titel = f"🐾 {dog_name} ist heute {months_label} alt!"
|
|
text = (
|
|
f"{dog_name} wird heute {months_label} alt — "
|
|
f"was für ein tolles kleines Hundeleben! 🥳"
|
|
)
|
|
return titel, text
|
|
|
|
return None
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# 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)")
|