Anlass, Dubletten, Rückseite, Teilen; Startseite und App-Anmutung
All checks were successful
Build & Deploy / deploy (push) Successful in 10m41s

Vier Funktionen:

- Anlass: ein Feld auf der Startseite gilt für alle folgenden Scans und
  wird im Browser gemerkt. Ordnet den Bestand verlässlicher als der
  Ortsname aus den Koordinaten. Durchsuchbar, nachträglich änderbar.
- Dubletten: die Detailansicht zeigt Karten, die dieselbe Person meinen
  könnten - gleiche E-Mail, gleiche Telefonnummer (verglichen werden die
  letzten acht Ziffern, damit +49 511 123456 und 0511/123456 aufeinander
  passen) oder gleicher Nachname bei gleicher Firma.
- Rückseite: nachträglich ein zweites Bild zur Karte. Es füllt nur
  Felder, die die Vorderseite offen gelassen hat.
- Schnellwahl bei Erinnerungen: in 3 Tagen / 1 Woche / 2 Wochen /
  1 Monat, jeweils 9 Uhr. Das Datumsfeld braucht man damit selten.

Teilen als vCard über das System-Teilenblatt, mit Download als
Rückfallebene, wo es kein Teilenblatt gibt.

Oberfläche:

- Startseite führt jetzt: Anlass, große Aufnahmefläche mit Symbol statt
  Textzeile, leise Alternative, darunter die zuletzt erfassten Karten.
  Vorher stand dort eine Überschrift mit Zahlen und man musste raten.
- Weniger Webseite, mehr App: kein Seitenrahmen auf dem Telefon, Symbole
  in der Tableiste mit Zählerblasen, kein Tap-Highlight, keine
  Textmarkierung auf Bedienelementen, Safe-Area oben, Einblendung beim
  Ansichtswechsel, Mindesthöhe 54 px für Schaltflächen.
- Die Marken "exportiert" / "nicht exportiert" sind aus der Liste raus;
  dafür gibt es den Filter.

Neue Spalten occasion und back_image_file kommen per ALTER TABLE in
bestehende Datenbanken - CREATE TABLE IF NOT EXISTS rührt eine
vorhandene Tabelle nicht an.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lucas Orth
2026-09-06 15:09:08 +02:00
parent a7fbc15792
commit bf0a397597
9 changed files with 660 additions and 149 deletions

View File

@@ -262,3 +262,96 @@ def test_handeintrag_ueberlebt_den_hintergrundlauf(client, monkeypatch):
card = client.get(f"/api/cards/{card_id}").json()
assert card["full_name"] == "Von Hand"
assert card["notes"] == "Messe"
def test_anlass_wird_gespeichert_und_ist_durchsuchbar(client):
created = client.post(
"/api/scan",
files={"file": ("tisch.jpg", photo_bytes(2), "image/jpeg")},
data={"occasion": "Hannover Messe 2026"},
).json()
ids = [c["id"] for c in created["cards"]]
warte_bis_gelesen(client, ids)
assert all(c["occasion"] == "Hannover Messe 2026" for c in created["cards"])
gefunden = client.get("/api/cards", params={"q": "hannover messe"}).json()["cards"]
assert set(ids) <= {c["id"] for c in gefunden}
def test_anlass_ist_nachtraeglich_aenderbar(client):
card_id = client.post(
"/api/scan", files={"file": ("einzel.jpg", photo_bytes(1), "image/jpeg")}
).json()["cards"][0]["id"]
warte_bis_gelesen(client, [card_id])
patched = client.patch(f"/api/cards/{card_id}", json={"occasion": "Kundentermin"})
assert patched.json()["occasion"] == "Kundentermin"
def test_dubletten_stehen_in_der_detailansicht(client):
erste = client.post(
"/api/scan", files={"file": ("a.jpg", photo_bytes(1), "image/jpeg")}
).json()["cards"][0]["id"]
zweite = client.post(
"/api/scan", files={"file": ("b.jpg", photo_bytes(1), "image/jpeg")}
).json()["cards"][0]["id"]
warte_bis_gelesen(client, [erste, zweite])
for card_id in (erste, zweite):
client.patch(f"/api/cards/{card_id}", json={"email": "doppelt@firma.de"})
detail = client.get(f"/api/cards/{zweite}").json()
assert erste in [d["id"] for d in detail["duplicates"]]
assert detail["duplicates"][0]["reason"] == "gleiche E-Mail"
def test_rueckseite_nachtragen(client):
card_id = client.post(
"/api/scan", files={"file": ("vorne.jpg", photo_bytes(1), "image/jpeg")}
).json()["cards"][0]["id"]
warte_bis_gelesen(client, [card_id])
response = client.post(
f"/api/cards/{card_id}/back",
files={"file": ("hinten.jpg", photo_bytes(1), "image/jpeg")},
)
assert response.status_code == 200
assert response.json()["back_image_url"].endswith("side=back")
warte_bis_gelesen(client, [card_id])
bild = client.get(f"/api/cards/{card_id}/image", params={"side": "back"})
assert bild.status_code == 200
assert bild.headers["content-type"] == "image/jpeg"
def test_ohne_rueckseite_gibt_es_kein_rueckseitenbild(client):
card_id = client.post(
"/api/scan", files={"file": ("vorne.jpg", photo_bytes(1), "image/jpeg")}
).json()["cards"][0]["id"]
warte_bis_gelesen(client, [card_id])
assert client.get(
f"/api/cards/{card_id}/image", params={"side": "back"}
).status_code == 404
def test_loeschen_entfernt_auch_die_rueckseite(client):
from app import config
card_id = client.post(
"/api/scan", files={"file": ("vorne.jpg", photo_bytes(1), "image/jpeg")}
).json()["cards"][0]["id"]
warte_bis_gelesen(client, [card_id])
client.post(
f"/api/cards/{card_id}/back",
files={"file": ("hinten.jpg", photo_bytes(1), "image/jpeg")},
)
warte_bis_gelesen(client, [card_id])
row = db.query_one("SELECT * FROM cards WHERE id = ?", (card_id,))
client.delete(f"/api/cards/{card_id}")
assert not (config.IMAGE_DIR / row["image_file"]).exists()
assert not (config.IMAGE_DIR / row["back_image_file"]).exists()

