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>
This commit is contained in:
Lucas Orth
2026-09-06 11:45:56 +02:00
commit ab993e98e1
37 changed files with 3458 additions and 0 deletions

183
tests/test_api.py Normal file
View File

@@ -0,0 +1,183 @@
"""Durchstich durch die HTTP-Schnittstelle.
Ohne ANTHROPIC_API_KEY laeuft die Extraktion in den Leerlauf - Freistellen,
Speichern, Suchen, Bearbeiten und Export werden trotzdem vollstaendig gepruft.
"""
from datetime import datetime, timedelta, timezone
import pytest
from fastapi.testclient import TestClient
from app import db, mailer, reminders
from app.main import app
from tests.helpers import photo_bytes
@pytest.fixture(scope="module")
def client():
with TestClient(app) as test_client:
yield test_client
@pytest.fixture(scope="module")
def scanned(client):
response = client.post(
"/api/scan", files={"file": ("tisch.jpg", photo_bytes(4), "image/jpeg")}
)
assert response.status_code == 200
return response.json()
def test_healthz(client):
assert client.get("/healthz").json() == {"status": "ok"}
def test_scan_legt_je_karte_einen_datensatz_an(scanned):
assert scanned["count"] == 4
assert scanned["fallback"] is False
assert len({card["id"] for card in scanned["cards"]}) == 4
def test_jede_karte_hat_ein_gespeichertes_bild(client, scanned):
for card in scanned["cards"]:
image = client.get(card["image_url"])
assert image.status_code == 200
assert image.headers["content-type"] == "image/jpeg"
assert len(image.content) > 1000
def test_liste_zaehlt_und_liefert_die_karten(client, scanned):
data = client.get("/api/cards").json()
assert data["stats"]["total"] >= 4
assert data["stats"]["offen"] >= 4
def test_bearbeiten_und_suchen(client, scanned):
card_id = scanned["cards"][0]["id"]
patched = client.patch(
f"/api/cards/{card_id}",
json={"full_name": "Anna Berg", "company": "Berg & Partner", "notes": "Messe"},
)
assert patched.status_code == 200
assert patched.json()["full_name"] == "Anna Berg"
hits = client.get("/api/cards", params={"q": "berg"}).json()["cards"]
assert [c["id"] for c in hits] == [card_id]
by_note = client.get("/api/cards", params={"q": "Messe"}).json()["cards"]
assert card_id in [c["id"] for c in by_note]
def test_unbekannte_felder_werden_abgewiesen(client, scanned):
response = client.patch(
f"/api/cards/{scanned['cards'][0]['id']}", json={"extract_status": "ok"}
)
assert response.status_code == 400
def test_vcard_download_markiert_die_karte_als_exportiert(client, scanned):
card_id = scanned["cards"][1]["id"]
client.patch(f"/api/cards/{card_id}", json={"full_name": "Bea Kurz"})
response = client.get(f"/vcf/{card_id}")
assert response.status_code == 200
assert response.headers["content-type"].startswith("text/vcard")
assert "Bea-Kurz.vcf" in response.headers["content-disposition"]
assert "FN:Bea Kurz" in response.text
assert client.get(f"/api/cards/{card_id}").json()["exported_at"] is not None
def test_filter_offen_blendet_exportierte_karten_aus(client, scanned):
offen = client.get("/api/cards", params={"filter": "offen"}).json()["cards"]
assert all(card["exported_at"] is None for card in offen)
def test_sammelexport_mehrerer_karten(client, scanned):
ids = ",".join(card["id"] for card in scanned["cards"][:3])
response = client.get("/vcf", params={"ids": ids})
assert response.text.count("BEGIN:VCARD") == 3
def test_erinnerung_anlegen_und_filtern(client, scanned):
card_id = scanned["cards"][2]["id"]
due = datetime.now(timezone.utc) + timedelta(days=3)
created = client.post(
f"/api/cards/{card_id}/reminders",
json={"text": "Angebot nachfassen", "due_at": due.isoformat()},
)
assert created.status_code == 200
detail = client.get(f"/api/cards/{card_id}").json()
assert len(detail["reminders"]) == 1
mit_erinnerung = client.get("/api/cards", params={"filter": "erinnerung"}).json()
assert card_id in [c["id"] for c in mit_erinnerung["cards"]]
assert mit_erinnerung["stats"]["erinnerungen"] >= 1
def test_erinnerung_ohne_datum_wird_abgewiesen(client, scanned):
response = client.post(
f"/api/cards/{scanned['cards'][2]['id']}/reminders",
json={"text": "Ohne Termin", "due_at": "morgen"},
)
assert response.status_code == 400
def test_faellige_erinnerung_wird_verschickt(client, scanned, monkeypatch):
card_id = scanned["cards"][3]["id"]
client.patch(f"/api/cards/{card_id}", json={"full_name": "Carl Spät"})
past = datetime.now(timezone.utc) - timedelta(minutes=5)
client.post(
f"/api/cards/{card_id}/reminders",
json={"text": "Rueckruf", "due_at": past.isoformat()},
)
verschickt = []
monkeypatch.setattr(mailer, "configured", lambda: True)
monkeypatch.setattr(mailer, "send", lambda subject, body: verschickt.append((subject, body)))
assert reminders.process_due() == 1
assert "Carl Spät" in verschickt[0][0]
assert "Rueckruf" in verschickt[0][1]
# Zweiter Durchlauf darf nicht erneut verschicken.
assert reminders.process_due() == 0
def test_loeschen_entfernt_karte_und_bild(client):
created = client.post(
"/api/scan", files={"file": ("einzel.jpg", photo_bytes(1), "image/jpeg")}
).json()
card_id = created["cards"][0]["id"]
image_file = db.query_one("SELECT image_file FROM cards WHERE id = ?", (card_id,))["image_file"]
from app import config
assert (config.IMAGE_DIR / image_file).exists()
assert client.delete(f"/api/cards/{card_id}").status_code == 200
assert client.get(f"/api/cards/{card_id}").status_code == 404
assert not (config.IMAGE_DIR / image_file).exists()
def test_kaputter_upload_gibt_400(client):
response = client.post(
"/api/scan", files={"file": ("kaputt.jpg", b"kein bild", "image/jpeg")}
)
assert response.status_code == 400
def test_pwa_dateien_werden_ausgeliefert(client):
assert client.get("/").status_code == 200
assert client.get("/sw.js").status_code == 200
assert client.get("/manifest.webmanifest").status_code == 200
assert client.get("/static/app.js").status_code == 200