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:
16
tests/conftest.py
Normal file
16
tests/conftest.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""Testumgebung: eigenes Datenverzeichnis, keine Anmeldung, kein Netzverkehr.
|
||||
|
||||
Muss vor dem Import von app.* laufen - config liest die Umgebung beim Import.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
os.environ["DATA_DIR"] = tempfile.mkdtemp(prefix="bcs-test-")
|
||||
os.environ["APP_PASSWORD"] = ""
|
||||
os.environ["ANTHROPIC_API_KEY"] = ""
|
||||
os.environ["REVERSE_GEOCODE"] = "0"
|
||||
os.environ["SMTP_HOST"] = ""
|
||||
59
tests/helpers.py
Normal file
59
tests/helpers.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Erzeugt Testfotos: mehrere Visitenkarten auf einem dunklen Untergrund."""
|
||||
import io
|
||||
import random
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
|
||||
CARD_RATIO = 85.6 / 54.0
|
||||
|
||||
|
||||
def _card(width: int = 420, seed: int = 0) -> Image.Image:
|
||||
"""Eine weisse Karte mit ein paar dunklen Textzeilen."""
|
||||
height = round(width / CARD_RATIO)
|
||||
card = Image.new("RGB", (width, height), "white")
|
||||
draw = ImageDraw.Draw(card)
|
||||
rng = random.Random(seed)
|
||||
draw.line([(0.08 * width, 0.30 * height), (0.62 * width, 0.30 * height)], fill="black", width=6)
|
||||
draw.line([(0.08 * width, 0.44 * height), (0.48 * width, 0.44 * height)], fill=(70, 70, 70), width=3)
|
||||
for index in range(3):
|
||||
y = (0.62 + index * 0.10) * height
|
||||
draw.line(
|
||||
[(0.08 * width, y), ((0.30 + rng.random() * 0.35) * width, y)],
|
||||
fill=(90, 90, 90),
|
||||
width=2,
|
||||
)
|
||||
return card
|
||||
|
||||
|
||||
def photo_with_cards(count: int = 6, size=(2400, 1800), angle_range: int = 8) -> np.ndarray:
|
||||
"""Foto im BGR-Format mit `count` Karten in einem Raster."""
|
||||
background = Image.new("RGB", size, (38, 40, 44))
|
||||
rng = random.Random(count)
|
||||
columns = 2 if count <= 6 else 3
|
||||
rows = (count + columns - 1) // columns
|
||||
cell_width = size[0] // columns
|
||||
cell_height = size[1] // rows
|
||||
card_width = int(min(cell_width * 0.72, cell_height * 0.72 * CARD_RATIO))
|
||||
|
||||
for index in range(count):
|
||||
column, row = index % columns, index // columns
|
||||
card = _card(card_width, seed=index)
|
||||
angle = rng.uniform(-angle_range, angle_range)
|
||||
rotated = card.rotate(angle, expand=True, resample=Image.BICUBIC)
|
||||
mask = Image.new("L", card.size, 255).rotate(
|
||||
angle, expand=True, resample=Image.BICUBIC
|
||||
)
|
||||
x = column * cell_width + (cell_width - rotated.width) // 2
|
||||
y = row * cell_height + (cell_height - rotated.height) // 2
|
||||
background.paste(rotated, (x, y), mask)
|
||||
|
||||
return np.asarray(background)[:, :, ::-1].copy()
|
||||
|
||||
|
||||
def photo_bytes(count: int = 6) -> bytes:
|
||||
array = photo_with_cards(count)
|
||||
buffer = io.BytesIO()
|
||||
Image.fromarray(array[:, :, ::-1]).save(buffer, format="JPEG", quality=92)
|
||||
return buffer.getvalue()
|
||||
183
tests/test_api.py
Normal file
183
tests/test_api.py
Normal 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
|
||||
72
tests/test_imaging.py
Normal file
72
tests/test_imaging.py
Normal file
@@ -0,0 +1,72 @@
|
||||
import io
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from app import imaging
|
||||
|
||||
|
||||
def _jpeg_with_exif(tags: dict) -> bytes:
|
||||
image = Image.new("RGB", (64, 40), "white")
|
||||
exif = Image.Exif()
|
||||
for tag, value in tags.items():
|
||||
exif[tag] = value
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer, format="JPEG", exif=exif.tobytes())
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def test_aufnahmezeit_wird_gelesen():
|
||||
raw = _jpeg_with_exif({0x9003: "2026:09:01 14:23:05"})
|
||||
|
||||
meta = imaging.read_metadata(raw)
|
||||
|
||||
assert meta["captured_at"].startswith("2026-09-01T14:23:05")
|
||||
|
||||
|
||||
def test_gps_wird_in_dezimalgrad_umgerechnet():
|
||||
raw = _jpeg_with_exif(
|
||||
{0x8825: {1: "N", 2: (52.0, 19.0, 18.0), 3: "E", 4: (9.0, 48.0, 28.0)}}
|
||||
)
|
||||
|
||||
meta = imaging.read_metadata(raw)
|
||||
|
||||
assert meta["lat"] == 52.321667
|
||||
assert meta["lon"] == 9.807778
|
||||
|
||||
|
||||
def test_suedliche_und_westliche_koordinaten_werden_negativ():
|
||||
raw = _jpeg_with_exif(
|
||||
{0x8825: {1: "S", 2: (33.0, 55.0, 0.0), 3: "W", 4: (18.0, 25.0, 0.0)}}
|
||||
)
|
||||
|
||||
meta = imaging.read_metadata(raw)
|
||||
|
||||
assert meta["lat"] < 0
|
||||
assert meta["lon"] < 0
|
||||
|
||||
|
||||
def test_foto_ohne_exif_liefert_leere_metadaten():
|
||||
buffer = io.BytesIO()
|
||||
Image.new("RGB", (32, 32), "white").save(buffer, format="JPEG")
|
||||
|
||||
meta = imaging.read_metadata(buffer.getvalue())
|
||||
|
||||
assert meta == {"captured_at": None, "lat": None, "lon": None}
|
||||
|
||||
|
||||
def test_kaputte_datei_wirft_nicht():
|
||||
assert imaging.read_metadata(b"kein bild") == {
|
||||
"captured_at": None,
|
||||
"lat": None,
|
||||
"lon": None,
|
||||
}
|
||||
|
||||
|
||||
def test_dekodieren_liefert_bgr_array():
|
||||
buffer = io.BytesIO()
|
||||
Image.new("RGB", (20, 10), (255, 0, 0)).save(buffer, format="PNG")
|
||||
|
||||
array = imaging.decode(buffer.getvalue())
|
||||
|
||||
assert array.shape == (10, 20, 3)
|
||||
assert tuple(array[0, 0]) == (0, 0, 255) # Rot liegt in BGR hinten
|
||||
45
tests/test_segment.py
Normal file
45
tests/test_segment.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import numpy as np
|
||||
|
||||
from app import segment
|
||||
from tests.helpers import photo_with_cards
|
||||
|
||||
|
||||
def test_findet_alle_karten_im_stapel():
|
||||
photo = photo_with_cards(count=6)
|
||||
crops, fallback = segment.segment(photo)
|
||||
|
||||
assert fallback is False
|
||||
assert len(crops) == 6
|
||||
|
||||
|
||||
def test_zuschnitte_haben_kartenformat():
|
||||
crops, _ = segment.segment(photo_with_cards(count=4))
|
||||
|
||||
for crop in crops:
|
||||
height, width = crop.shape[:2]
|
||||
assert (width, height) == (segment.OUT_WIDTH, segment.OUT_HEIGHT)
|
||||
|
||||
|
||||
def test_zuschnitt_enthaelt_die_karte_und_nicht_den_tisch():
|
||||
crops, _ = segment.segment(photo_with_cards(count=4))
|
||||
|
||||
for crop in crops:
|
||||
# Die Karte ist weiss, der Untergrund dunkel. Ein Zuschnitt, der
|
||||
# danebenliegt, waere im Mittel dunkel.
|
||||
assert crop.mean() > 170
|
||||
|
||||
|
||||
def test_grosser_stapel():
|
||||
crops, fallback = segment.segment(photo_with_cards(count=12))
|
||||
|
||||
assert fallback is False
|
||||
assert len(crops) == 12
|
||||
|
||||
|
||||
def test_einzelne_formatfuellende_karte_faellt_auf_gesamtbild_zurueck():
|
||||
# Randlose Karte: es gibt keine Kante zum Untergrund, also keinen Fund.
|
||||
card = np.full((1080, 1712, 3), 245, dtype=np.uint8)
|
||||
crops, fallback = segment.segment(card)
|
||||
|
||||
assert fallback is True
|
||||
assert len(crops) == 1
|
||||
71
tests/test_vcard.py
Normal file
71
tests/test_vcard.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from app import vcard
|
||||
|
||||
BASE = {
|
||||
"full_name": "Dr. Anna Berg",
|
||||
"first_name": "Anna",
|
||||
"last_name": "Berg",
|
||||
"job_title": "Geschäftsführerin",
|
||||
"company": "Berg & Partner GmbH",
|
||||
"email": "a.berg@berg-partner.de",
|
||||
"phone": "+49 511 1234560",
|
||||
"mobile": "+49 170 1234567",
|
||||
"website": "berg-partner.de",
|
||||
"street": "Musterweg 3",
|
||||
"postal_code": "30159",
|
||||
"city": "Hannover",
|
||||
"country": "Deutschland",
|
||||
"notes": "Sucht ein neues CRM.",
|
||||
"captured_at": "2026-09-01T10:15:00+00:00",
|
||||
"place": "Hannover, Messegelände",
|
||||
"lat": 52.3216,
|
||||
"lon": 9.8078,
|
||||
}
|
||||
|
||||
|
||||
def test_enthaelt_die_pflichtzeilen():
|
||||
output = vcard.build(BASE)
|
||||
|
||||
assert output.startswith("BEGIN:VCARD\r\nVERSION:3.0\r\n")
|
||||
assert output.endswith("END:VCARD\r\n")
|
||||
assert "FN:Dr. Anna Berg" in output
|
||||
assert "N:Berg;Anna;;;" in output
|
||||
assert "TEL;TYPE=CELL:+49 170 1234567" in output
|
||||
|
||||
|
||||
def test_kommas_und_semikolons_werden_maskiert():
|
||||
output = vcard.build({**BASE, "company": "Berg, Partner; Co"})
|
||||
|
||||
assert "ORG:Berg\\, Partner\\; Co" in output
|
||||
|
||||
|
||||
def test_notiz_traegt_zeit_und_ort():
|
||||
note = [line for line in vcard.build(BASE).splitlines() if line.startswith("NOTE:")][0]
|
||||
|
||||
assert "Sucht ein neues CRM." in note
|
||||
assert "01.09.2026" in note
|
||||
assert "Messegelände" in note
|
||||
|
||||
|
||||
def test_leere_karte_bekommt_trotzdem_einen_namen():
|
||||
output = vcard.build({"notes": ""})
|
||||
|
||||
assert "FN:Unbenannt" in output
|
||||
|
||||
|
||||
def test_firma_ersetzt_den_namen_wenn_keiner_erkannt_wurde():
|
||||
output = vcard.build({"company": "Berg & Partner GmbH"})
|
||||
|
||||
assert "FN:Berg & Partner GmbH" in output
|
||||
|
||||
|
||||
def test_mehrere_karten_in_einer_datei():
|
||||
output = vcard.build_many([BASE, {"full_name": "Bea Kurz"}])
|
||||
|
||||
assert output.count("BEGIN:VCARD") == 2
|
||||
assert output.count("END:VCARD") == 2
|
||||
|
||||
|
||||
def test_dateiname_ist_dateisystemtauglich():
|
||||
assert vcard.filename(BASE) == "Dr-Anna-Berg.vcf"
|
||||
assert vcard.filename({}) == "Unbenannt.vcf"
|
||||
assert vcard.filename({"full_name": "?!"}) == "kontakt.vcf"
|
||||
Reference in New Issue
Block a user