88
tests/test_duplicates.py Normal file
View File

@@ -0,0 +1,88 @@
"""Dublettenerkennung. Auf Messen trifft man Leute wieder."""
import uuid
import pytest
from app import db, duplicates
def _card(**felder):
columns = {
"id": uuid.uuid4().hex,
"created_at": "2026-09-01T10:00:00+00:00",
"extract_status": "ok",
**felder,
}
names = ", ".join(columns)
db.execute(
f"INSERT INTO cards ({names}) VALUES ({', '.join('?' for _ in columns)})",
list(columns.values()),
)
return db.query_one("SELECT * FROM cards WHERE id = ?", (columns["id"],))
@pytest.fixture(autouse=True)
def leere_tabelle():
db.execute("DELETE FROM cards")
yield
db.execute("DELETE FROM cards")
def test_gleiche_email_wird_gefunden():
_card(full_name="Anna Berg", email="A.Berg@Firma.de")
neu = _card(full_name="A. Berg", email="a.berg@firma.de")
treffer = duplicates.find(neu)
assert [t["reason"] for t in treffer] == ["gleiche E-Mail"]
def test_telefonnummer_in_anderer_schreibweise():
_card(full_name="Anna Berg", phone="+49 511 1234560")
neu = _card(full_name="Anna B.", mobile="0511/1234560")
treffer = duplicates.find(neu)
assert [t["reason"] for t in treffer] == ["gleiche Telefonnummer"]
def test_name_und_firma_zusammen():
_card(last_name="Berg", company="Berg & Partner GmbH")
neu = _card(last_name="berg", company="berg & partner gmbh")
assert [t["reason"] for t in duplicates.find(neu)] == ["gleicher Name und dieselbe Firma"]
def test_gleicher_nachname_allein_reicht_nicht():
_card(last_name="Berg", company="Andere GmbH")
neu = _card(last_name="Berg", company="Berg & Partner GmbH")
assert duplicates.find(neu) == []
def test_kurze_nummern_werden_nicht_verglichen():
# Eine dreistellige Durchwahl darf keine Dublette erzeugen.
_card(full_name="Anna Berg", phone="123")
neu = _card(full_name="Bea Kurz", phone="123")
assert duplicates.find(neu) == []
def test_leere_felder_erzeugen_keine_treffer():
_card(full_name="Anna Berg")
neu = _card(full_name="Bea Kurz")
assert duplicates.find(neu) == []
def test_die_karte_selbst_ist_keine_dublette():
karte = _card(full_name="Anna Berg", email="a@b.de")
assert duplicates.find(karte) == []
def test_noch_nicht_gelesene_karten_bleiben_aussen_vor():
_card(full_name="Anna Berg", email="a@b.de", extract_status="offen")
neu = _card(full_name="Anna Berg", email="a@b.de")
assert duplicates.find(neu) == []