Files
business-card-scanner/app/reminders.py
Lucas Orth ab993e98e1 Visitenkarten-Scanner: Stapelscan, Extraktion, Übersicht, vCard-Export
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>
2026-09-06 11:45:56 +02:00

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)