Sprint 11: Freunde & Chat + Phosphor-Icon-Vollmigration

- Freundschaften (pending/accepted), Nutzersuche, Anfragen per Push
- Direktnachrichten mit Polling, iMessage-Stil, Deep-Links aus Push
- Alle Seiten (map, places, diary, health, dog-profile, sitting, knigge,
  forum, wiki, walks) vollständig auf Phosphor-Icons migriert
- Wikidata-Rassen-Scraper (~833 neue Rassen, lokal gespiegelte Fotos)
- TheDogAPI lokal gespiegelt (169 Rassen + Fotos)
- Quiz-Result-Cards horizontal (korrekte Bildproportionen)
- SW by-v89
This commit is contained in:
rene 2026-04-15 21:33:53 +02:00
parent 96bd57f0ad
commit 097295c628
44 changed files with 9980 additions and 300 deletions

View file

@ -4,12 +4,13 @@ Täglich: Gesundheits-Erinnerungen per Push versenden.
"""
import logging
from datetime import date, timedelta
from datetime import date, datetime, timedelta
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from database import db
from routes.push import send_push_to_user
from routes.push import send_push_to_user, send_push_to_all
import weather
logger = logging.getLogger(__name__)
@ -31,8 +32,53 @@ def start():
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() + 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() + 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() + timedelta(seconds=45),
id="seed_wikidata_startup",
replace_existing=True,
)
_scheduler.start()
logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00.")
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():
@ -122,3 +168,254 @@ async def _job_poison_archive():
count = result.rowcount
if count:
logger.info(f"Giftköder-Archiv: {count} abgelaufene Meldungen archiviert.")
# ------------------------------------------------------------------
# JOB: Wetter-Alarm (Hitzepfoten / Gewitter)
# ------------------------------------------------------------------
async def _job_weather_alert():
"""
Holt Tagesprognose für mehrere deutsche Städte.
Sendet Push-Notification wenn:
- Temperatur >= 28°C (Asphalt-Warnung für Pfoten)
- Gewitter wahrscheinlich
Hitze hat Vorrang: Bei Hitze wird kein Gewitter-Push mehr gesendet.
"""
logger.info("Wetter-Alert Job läuft")
try:
summary = await weather.get_weather_summary()
except Exception as e:
logger.error(f"Wetter-Alert: Fehler beim Abruf: {e}")
return
max_temp = summary["max_temp_c"]
thunderstorm = summary["thunderstorm"]
if max_temp >= 28:
sent = send_push_to_all({
"type": "weather_heat",
"title": "☀️ Heißer Asphalt heute",
"body": f"Bis {max_temp:.0f}°C heute — Asphalt kann über 50°C heiß werden. Frühmorgens oder abends gassi gehen!",
"data": {"tag": "weather-heat"},
})
logger.info(f"Wetter-Alert Hitze: {max_temp:.1f}°C — {sent} Push gesendet.")
return # Kein Gewitter-Push mehr nötig wenn Hitze bereits gemeldet
if thunderstorm:
sent = send_push_to_all({
"type": "weather_thunder",
"title": "⛈️ Gewitter möglich",
"body": "Heute Gewitter wahrscheinlich. Gassi-Tour früh einplanen und Hund beruhigen.",
"data": {"tag": "weather-thunder"},
})
logger.info(f"Wetter-Alert Gewitter — {sent} Push gesendet.")
return
logger.info("Wetter-Alert: Keine Warnung nötig heute.")
# ------------------------------------------------------------------
# JOB: Geburtstags- und Monats-Meilensteine
# Läuft täglich um 00:05 Uhr (Europe/Berlin).
# Prüft alle Hunde mit gesetztem Geburtstag und erstellt bei Treffern
# einen Tagebucheintrag (is_milestone=1) + Push-Notification.
# ------------------------------------------------------------------
async def _job_milestone_check():
"""
Prüft für jeden Hund mit bekanntem Geburtstag ob heute ein
Meilenstein-Tag ist:
- Jahrestag (1. Geburtstag, 2. Geburtstag, )
- Monatsjubiläum in den ersten 12 Monaten (1 Monat, 2 Monate, , 11 Monate)
Doppelt-Schutz: Wenn bereits ein Meilenstein-Eintrag mit demselben
Titel für heute existiert, wird kein zweiter erstellt.
"""
today = date.today()
logger.info(f"Meilenstein-Check läuft für {today}")
with db() as conn:
dogs = conn.execute("""
SELECT d.id, d.name, d.user_id, d.geburtstag
FROM dogs d
WHERE d.geburtstag IS NOT NULL
AND d.geburtstag != ''
""").fetchall()
created_total = 0
for dog in dogs:
try:
bday = date.fromisoformat(dog["geburtstag"])
except ValueError:
logger.warning(f"Meilenstein: ungültiges Geburtstag für Hund {dog['id']}: {dog['geburtstag']!r}")
continue
milestone = _compute_milestone(today, bday, dog["name"])
if milestone is None:
continue
titel, text = milestone
with db() as conn:
# Doppelt-Schutz: kein zweiter Eintrag am selben Tag mit gleichem Titel
exists = conn.execute("""
SELECT id FROM diary
WHERE dog_id = ? AND datum = ? AND titel = ? AND is_milestone = 1
""", (dog["id"], str(today), titel)).fetchone()
if exists:
logger.info(f"Meilenstein bereits vorhanden: Hund {dog['id']} '{titel}'")
continue
# Tagebucheintrag anlegen
cur = conn.execute("""
INSERT INTO diary (dog_id, datum, typ, titel, text, is_milestone)
VALUES (?, ?, 'milestone', ?, ?, 1)
""", (dog["id"], str(today), titel, text))
entry_id = cur.lastrowid
# Junction-Tabelle befüllen
conn.execute("""
INSERT OR IGNORE INTO diary_dogs (diary_id, dog_id) VALUES (?, ?)
""", (entry_id, dog["id"]))
# Push an Besitzer
send_push_to_user(dog["user_id"], {
"type": "milestone",
"title": titel,
"body": text,
"data": {"page": "diary"},
"tag": f"milestone-{dog['id']}-{today}",
})
logger.info(f"Meilenstein erstellt: Hund {dog['id']} '{titel}' → diary_id={entry_id}")
created_total += 1
logger.info(f"Meilenstein-Check fertig — {created_total} Einträge erstellt.")
# ------------------------------------------------------------------
# JOB: VDH-Events importieren
# ------------------------------------------------------------------
async def _job_import_events():
"""
Scrapt Veranstaltungen von vdh.de und importiert neue Events in die DB.
Bereits vorhandene external_ids werden übersprungen (Upsert-Logik).
"""
try:
from scraper.events_vdh import fetch_vdh_events
except ImportError as e:
logger.error(f"Event-Import: Scraper konnte nicht geladen werden: {e}")
return
try:
events = await fetch_vdh_events()
except Exception as e:
logger.error(f"Event-Import: Fehler beim Scrapen: {e}")
return
imported = 0
with db() as conn:
for ev in events:
try:
exists = conn.execute(
"SELECT id FROM events WHERE external_id = ?",
(ev['external_id'],)
).fetchone()
if not exists:
conn.execute("""
INSERT INTO events (user_id, titel, datum, ort_name, typ, link, quelle, external_id, status)
VALUES (0, ?, ?, ?, ?, ?, 'vdh', ?, 'aktiv')
""", (
ev['titel'],
ev['datum'],
ev.get('ort_name'),
ev['typ'],
ev.get('link'),
ev['external_id'],
))
imported += 1
except Exception as e:
logger.warning(f"Event-Import: Fehler beim Speichern von '{ev.get('titel')}': {e}")
logger.info(f"Event-Import: {imported} neue Events importiert (von {len(events)} geparsten).")
# ------------------------------------------------------------------
# JOB: Rassen aus TheDogAPI seeden
# ------------------------------------------------------------------
async def _job_seed_breeds():
"""Lädt alle Hunderassen von TheDogAPI und speichert sie in wiki_rassen."""
try:
from scraper.breeds import fetch_and_seed_breeds, mirror_breed_photos
except ImportError as e:
logger.error(f"Breed-Seed: Scraper konnte nicht geladen werden: {e}")
return
try:
count = await fetch_and_seed_breeds()
logger.info(f"Breed seed job done: {count} breeds")
mirrored = await mirror_breed_photos()
logger.info(f"Breed photo mirror done: {mirrored} photos")
except Exception as e:
logger.error(f"Breed-Seed: Fehler: {e}")
# ------------------------------------------------------------------
# JOB: Fehlende Rassen aus Wikidata ergänzen
# ------------------------------------------------------------------
async def _job_seed_wikidata_breeds():
"""Lädt fehlende Hunderassen von Wikidata und spiegelt Fotos lokal."""
try:
from scraper.wikidata_breeds import fetch_and_seed_wikidata_breeds, mirror_wikidata_photos
except ImportError as e:
logger.error(f"Wikidata-Seed: Scraper konnte nicht geladen werden: {e}")
return
try:
count = await fetch_and_seed_wikidata_breeds()
logger.info(f"Wikidata breed seed done: {count} neue Rassen")
mirrored = await mirror_wikidata_photos()
logger.info(f"Wikidata photo mirror done: {mirrored} Fotos")
except Exception as e:
logger.error(f"Wikidata-Seed: 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