banyaro/backend/scheduler.py
rene 553e9e7854 Sprint 12+13: Tagebuch Day-One-Redesign, Notiz-Feature, Icon-Fixes, SW by-v405
Tagebuch:
- Day-One-Listenansicht: Wochentag + Tageszahl + Meta-Zeile (Zeit/Ort/Wetter)
- 4 Ansichten: Liste, Medien-Mosaik, Kalender (mit Sprungbuttons), Karte (GPS-Marker)
- Detail-Ansicht inline im Content-Bereich (kein Fullscreen-Overlay mehr)
- Hero-Bild vollständig sichtbar (object-fit:contain), Lightbox mit Safe-Area
- 2-Spalten-Layout Desktop: Text + Leaflet-Karte + POI-Liste
- EXIF-GPS-Extraktion bei Foto-Upload, historisches Wetter via Archive-API
- NoteStation-Import: Fotos in diary_media (80 Einträge migriert, 94 Medien)
- Stats-Endpoints: /diary/stats, /diary/calendar, /diary/locations

Notiz-Feature:
- Generische notes-Tabelle (parent_type + parent_id + meta_json)
- 📝-Button in 8 Bereichen, Notizblock-Seite mit KI-Analyse
- KI-Toggle in Einstellungen, notes_ki_enabled in User-Profil

Icons & Design:
- fill:currentColor Fix für welcome/onboarding/friends.js
- --c-icon Variable, --c-text-muted Dark Mode aufgehellt
- 15+ neue Phosphor-Icons aus lokaler Kopie
- CSS Network-First im SW, Cache-Control-Middleware

Infrastruktur:
- Wiki-Anreicherungs-Scheduler-Jobs entfernt (abgeschlossen)
- auth.py: notes_ki_enabled + is_social_media im User-Response
2026-04-25 20:44:46 +02:00

933 lines
37 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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