feat(provenance): Datenherkunft als chronologischer, datei-attribuierter Verlauf
Die Herkunft liest sich jetzt wie eine History: pro Feld wird genannt, aus welcher Quelldatei der Wert kam, plus Merge-/Entscheidungs-/Wurfchronik-Schritte. Beispiel: „In ‚X.xlsx' gefunden." → „Geburtsdatum (…) aus ‚X.xlsx'." → „Auch in ‚Y.docx' gefunden → zusammengeführt." → „Eltern über Position erkannt (Quelle …)." Import: build_entity_provenance() um history[] erweitert; ein field_source- Tracking im Gerbil-Dedup hält fest, welcher Datensatz/welche Datei jedes Feld (DOB/Genotyp/Geschlecht/Farbschlag/Sterbedatum) lieferte — auch über Empty-Fill und Mehrheitsentscheid hinweg. Kontakte und Würfe erhalten analoge, datei- attribuierte Verläufe. Kein DB-Migration nötig (history liegt im Provenance-JSON). Frontend: ProvenanceDialog rendert den Verlauf als nummerierte Timeline. Tests: history wird erzeugt, nennt Dateien, überlebt Ingest (dotnet 212, vitest 129, playwright 17, python merge/extract grün). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -87,7 +87,7 @@ namespace GerbilManager.Tests
|
|||||||
CharacterNote = (string?)null,
|
CharacterNote = (string?)null,
|
||||||
IsDeaf = false,
|
IsDeaf = false,
|
||||||
IsResident = true,
|
IsResident = true,
|
||||||
Provenance = (string?)"{\"sourceFiles\":[\"Stammbaum von Papa.xlsx\",\"Wurfchronik-Detail.docx\"],\"mergedRecordCount\":2,\"fromWurfchronik\":true,\"notes\":[\"aus 2 Datensätzen zusammengeführt\"]}"
|
Provenance = (string?)"{\"sourceFiles\":[\"Stammbaum von Papa.xlsx\",\"Wurfchronik-Detail.docx\"],\"mergedRecordCount\":2,\"fromWurfchronik\":true,\"notes\":[\"aus 2 Datensätzen zusammengeführt\"],\"history\":[\"In \\u201eStammbaum von Papa.xlsx\\u201c gefunden.\",\"Genotyp aus \\u201eWurfchronik-Detail.docx\\u201c.\"]}"
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
@@ -185,6 +185,10 @@ namespace GerbilManager.Tests
|
|||||||
Assert.NotNull(father.Provenance);
|
Assert.NotNull(father.Provenance);
|
||||||
Assert.Contains("Stammbaum von Papa.xlsx", father.Provenance);
|
Assert.Contains("Stammbaum von Papa.xlsx", father.Provenance);
|
||||||
Assert.Contains("mergedRecordCount", father.Provenance);
|
Assert.Contains("mergedRecordCount", father.Provenance);
|
||||||
|
// The chronological, file-attributed history survives ingest verbatim
|
||||||
|
// (stored as opaque JSON on the text column — no schema for it).
|
||||||
|
Assert.Contains("history", father.Provenance);
|
||||||
|
Assert.Contains("Genotyp aus", father.Provenance);
|
||||||
Assert.Null(mother.Provenance);
|
Assert.Null(mother.Provenance);
|
||||||
|
|
||||||
// Contact + litter provenance also round-trips through the ingest.
|
// Contact + litter provenance also round-trips through the ingest.
|
||||||
|
|||||||
@@ -130,6 +130,14 @@ export function seedDb(): MockDb {
|
|||||||
parentMethod: 'chart-position',
|
parentMethod: 'chart-position',
|
||||||
parentConfidence: 'medium',
|
parentConfidence: 'medium',
|
||||||
notes: ['aus 2 Datensätzen zusammengeführt'],
|
notes: ['aus 2 Datensätzen zusammengeführt'],
|
||||||
|
history: [
|
||||||
|
'In „Stammbaum von Krümel.xlsx“ gefunden.',
|
||||||
|
'Geburtsdatum (12.03.2025) aus „Stammbaum von Krümel.xlsx“.',
|
||||||
|
'Genotyp aus „Stammbaum von Krümel.xlsx“.',
|
||||||
|
'Auch in „Wurfchronik-Detail.docx“ gefunden → Datensätze zusammengeführt.',
|
||||||
|
'Eltern über Position im Stammbaum erkannt (Quelle: „Stammbaum von Krümel.xlsx“).',
|
||||||
|
'Angaben aus der Wurfchronik übernommen.',
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{ ...gerbil('fridolin', 'Fridolin', 'male', '2023-05-01', 'w-fridolin', 'cv-schwarz', 'aa CC DD EE GG PP spsp rere'), enclosureId: 'enc-gross', originBreeder: 'Zoohandlung Meier', profilePhotoUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' },
|
{ ...gerbil('fridolin', 'Fridolin', 'male', '2023-05-01', 'w-fridolin', 'cv-schwarz', 'aa CC DD EE GG PP spsp rere'), enclosureId: 'enc-gross', originBreeder: 'Zoohandlung Meier', profilePhotoUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' },
|
||||||
@@ -203,6 +211,11 @@ export function seedDb(): MockDb {
|
|||||||
mergedRecordCount: 2,
|
mergedRecordCount: 2,
|
||||||
fromWurfchronik: true,
|
fromWurfchronik: true,
|
||||||
notes: ['aus Wurfchronik', 'aus 2 Datensätzen zusammengeführt', 'Geschwister-Würfe zusammengeführt'],
|
notes: ['aus Wurfchronik', 'aus 2 Datensätzen zusammengeführt', 'Geschwister-Würfe zusammengeführt'],
|
||||||
|
history: [
|
||||||
|
'Wurf aus Wurfchronik „Wurfchronik Teil 1_page_0009.md“.',
|
||||||
|
'Auch in „Wurfchronik Teil 1_page_0028.md“ gefunden → Datensätze zusammengeführt.',
|
||||||
|
'Geschwister-Würfe zusammengeführt.',
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{ id: 'w-fridolin', name: 'Wurf F', date: '2023-05-01', totalBorn: 4, expectedGoHomeDate: null, notes: null, fatherId: 'balu', motherId: 'maja' },
|
{ id: 'w-fridolin', name: 'Wurf F', date: '2023-05-01', totalBorn: 4, expectedGoHomeDate: null, notes: null, fatherId: 'balu', motherId: 'maja' },
|
||||||
@@ -229,6 +242,11 @@ export function seedDb(): MockDb {
|
|||||||
mergedRecordCount: 2,
|
mergedRecordCount: 2,
|
||||||
fromWurfchronik: true,
|
fromWurfchronik: true,
|
||||||
notes: ['aus 2 Datensätzen zusammengeführt', 'als Züchter erkannt', 'als Abnehmer erkannt'],
|
notes: ['aus 2 Datensätzen zusammengeführt', 'als Züchter erkannt', 'als Abnehmer erkannt'],
|
||||||
|
history: [
|
||||||
|
'In „Stammbaum von Krümel.xlsx“ als Züchter erkannt.',
|
||||||
|
'Auch in „Wurfchronik Teil 1_page_0001.md“ gefunden → Datensätze zusammengeführt.',
|
||||||
|
'Sowohl als Züchter als auch als Abnehmer geführt.',
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{ id: 'con-huber', name: 'Familie Huber', email: null, phone: '0151 2345678', address: null, notes: null, isBreeder: false, isReceiver: true },
|
{ id: 'con-huber', name: 'Familie Huber', email: null, phone: '0151 2345678', address: null, notes: null, isBreeder: false, isReceiver: true },
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ test('Tierakte: "Datenherkunft"-Button öffnet den Nachverfolgungs-Dialog', asyn
|
|||||||
const dialog = page.getByRole('dialog', { name: p.dialogTitle })
|
const dialog = page.getByRole('dialog', { name: p.dialogTitle })
|
||||||
await expect(dialog).toBeVisible()
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
|
// VERLAUF: chronologischer, dateibezogener Verlauf als Primärinhalt.
|
||||||
|
await expect(dialog).toContainText(p.historyTitle)
|
||||||
|
await expect(dialog).toContainText('In „Stammbaum von Krümel.xlsx“ gefunden.')
|
||||||
|
await expect(dialog).toContainText('Geburtsdatum (12.03.2025) aus „Stammbaum von Krümel.xlsx“.')
|
||||||
|
await expect(dialog).toContainText(
|
||||||
|
'Auch in „Wurfchronik-Detail.docx“ gefunden → Datensätze zusammengeführt.',
|
||||||
|
)
|
||||||
|
|
||||||
// Quelldateien werden angezeigt.
|
// Quelldateien werden angezeigt.
|
||||||
await expect(dialog).toContainText(p.sourceFilesTitle)
|
await expect(dialog).toContainText(p.sourceFilesTitle)
|
||||||
await expect(dialog).toContainText('Stammbaum von Krümel.xlsx')
|
await expect(dialog).toContainText('Stammbaum von Krümel.xlsx')
|
||||||
@@ -53,6 +61,10 @@ test('Kontakt: "Datenherkunft"-Button öffnet den Nachverfolgungs-Dialog', async
|
|||||||
const dialog = page.getByRole('dialog', { name: p.dialogTitle })
|
const dialog = page.getByRole('dialog', { name: p.dialogTitle })
|
||||||
await expect(dialog).toBeVisible()
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
|
// VERLAUF: dateibezogene Schritte des Kontakts.
|
||||||
|
await expect(dialog).toContainText(p.historyTitle)
|
||||||
|
await expect(dialog).toContainText('In „Stammbaum von Krümel.xlsx“ als Züchter erkannt.')
|
||||||
|
|
||||||
// Quelldateien + Zusammenführung + Kontakt-Rolle-Hinweise.
|
// Quelldateien + Zusammenführung + Kontakt-Rolle-Hinweise.
|
||||||
await expect(dialog).toContainText(p.sourceFilesTitle)
|
await expect(dialog).toContainText(p.sourceFilesTitle)
|
||||||
await expect(dialog).toContainText('Wurfchronik Teil 1_page_0001.md')
|
await expect(dialog).toContainText('Wurfchronik Teil 1_page_0001.md')
|
||||||
@@ -73,6 +85,10 @@ test('Wurf: "Datenherkunft"-Button öffnet den Nachverfolgungs-Dialog', async ({
|
|||||||
const dialog = page.getByRole('dialog', { name: p.dialogTitle })
|
const dialog = page.getByRole('dialog', { name: p.dialogTitle })
|
||||||
await expect(dialog).toBeVisible()
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
|
// VERLAUF: dateibezogene Schritte des Wurfs.
|
||||||
|
await expect(dialog).toContainText(p.historyTitle)
|
||||||
|
await expect(dialog).toContainText('Wurf aus Wurfchronik „Wurfchronik Teil 1_page_0009.md“.')
|
||||||
|
|
||||||
await expect(dialog).toContainText(p.sourceFilesTitle)
|
await expect(dialog).toContainText(p.sourceFilesTitle)
|
||||||
await expect(dialog).toContainText('Wurfchronik Teil 1_page_0009.md')
|
await expect(dialog).toContainText('Wurfchronik Teil 1_page_0009.md')
|
||||||
await expect(dialog).toContainText(p.mergedCount(2))
|
await expect(dialog).toContainText(p.mergedCount(2))
|
||||||
|
|||||||
@@ -88,6 +88,13 @@ export interface GerbilProvenance {
|
|||||||
parentConfidence?: string
|
parentConfidence?: string
|
||||||
/** Menschlich lesbare Herkunftshinweise (deutsch). */
|
/** Menschlich lesbare Herkunftshinweise (deutsch). */
|
||||||
notes: string[]
|
notes: string[]
|
||||||
|
/**
|
||||||
|
* Chronologischer, dateibezogener Verlauf (deutsch): liest sich wie ein
|
||||||
|
* Protokoll, WELCHE Quelldatei WELCHE Angabe beigetragen hat (z. B.
|
||||||
|
* „Geburtsdatum (27.03.2022) aus ‚Stammbaum von X.xlsx'."). Primärinhalt des
|
||||||
|
* Datenherkunft-Dialogs.
|
||||||
|
*/
|
||||||
|
history: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ function parseProvenance(raw?: string | null): EntityProvenance | null {
|
|||||||
parentMethod: p.parentMethod,
|
parentMethod: p.parentMethod,
|
||||||
parentConfidence: p.parentConfidence,
|
parentConfidence: p.parentConfidence,
|
||||||
notes: Array.isArray(p.notes) ? p.notes : [],
|
notes: Array.isArray(p.notes) ? p.notes : [],
|
||||||
|
history: Array.isArray(p.history) ? p.history : [],
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
@@ -88,6 +89,19 @@ export default function ProvenanceDialog({ open, onClose, provenance, entityLabe
|
|||||||
<>
|
<>
|
||||||
<p className="provenance__intro">{entityLabel ? t.introFor(entityLabel) : t.intro}</p>
|
<p className="provenance__intro">{entityLabel ? t.introFor(entityLabel) : t.intro}</p>
|
||||||
|
|
||||||
|
{p.history.length > 0 && (
|
||||||
|
<section className="provenance__section">
|
||||||
|
<div className="provenance__section-title">{t.historyTitle}</div>
|
||||||
|
<ol className="provenance__history">
|
||||||
|
{p.history.map((step, i) => (
|
||||||
|
<li key={`${i}-${step}`} className="provenance__history-step">
|
||||||
|
{step}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<section className="provenance__section">
|
<section className="provenance__section">
|
||||||
<div className="provenance__section-title">
|
<div className="provenance__section-title">
|
||||||
{t.mergedTitle}
|
{t.mergedTitle}
|
||||||
|
|||||||
@@ -149,6 +149,20 @@
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Chronologischer Verlauf: liest sich wie ein Protokoll der Datenherkunft. */
|
||||||
|
.provenance__history {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provenance__history-step {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
.provenance__actions {
|
.provenance__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
|||||||
@@ -894,6 +894,8 @@ export const de = {
|
|||||||
contact: 'dieses Kontakts',
|
contact: 'dieses Kontakts',
|
||||||
litter: 'dieses Wurfs',
|
litter: 'dieses Wurfs',
|
||||||
},
|
},
|
||||||
|
/** Überschrift des chronologischen, dateibezogenen Verlaufs (Primärinhalt). */
|
||||||
|
historyTitle: 'Verlauf',
|
||||||
/** Überschriften / Feldbeschriftungen. */
|
/** Überschriften / Feldbeschriftungen. */
|
||||||
sourceFilesTitle: 'Quelldateien',
|
sourceFilesTitle: 'Quelldateien',
|
||||||
sourceFilesCount: (n: number) => (n === 1 ? 'aus 1 Quelle' : `aus ${n} Quellen`),
|
sourceFilesCount: (n: number) => (n === 1 ? 'aus 1 Quelle' : `aus ${n} Quellen`),
|
||||||
|
|||||||
@@ -23,14 +23,17 @@ def generate_guid(key_str):
|
|||||||
return str(uuid.uuid5(uuid.NAMESPACE_DNS, key_str))
|
return str(uuid.uuid5(uuid.NAMESPACE_DNS, key_str))
|
||||||
|
|
||||||
def build_entity_provenance(source_files, merged_record_count, notes=None,
|
def build_entity_provenance(source_files, merged_record_count, notes=None,
|
||||||
from_wurfchronik=None, extra=None):
|
from_wurfchronik=None, extra=None, history=None):
|
||||||
"""Generic data-provenance JSON builder shared by gerbils, contacts and
|
"""Generic data-provenance JSON builder shared by gerbils, contacts and
|
||||||
litters. Mirrors the GerbilProvenance frontend contract:
|
litters. Mirrors the GerbilProvenance frontend contract:
|
||||||
{ sourceFiles, mergedRecordCount, fromWurfchronik, notes, ... }
|
{ sourceFiles, mergedRecordCount, fromWurfchronik, notes, history, ... }
|
||||||
`source_files` is any iterable of filenames; `from_wurfchronik` is auto-
|
`source_files` is any iterable of filenames; `from_wurfchronik` is auto-
|
||||||
derived from the filenames when left as None. `extra` may carry entity-
|
derived from the filenames when left as None. `extra` may carry entity-
|
||||||
specific keys (e.g. parentMethod/parentConfidence for gerbils). Returns a
|
specific keys (e.g. parentMethod/parentConfidence for gerbils). `history`
|
||||||
JSON string (stored on the nullable Provenance text column)."""
|
is an ordered list of human-readable German lines that read like a
|
||||||
|
chronological log of where each fact came from (the primary content shown
|
||||||
|
in the Datenherkunft dialog). Returns a JSON string (stored on the nullable
|
||||||
|
Provenance text column)."""
|
||||||
files = sorted({f for f in source_files if f})
|
files = sorted({f for f in source_files if f})
|
||||||
if from_wurfchronik is None:
|
if from_wurfchronik is None:
|
||||||
from_wurfchronik = any("wurfchronik" in f.lower() for f in files)
|
from_wurfchronik = any("wurfchronik" in f.lower() for f in files)
|
||||||
@@ -39,6 +42,7 @@ def build_entity_provenance(source_files, merged_record_count, notes=None,
|
|||||||
"mergedRecordCount": merged_record_count,
|
"mergedRecordCount": merged_record_count,
|
||||||
"fromWurfchronik": bool(from_wurfchronik),
|
"fromWurfchronik": bool(from_wurfchronik),
|
||||||
"notes": list(notes or []),
|
"notes": list(notes or []),
|
||||||
|
"history": list(history or []),
|
||||||
}
|
}
|
||||||
if extra:
|
if extra:
|
||||||
for k, v in extra.items():
|
for k, v in extra.items():
|
||||||
@@ -46,6 +50,58 @@ def build_entity_provenance(source_files, merged_record_count, notes=None,
|
|||||||
prov[k] = v
|
prov[k] = v
|
||||||
return json.dumps(prov, ensure_ascii=False)
|
return json.dumps(prov, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _quote_file(fname):
|
||||||
|
"""German typographic quotes around a source filename for history lines."""
|
||||||
|
return f"„{fname}“"
|
||||||
|
|
||||||
|
|
||||||
|
# Human-readable German labels for the significant fields we attribute to files.
|
||||||
|
PROV_FIELD_LABELS = {
|
||||||
|
"DateOfBirth": "Geburtsdatum",
|
||||||
|
"DateOfDeath": "Sterbedatum",
|
||||||
|
"Gender": "Geschlecht",
|
||||||
|
"Genotype": "Genotyp",
|
||||||
|
"ColorVarietyId": "Farbschlag",
|
||||||
|
"Name": "Name",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _primary_file_of(record):
|
||||||
|
"""The single most representative source file of one raw record.
|
||||||
|
|
||||||
|
Prefers an explicit Stammbaum/Wurfchronik filename from ImportSource, else
|
||||||
|
the record's _filename. Used to attribute a field value to a concrete file
|
||||||
|
in the history log."""
|
||||||
|
fn = record.get("_filename")
|
||||||
|
if fn:
|
||||||
|
return fn
|
||||||
|
imp = record.get("ImportSource")
|
||||||
|
if imp:
|
||||||
|
first = str(imp).split(",")[0].strip()
|
||||||
|
if first:
|
||||||
|
return first
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _record_source_files(g):
|
||||||
|
"""All distinct source files a single raw gerbil record drew from.
|
||||||
|
|
||||||
|
ImportSource is either a comma-joined Stammbaum file list, a single
|
||||||
|
Wurfchronik filename, or the per-litter filename; _filename is the primary
|
||||||
|
file. We union both so nothing is lost."""
|
||||||
|
files = set()
|
||||||
|
imp = g.get("ImportSource")
|
||||||
|
if imp:
|
||||||
|
for part in str(imp).split(","):
|
||||||
|
part = part.strip()
|
||||||
|
if part:
|
||||||
|
files.add(part)
|
||||||
|
fn = g.get("_filename")
|
||||||
|
if fn:
|
||||||
|
files.add(fn)
|
||||||
|
return files
|
||||||
|
|
||||||
def to_valid_guid(val):
|
def to_valid_guid(val):
|
||||||
if not val:
|
if not val:
|
||||||
return None
|
return None
|
||||||
@@ -657,6 +713,114 @@ def pick_parent_ref(parent_refs, role, child_dob, avoid_name=None, gender_of=Non
|
|||||||
return role_refs[order[0]]
|
return role_refs[order[0]]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_gerbil_history(records, best_g, field_source, parent_method=None,
|
||||||
|
any_decision=False, any_conflict=False, conflict_notes=None):
|
||||||
|
"""Build an ordered, file-attributed German history for a resolved gerbil.
|
||||||
|
|
||||||
|
Reads like a chronological log:
|
||||||
|
• „In ‚X.xlsx' gefunden."
|
||||||
|
• „Geburtsdatum (27.03.2022) aus ‚X.xlsx'."
|
||||||
|
• „Auch in ‚Y.xlsx' gefunden → Datensätze zusammengeführt."
|
||||||
|
• „Genotyp aus ‚Z.xlsx'."
|
||||||
|
• „Eltern über Position im Stammbaum erkannt (Quelle: ‚X.xlsx')."
|
||||||
|
• „Aus Wurfchronik übernommen."
|
||||||
|
|
||||||
|
`field_source` maps a field name to the raw record that supplied its final
|
||||||
|
value; when present we name that record's file, otherwise we fall back to
|
||||||
|
the primary record. The records are visited in a stable order (primary
|
||||||
|
first, then the rest sorted by file) so the log is deterministic."""
|
||||||
|
conflict_notes = conflict_notes or []
|
||||||
|
history = []
|
||||||
|
|
||||||
|
# Order records: best_g first, then others by primary file name (stable).
|
||||||
|
others = [r for r in records if r is not best_g]
|
||||||
|
others.sort(key=lambda r: (_primary_file_of(r) or ""))
|
||||||
|
ordered = [best_g] + others
|
||||||
|
|
||||||
|
best_file = _primary_file_of(best_g)
|
||||||
|
if best_file:
|
||||||
|
history.append(f"In {_quote_file(best_file)} gefunden.")
|
||||||
|
else:
|
||||||
|
history.append("Im Import gefunden.")
|
||||||
|
|
||||||
|
# Field-by-field attribution: name the file that supplied each fact.
|
||||||
|
def attr_line(field, formatter):
|
||||||
|
rec = field_source.get(field) or best_g
|
||||||
|
val = best_g.get(field)
|
||||||
|
if not val or val == "unknown":
|
||||||
|
return
|
||||||
|
fname = _primary_file_of(rec)
|
||||||
|
label = PROV_FIELD_LABELS.get(field, field)
|
||||||
|
text = formatter(label, val)
|
||||||
|
if fname:
|
||||||
|
history.append(f"{text} aus {_quote_file(fname)}.")
|
||||||
|
else:
|
||||||
|
history.append(f"{text} (Quelle unbekannt).")
|
||||||
|
|
||||||
|
attr_line("DateOfBirth", lambda label, v: f"{label} ({_de_date(v)})")
|
||||||
|
attr_line("Gender", lambda label, v: f"{label} ({_de_gender(v)})")
|
||||||
|
attr_line("Genotype", lambda label, v: f"{label}")
|
||||||
|
attr_line("ColorVarietyId", lambda label, v: f"{label}")
|
||||||
|
attr_line("DateOfDeath", lambda label, v: f"{label} ({_de_date(v)})")
|
||||||
|
|
||||||
|
# Merge step: every additional record that contributed.
|
||||||
|
for r in others:
|
||||||
|
fname = _primary_file_of(r)
|
||||||
|
if fname:
|
||||||
|
history.append(
|
||||||
|
f"Auch in {_quote_file(fname)} gefunden → Datensätze zusammengeführt."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
history.append("In weiterem Datensatz gefunden → Datensätze zusammengeführt.")
|
||||||
|
|
||||||
|
# Parent derivation.
|
||||||
|
if parent_method:
|
||||||
|
method_label = {
|
||||||
|
"chart-position": "Position im Stammbaum",
|
||||||
|
"geburtsdatum+eltern": "Geburtsdatum und Elternnamen",
|
||||||
|
"nur-geburtsdatum": "Geburtsdatum",
|
||||||
|
"decision": "manuelle Entscheidung",
|
||||||
|
}.get(parent_method, parent_method)
|
||||||
|
# The parent evidence comes from a Stammbaum chart — attribute to the
|
||||||
|
# primary record's file when it is a Stammbaum.
|
||||||
|
parent_file = best_file if best_file and "stammbaum" in best_file.lower() else None
|
||||||
|
if parent_file:
|
||||||
|
history.append(
|
||||||
|
f"Eltern über {method_label} erkannt (Quelle: {_quote_file(parent_file)})."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
history.append(f"Eltern über {method_label} erkannt.")
|
||||||
|
|
||||||
|
# Manual decisions / conflicts.
|
||||||
|
if any_decision:
|
||||||
|
history.append("Zuordnung per manueller Entscheidung getroffen.")
|
||||||
|
if any_conflict:
|
||||||
|
history.append("Konflikt per Entscheidung gelöst.")
|
||||||
|
for cn in conflict_notes:
|
||||||
|
if cn and cn not in history:
|
||||||
|
history.append(cn + ".")
|
||||||
|
|
||||||
|
# Wurfchronik provenance line.
|
||||||
|
if any("wurfchronik" in f.lower() for r in records for f in _record_source_files(r)):
|
||||||
|
history.append("Angaben aus der Wurfchronik übernommen.")
|
||||||
|
|
||||||
|
return history
|
||||||
|
|
||||||
|
|
||||||
|
def _de_date(iso):
|
||||||
|
"""YYYY-MM-DD → DD.MM.YYYY for display; pass through anything else."""
|
||||||
|
if not iso:
|
||||||
|
return iso
|
||||||
|
m = re.match(r"^(\d{4})-(\d{2})-(\d{2})$", str(iso))
|
||||||
|
if m:
|
||||||
|
return f"{m.group(3)}.{m.group(2)}.{m.group(1)}"
|
||||||
|
return iso
|
||||||
|
|
||||||
|
|
||||||
|
def _de_gender(g):
|
||||||
|
return {"male": "männlich", "female": "weiblich"}.get(g, g)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
print("Loading color variety seeds...")
|
print("Loading color variety seeds...")
|
||||||
variety_map = {}
|
variety_map = {}
|
||||||
@@ -1721,30 +1885,14 @@ def main():
|
|||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _record_source_files(g):
|
def build_provenance(records, best_g, extra_notes=None, field_source=None):
|
||||||
"""All distinct source files a single raw record drew from.
|
|
||||||
|
|
||||||
ImportSource is either a comma-joined Stammbaum file list, a single
|
|
||||||
Wurfchronik filename, or the per-litter filename; _filename is the
|
|
||||||
primary file. We union both so nothing is lost."""
|
|
||||||
files = set()
|
|
||||||
imp = g.get("ImportSource")
|
|
||||||
if imp:
|
|
||||||
for part in str(imp).split(","):
|
|
||||||
part = part.strip()
|
|
||||||
if part:
|
|
||||||
files.add(part)
|
|
||||||
fn = g.get("_filename")
|
|
||||||
if fn:
|
|
||||||
files.add(fn)
|
|
||||||
return files
|
|
||||||
|
|
||||||
def build_provenance(records, best_g, extra_notes=None):
|
|
||||||
"""Aggregate data-provenance across every raw record merged into one
|
"""Aggregate data-provenance across every raw record merged into one
|
||||||
resolved gerbil. Returns a JSON string (stored on the Gerbil entity as a
|
resolved gerbil. Returns a JSON string (stored on the Gerbil entity as a
|
||||||
nullable text column) so the Rennmausakte can show where the entry came
|
nullable text column) so the Rennmausakte can show where the entry came
|
||||||
from. `records` is the list of raw records that became this gerbil;
|
from. `records` is the list of raw records that became this gerbil;
|
||||||
`best_g` is the chosen primary record."""
|
`best_g` is the chosen primary record. `field_source` maps a significant
|
||||||
|
field name (DateOfBirth/Genotype/…) to the raw record that supplied its
|
||||||
|
final value, so the history can name the exact file for each fact."""
|
||||||
source_files = set()
|
source_files = set()
|
||||||
from_wurfchronik = False
|
from_wurfchronik = False
|
||||||
any_conflict = False
|
any_conflict = False
|
||||||
@@ -1783,12 +1931,21 @@ def main():
|
|||||||
if n and n not in notes:
|
if n and n not in notes:
|
||||||
notes.append(n)
|
notes.append(n)
|
||||||
|
|
||||||
|
history = _build_gerbil_history(
|
||||||
|
records, best_g, field_source or {},
|
||||||
|
parent_method=parent_method,
|
||||||
|
any_decision=any_decision,
|
||||||
|
any_conflict=any_conflict,
|
||||||
|
conflict_notes=extra_notes or [],
|
||||||
|
)
|
||||||
|
|
||||||
return build_entity_provenance(
|
return build_entity_provenance(
|
||||||
source_files,
|
source_files,
|
||||||
merged_count,
|
merged_count,
|
||||||
notes=notes,
|
notes=notes,
|
||||||
from_wurfchronik=from_wurfchronik,
|
from_wurfchronik=from_wurfchronik,
|
||||||
extra={"parentMethod": parent_method, "parentConfidence": parent_confidence},
|
extra={"parentMethod": parent_method, "parentConfidence": parent_confidence},
|
||||||
|
history=history,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Group gerbils by name to perform deduplication
|
# Group gerbils by name to perform deduplication
|
||||||
@@ -1861,13 +2018,24 @@ def main():
|
|||||||
merged_notes = []
|
merged_notes = []
|
||||||
if best_g["Notes"]:
|
if best_g["Notes"]:
|
||||||
merged_notes.append(best_g["Notes"])
|
merged_notes.append(best_g["Notes"])
|
||||||
|
|
||||||
# Merge photos
|
# Merge photos
|
||||||
merged_photos = list(best_g.get("_photos", []))
|
merged_photos = list(best_g.get("_photos", []))
|
||||||
|
|
||||||
# Track sources for debugging
|
# Track sources for debugging
|
||||||
sources = [best_g["_filename"]]
|
sources = [best_g["_filename"]]
|
||||||
|
|
||||||
|
# Per-field file attribution: which raw record supplied each final
|
||||||
|
# field value. Seed with best_g for every field it already carries;
|
||||||
|
# the fill loop and voting loop update it as winners change.
|
||||||
|
ATTRIB_FIELDS = ["DateOfBirth", "DateOfDeath", "Gender", "Genotype",
|
||||||
|
"ColorVarietyId", "Name"]
|
||||||
|
field_source = {}
|
||||||
|
for fld in ATTRIB_FIELDS:
|
||||||
|
v = best_g.get(fld)
|
||||||
|
if v and v != "unknown":
|
||||||
|
field_source[fld] = best_g
|
||||||
|
|
||||||
for g in sub:
|
for g in sub:
|
||||||
if g == best_g:
|
if g == best_g:
|
||||||
continue
|
continue
|
||||||
@@ -1884,16 +2052,20 @@ def main():
|
|||||||
best_g["LitterId"] = g["LitterId"]
|
best_g["LitterId"] = g["LitterId"]
|
||||||
if not best_g["DateOfBirth"] and g["DateOfBirth"]:
|
if not best_g["DateOfBirth"] and g["DateOfBirth"]:
|
||||||
best_g["DateOfBirth"] = g["DateOfBirth"]
|
best_g["DateOfBirth"] = g["DateOfBirth"]
|
||||||
|
field_source["DateOfBirth"] = g
|
||||||
if not best_g["DateOfDeath"] and g["DateOfDeath"]:
|
if not best_g["DateOfDeath"] and g["DateOfDeath"]:
|
||||||
best_g["DateOfDeath"] = g["DateOfDeath"]
|
best_g["DateOfDeath"] = g["DateOfDeath"]
|
||||||
|
field_source["DateOfDeath"] = g
|
||||||
if not best_g["CauseOfDeath"] and g["CauseOfDeath"]:
|
if not best_g["CauseOfDeath"] and g["CauseOfDeath"]:
|
||||||
best_g["CauseOfDeath"] = g["CauseOfDeath"]
|
best_g["CauseOfDeath"] = g["CauseOfDeath"]
|
||||||
if not best_g["GoHomeDate"] and g["GoHomeDate"]:
|
if not best_g["GoHomeDate"] and g["GoHomeDate"]:
|
||||||
best_g["GoHomeDate"] = g["GoHomeDate"]
|
best_g["GoHomeDate"] = g["GoHomeDate"]
|
||||||
if not best_g["Genotype"] and g["Genotype"]:
|
if not best_g["Genotype"] and g["Genotype"]:
|
||||||
best_g["Genotype"] = g["Genotype"]
|
best_g["Genotype"] = g["Genotype"]
|
||||||
|
field_source["Genotype"] = g
|
||||||
if not best_g["ColorVarietyId"] and g["ColorVarietyId"]:
|
if not best_g["ColorVarietyId"] and g["ColorVarietyId"]:
|
||||||
best_g["ColorVarietyId"] = g["ColorVarietyId"]
|
best_g["ColorVarietyId"] = g["ColorVarietyId"]
|
||||||
|
field_source["ColorVarietyId"] = g
|
||||||
if not best_g["OriginContactId"] and g["OriginContactId"]:
|
if not best_g["OriginContactId"] and g["OriginContactId"]:
|
||||||
best_g["OriginContactId"] = g["OriginContactId"]
|
best_g["OriginContactId"] = g["OriginContactId"]
|
||||||
if not best_g["ReceiverContactId"] and g["ReceiverContactId"]:
|
if not best_g["ReceiverContactId"] and g["ReceiverContactId"]:
|
||||||
@@ -1904,10 +2076,12 @@ def main():
|
|||||||
# Reconcile Gender: prefer a known gender over unknown, and prefer stammbaum over other sources
|
# Reconcile Gender: prefer a known gender over unknown, and prefer stammbaum over other sources
|
||||||
if best_g["Gender"] == "unknown" and g["Gender"] != "unknown":
|
if best_g["Gender"] == "unknown" and g["Gender"] != "unknown":
|
||||||
best_g["Gender"] = g["Gender"]
|
best_g["Gender"] = g["Gender"]
|
||||||
|
field_source["Gender"] = g
|
||||||
elif best_g["Gender"] != "unknown" and g["Gender"] != "unknown" and best_g["Gender"] != g["Gender"]:
|
elif best_g["Gender"] != "unknown" and g["Gender"] != "unknown" and best_g["Gender"] != g["Gender"]:
|
||||||
if g["ImportSource"] and "stammbaum" in g["ImportSource"].lower():
|
if g["ImportSource"] and "stammbaum" in g["ImportSource"].lower():
|
||||||
if not best_g["ImportSource"] or "stammbaum" not in best_g["ImportSource"].lower():
|
if not best_g["ImportSource"] or "stammbaum" not in best_g["ImportSource"].lower():
|
||||||
best_g["Gender"] = g["Gender"]
|
best_g["Gender"] = g["Gender"]
|
||||||
|
field_source["Gender"] = g
|
||||||
|
|
||||||
# Status precedence: Deceased > GivenAway > Breeding/Pet
|
# Status precedence: Deceased > GivenAway > Breeding/Pet
|
||||||
if g["Status"] == "Deceased":
|
if g["Status"] == "Deceased":
|
||||||
@@ -1935,17 +2109,15 @@ def main():
|
|||||||
# If the records disagreed on a field, the merge had to pick a
|
# If the records disagreed on a field, the merge had to pick a
|
||||||
# winner — record that as a provenance note.
|
# winner — record that as a provenance note.
|
||||||
if len(votes) > 1:
|
if len(votes) > 1:
|
||||||
FIELD_LABEL = {
|
|
||||||
"DateOfBirth": "Geburtsdatum",
|
|
||||||
"DateOfDeath": "Sterbedatum",
|
|
||||||
"Gender": "Geschlecht",
|
|
||||||
"Genotype": "Genotyp",
|
|
||||||
"ColorVarietyId": "Farbschlag",
|
|
||||||
}
|
|
||||||
conflict_notes.append(
|
conflict_notes.append(
|
||||||
f"Konflikt bei {FIELD_LABEL[field]} per Mehrheitsentscheidung gelöst"
|
f"Konflikt bei {PROV_FIELD_LABELS[field]} per Mehrheitsentscheidung gelöst"
|
||||||
)
|
)
|
||||||
best_g[field] = best_val
|
best_g[field] = best_val
|
||||||
|
# Attribute the winning value to a record that actually holds
|
||||||
|
# it, so the history names the right file.
|
||||||
|
winner = next((g for g in sub if g.get(field) == best_val), None)
|
||||||
|
if winner is not None:
|
||||||
|
field_source[field] = winner
|
||||||
# Keep helper fields in sync if we changed DateOfBirth
|
# Keep helper fields in sync if we changed DateOfBirth
|
||||||
if field == "DateOfBirth":
|
if field == "DateOfBirth":
|
||||||
best_g["_birth_date"] = best_val
|
best_g["_birth_date"] = best_val
|
||||||
@@ -1955,7 +2127,9 @@ def main():
|
|||||||
best_g["Notes"] = " | ".join(merged_notes)
|
best_g["Notes"] = " | ".join(merged_notes)
|
||||||
|
|
||||||
best_g["_photos"] = merged_photos
|
best_g["_photos"] = merged_photos
|
||||||
best_g["Provenance"] = build_provenance(sub, best_g, extra_notes=conflict_notes)
|
best_g["Provenance"] = build_provenance(
|
||||||
|
sub, best_g, extra_notes=conflict_notes, field_source=field_source
|
||||||
|
)
|
||||||
|
|
||||||
# Print merge trace
|
# Print merge trace
|
||||||
print(f"Deduplicated same-animal name '{best_g['Name']}': merged {len(sub)} entries across files: {', '.join(sources)}")
|
print(f"Deduplicated same-animal name '{best_g['Name']}': merged {len(sub)} entries across files: {', '.join(sources)}")
|
||||||
@@ -2246,9 +2420,31 @@ def main():
|
|||||||
if l_merged_count > 1:
|
if l_merged_count > 1:
|
||||||
l_notes.append(f"aus {l_merged_count} Datensätzen zusammengeführt")
|
l_notes.append(f"aus {l_merged_count} Datensätzen zusammengeführt")
|
||||||
l_notes.append("Geschwister-Würfe zusammengeführt")
|
l_notes.append("Geschwister-Würfe zusammengeführt")
|
||||||
|
|
||||||
|
# Chronological, file-attributed history for the litter.
|
||||||
|
l_files_sorted = sorted({f for f in l_source_files if f})
|
||||||
|
l_history = []
|
||||||
|
first_file = l_files_sorted[0] if l_files_sorted else None
|
||||||
|
if is_virtual and not l_from_wurfchronik:
|
||||||
|
if first_file:
|
||||||
|
l_history.append(
|
||||||
|
f"Aus Stammbaum-Diagramm rekonstruiert ({_quote_file(first_file)})."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
l_history.append("Aus Stammbaum-Diagramm rekonstruiert.")
|
||||||
|
elif first_file:
|
||||||
|
l_history.append(f"Wurf aus Wurfchronik {_quote_file(first_file)}.")
|
||||||
|
else:
|
||||||
|
l_history.append("Wurf im Import gefunden.")
|
||||||
|
for f in l_files_sorted[1:]:
|
||||||
|
l_history.append(
|
||||||
|
f"Auch in {_quote_file(f)} gefunden → Datensätze zusammengeführt."
|
||||||
|
)
|
||||||
|
if l_merged_count > 1:
|
||||||
|
l_history.append("Geschwister-Würfe zusammengeführt.")
|
||||||
l["Provenance"] = build_entity_provenance(
|
l["Provenance"] = build_entity_provenance(
|
||||||
l_source_files, l_merged_count, notes=l_notes,
|
l_source_files, l_merged_count, notes=l_notes,
|
||||||
from_wurfchronik=l_from_wurfchronik,
|
from_wurfchronik=l_from_wurfchronik, history=l_history,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Set IsBreeder and IsReceiver flags on contacts
|
# Set IsBreeder and IsReceiver flags on contacts
|
||||||
@@ -2275,7 +2471,24 @@ def main():
|
|||||||
c_notes.append("als Züchter erkannt")
|
c_notes.append("als Züchter erkannt")
|
||||||
if is_receiver:
|
if is_receiver:
|
||||||
c_notes.append("als Abnehmer erkannt")
|
c_notes.append("als Abnehmer erkannt")
|
||||||
c["Provenance"] = build_entity_provenance(c_source_files, c_merged_count, notes=c_notes)
|
|
||||||
|
# Chronological, file-attributed history for the contact.
|
||||||
|
c_files_sorted = sorted({f for f in c_source_files if f})
|
||||||
|
c_history = []
|
||||||
|
role_word = "Züchter" if is_breeder else "Abnehmer"
|
||||||
|
if c_files_sorted:
|
||||||
|
c_history.append(f"In {_quote_file(c_files_sorted[0])} als {role_word} erkannt.")
|
||||||
|
for f in c_files_sorted[1:]:
|
||||||
|
c_history.append(
|
||||||
|
f"Auch in {_quote_file(f)} gefunden → Datensätze zusammengeführt."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
c_history.append(f"Im Import als {role_word} erkannt.")
|
||||||
|
if is_breeder and is_receiver:
|
||||||
|
c_history.append("Sowohl als Züchter als auch als Abnehmer geführt.")
|
||||||
|
c["Provenance"] = build_entity_provenance(
|
||||||
|
c_source_files, c_merged_count, notes=c_notes, history=c_history
|
||||||
|
)
|
||||||
|
|
||||||
# Set and map gerbilPhotos
|
# Set and map gerbilPhotos
|
||||||
resolved_photos = []
|
resolved_photos = []
|
||||||
|
|||||||
@@ -213,6 +213,70 @@ mr = m.pick_parent_ref(molly_refs, "mother", "13.09.2021", avoid_name=fr["name"]
|
|||||||
check("pick(gender): mother = Arya (female)", mr and mr["name"].startswith("Arya"))
|
check("pick(gender): mother = Arya (female)", mr and mr["name"].startswith("Arya"))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Provenance history: chronological, file-attributed German log ──
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
|
||||||
|
def _hist(prov_json):
|
||||||
|
return _json.loads(prov_json)["history"]
|
||||||
|
|
||||||
|
|
||||||
|
# build_entity_provenance carries history through verbatim.
|
||||||
|
_prov = _json.loads(
|
||||||
|
m.build_entity_provenance(["A.xlsx"], 1, notes=["x"], history=["line one"])
|
||||||
|
)
|
||||||
|
check("build_entity_provenance includes history key", _prov.get("history") == ["line one"])
|
||||||
|
check("build_entity_provenance defaults history to []",
|
||||||
|
_json.loads(m.build_entity_provenance(["A.xlsx"], 1)).get("history") == [])
|
||||||
|
|
||||||
|
# Single-record gerbil history: names the file and the per-field facts.
|
||||||
|
g_single = {
|
||||||
|
"Name": "Picus", "DateOfBirth": "2022-03-27", "Gender": "male",
|
||||||
|
"Genotype": "aa", "ColorVarietyId": None, "DateOfDeath": None,
|
||||||
|
"ImportSource": "Stammbaum von Picus Son.xlsx",
|
||||||
|
"_filename": "Stammbaum von Picus Son.xlsx", "parentRefs": [],
|
||||||
|
}
|
||||||
|
h = m._build_gerbil_history([g_single], g_single, {})
|
||||||
|
check("history: first line names the source file",
|
||||||
|
h[0] == "In „Stammbaum von Picus Son.xlsx“ gefunden.")
|
||||||
|
check("history: dob line names file + formatted date",
|
||||||
|
"Geburtsdatum (27.03.2022) aus „Stammbaum von Picus Son.xlsx“." in h)
|
||||||
|
check("history: gender line is German + file-attributed",
|
||||||
|
"Geschlecht (männlich) aus „Stammbaum von Picus Son.xlsx“." in h)
|
||||||
|
check("history: genotype line file-attributed",
|
||||||
|
"Genotyp aus „Stammbaum von Picus Son.xlsx“." in h)
|
||||||
|
|
||||||
|
# Merged gerbil: a field sourced from a DIFFERENT file is attributed to THAT file.
|
||||||
|
g_best = {
|
||||||
|
"Name": "Solice", "DateOfBirth": "2022-03-27", "Gender": "male",
|
||||||
|
"Genotype": None, "ColorVarietyId": None, "DateOfDeath": None,
|
||||||
|
"ImportSource": "Stammbaum von Picus Son.xlsx",
|
||||||
|
"_filename": "Stammbaum von Picus Son.xlsx", "parentRefs": [],
|
||||||
|
}
|
||||||
|
g_other = {
|
||||||
|
"Name": "Solice", "DateOfBirth": "2022-03-27", "Gender": "male",
|
||||||
|
"Genotype": "aa", "ColorVarietyId": None, "DateOfDeath": None,
|
||||||
|
"ImportSource": "Wurfchronik-Detail.docx",
|
||||||
|
"_filename": "Wurfchronik-Detail.docx", "parentRefs": [],
|
||||||
|
}
|
||||||
|
# Genotype was filled from g_other → its line must name the docx file.
|
||||||
|
g_best["Genotype"] = "aa"
|
||||||
|
fs = {"DateOfBirth": g_best, "Gender": g_best, "Genotype": g_other}
|
||||||
|
h2 = m._build_gerbil_history([g_best, g_other], g_best, fs)
|
||||||
|
check("history(merge): genotype attributed to the file that supplied it",
|
||||||
|
"Genotyp aus „Wurfchronik-Detail.docx“." in h2)
|
||||||
|
check("history(merge): dob attributed to primary file",
|
||||||
|
"Geburtsdatum (27.03.2022) aus „Stammbaum von Picus Son.xlsx“." in h2)
|
||||||
|
check("history(merge): merge line names the absorbed file",
|
||||||
|
"Auch in „Wurfchronik-Detail.docx“ gefunden → Datensätze zusammengeführt." in h2)
|
||||||
|
check("history(merge): Wurfchronik line present",
|
||||||
|
"Angaben aus der Wurfchronik übernommen." in h2)
|
||||||
|
|
||||||
|
# Date formatting helper.
|
||||||
|
check("_de_date: ISO → DD.MM.YYYY", m._de_date("2022-03-27") == "27.03.2022")
|
||||||
|
check("_de_date: passes through non-ISO", m._de_date("unbekannt") == "unbekannt")
|
||||||
|
|
||||||
|
|
||||||
if check.failed:
|
if check.failed:
|
||||||
print(f"\n{check.failed} test(s) FAILED")
|
print(f"\n{check.failed} test(s) FAILED")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|||||||
Reference in New Issue
Block a user