Selbst gehostete PWA, die Visitenkarten von einem Foto freistellt, ausliest und als Kontakt bereitstellt. - Stapelscan: OpenCV findet die Kartenrechtecke über mehrere Binärmasken, entzerrt sie perspektivisch und schneidet sie einzeln aus. Ohne Fund gilt das ganze Foto als eine Karte. - Extraktion: ein Aufruf je Zuschnitt an das Vision-Modell mit JSON-Schema. Kein vorgeschaltetes OCR - das würde Layout und Schriftgrößen wegwerfen, aus denen die Feldzuordnung entsteht. - Metadaten: Aufnahmezeit und GPS aus den EXIF-Daten des Fotos, Ortsname über Nominatim, Browserstandort nur als Rückfallebene. - Übersicht mit Volltextsuche und Filtern, Detailansicht mit Korrekturmaske. - Notizfeld je Karte, Erinnerungen per Mail inklusive Nachholen verpasster Termine nach einem Neustart. - vCard 3.0 einzeln und als Sammeldatei, Karte gilt danach als exportiert. - Anmeldung über ein Passwort, Sitzung als signiertes Cookie. - Deployment per Tag-Push nach den Konventionen in DEPLOY.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
91 lines
2.7 KiB
Python
91 lines
2.7 KiB
Python
"""Faellige Erinnerungen als Mail verschicken.
|
|
|
|
Der Scheduler fragt die Datenbank ab, statt Timer im Speicher zu halten:
|
|
laeuft der Server zum Zeitpunkt einer Erinnerung nicht, wird sie beim
|
|
naechsten Start nachgeholt.
|
|
"""
|
|
import asyncio
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
|
|
from . import config, db, mailer
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
INTERVAL_SECONDS = 60
|
|
|
|
|
|
def _now() -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
def _body(row) -> str:
|
|
who = row["full_name"] or row["company"] or "Unbekannter Kontakt"
|
|
lines = [row["text"], "", "-" * 40, f"Kontakt: {who}"]
|
|
if row["company"] and row["full_name"]:
|
|
lines.append(f"Firma: {row['company']}")
|
|
if row["email"]:
|
|
lines.append(f"E-Mail: {row['email']}")
|
|
if row["phone"]:
|
|
lines.append(f"Telefon: {row['phone']}")
|
|
if row["mobile"]:
|
|
lines.append(f"Mobil: {row['mobile']}")
|
|
if row["place"]:
|
|
lines.append(f"Kennengelernt in: {row['place']}")
|
|
if config.PUBLIC_BASE_URL:
|
|
lines += ["", f"{config.PUBLIC_BASE_URL}/#/karte/{row['card_id']}"]
|
|
return "\n".join(lines)
|
|
|
|
|
|
def due_reminders() -> list:
|
|
return db.query(
|
|
"""
|
|
SELECT r.id, r.card_id, r.text, r.due_at,
|
|
c.full_name, c.company, c.email, c.phone, c.mobile, c.place
|
|
FROM reminders r
|
|
JOIN cards c ON c.id = r.card_id
|
|
WHERE r.sent_at IS NULL AND r.due_at <= ?
|
|
ORDER BY r.due_at
|
|
""",
|
|
(_now(),),
|
|
)
|
|
|
|
|
|
def process_due() -> int:
|
|
"""Alle faelligen Erinnerungen abarbeiten, Anzahl der Versendeten zurueck."""
|
|
rows = due_reminders()
|
|
if not rows:
|
|
return 0
|
|
if not mailer.configured():
|
|
log.warning("%d faellige Erinnerung(en), aber kein SMTP konfiguriert", len(rows))
|
|
return 0
|
|
|
|
sent = 0
|
|
for row in rows:
|
|
who = row["full_name"] or row["company"] or "Kontakt"
|
|
try:
|
|
mailer.send(f"Erinnerung: {who}", _body(row))
|
|
except Exception as exc:
|
|
log.exception("Erinnerung %s konnte nicht verschickt werden", row["id"])
|
|
db.execute(
|
|
"UPDATE reminders SET send_error = ? WHERE id = ?",
|
|
(f"{type(exc).__name__}: {exc}", row["id"]),
|
|
)
|
|
continue
|
|
db.execute(
|
|
"UPDATE reminders SET sent_at = ?, send_error = NULL WHERE id = ?",
|
|
(_now(), row["id"]),
|
|
)
|
|
sent += 1
|
|
return sent
|
|
|
|
|
|
async def run_forever() -> None:
|
|
log.info("Erinnerungs-Scheduler gestartet (Intervall %ds)", INTERVAL_SECONDS)
|
|
while True:
|
|
try:
|
|
await asyncio.to_thread(process_due)
|
|
except Exception:
|
|
log.exception("Scheduler-Durchlauf fehlgeschlagen")
|
|
await asyncio.sleep(INTERVAL_SECONDS)
|