feat(deploy): TrueNAS Custom-App + Auto-Deploy, plus aufgelaufene Arbeit
Deployment: - custom-app.compose.yaml: self-contained Compose fuer TrueNAS "Custom App" (absolute Host-Bind-Pfade, postgres:18, pull_policy always, Port 8090) - scripts/truenas-deploy.sh: Host-Skript create/redeploy via midclt (App bleibt unter Apps sichtbar) inkl. Image-Pull + Health-Check - ci.yml Deploy-Job: laeuft auf ubuntu-latest-Runner, kopiert Deploy-Dateien per SSH auf den NAS-Host und triggert truenas-deploy.sh (statt runs-on goldeye) - compose.yaml/.env.example: postgres:18 (Locale-Match zur Quell-DB), Port 8090 - .gitignore: .agents/, tools/rag/, deploy/truenas/.env (Secrets/Scratch) Aufgelaufene Feature-Arbeit (verified/Freeze, Migrationen, Import-Triage): - GerbilOverride/VerifiedGerbil-Endpoints + GerbilSnapshotService + Tests - EF-Migrationen (ShowInChronicle, Stillborn, BirthOrder, ManualFlag, DSGVO) - Frontend VerifizierteTierePage + verified-API + e2e-Spec - diverse Import-/Triage-Skripte und -Tests Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -487,6 +487,56 @@ _, _g_add = m.resolve_color_and_genotype("Agouti Schecke", "AA CC DD EE GG PP",
|
||||
check("schecke label + no Sp token -> appends Spsp", "Spsp" in _g_add)
|
||||
|
||||
|
||||
# ── parse_death_info: markerless death dates (Ticket ec9267b9) ──────────────────
|
||||
# A death keyword without a +/cross marker before the date must still yield the
|
||||
# death date. Status logic is unchanged; an existing dateOfDeath is never replaced.
|
||||
_st, _dod, _cod = m.parse_death_info(
|
||||
"Verstorbener Welpe am 30.03.15 an Durchfall nach frühem Abstillen wegen Tod der Mutter.",
|
||||
"Breeding", None, None)
|
||||
check("ec9267b9: markerless 'Verstorben ... am DD.MM.YY' -> Deceased", _st == "Deceased")
|
||||
check("ec9267b9: markerless death date parsed (30.03.15 -> 2015-03-30)", _dod == "2015-03-30")
|
||||
# 'Verstorben am DD.MM.YY' (the GivenAway-then-died notes) — date now captured.
|
||||
_st2, _dod2, _ = m.parse_death_info("Verstorben am 04.12.18 an Leberzyste", "GivenAway", None, None)
|
||||
check("ec9267b9: 'Verstorben am 04.12.18' -> Deceased + date", _st2 == "Deceased" and _dod2 == "2018-12-04")
|
||||
# Never overwrite an existing dateOfDeath.
|
||||
_st3, _dod3, _ = m.parse_death_info("Verstorben am 04.12.18", "Breeding", "2017-01-01", None)
|
||||
check("ec9267b9: existing dateOfDeath kept (not overwritten)", _dod3 == "2017-01-01")
|
||||
# No death keyword -> no spurious date / status change.
|
||||
_st4, _dod4, _ = m.parse_death_info("Geboren am 30.03.15", "Breeding", None, None)
|
||||
check("ec9267b9: no death keyword -> Status/date unchanged", _st4 == "Breeding" and _dod4 is None)
|
||||
|
||||
|
||||
# ── enrich_from_contracts: excludeContractMatch gate (parent-not-pup) ───────────
|
||||
# A parent animal pinned out via excludeContractMatch must NEVER receive a
|
||||
# contract's receiver/gohome/GivenAway, even when its call-name matches.
|
||||
_excl_gerbils = [{
|
||||
"Id": "g-makoto", "Name": "Makoto von den Kleinen Chaoten", "Gender": "male",
|
||||
"Status": "Breeding", "DateOfBirth": "2016-05-01", "ReceiverContactId": None,
|
||||
"GoHomeDate": None, "OriginBreeder": "Zucht der kleinen Chaoten",
|
||||
"ExternalRef": "stammbaum-makoto", "Provenance": None,
|
||||
}]
|
||||
_excl_contract = [{
|
||||
"sourceFile": "Zucht der kleinen Chaoten _ Balu (Makoto.Ella)-Thomas Weickert.docx",
|
||||
"buyer": "Thomas Weickert", "animals": ["Makoto"], "color": "",
|
||||
"gender": "", "dob": "", "handoverDate": "2017-06-01", "contractDate": "2017-06-01",
|
||||
"price": "",
|
||||
}]
|
||||
_excl_decisions = [{"name": "Makoto", "dob": "2016-05-01", "excludeContractMatch": True}]
|
||||
_es, _ = m.enrich_from_contracts(_excl_contract, _excl_gerbils, {}, {},
|
||||
exclude_decisions=_excl_decisions)
|
||||
check("excludeContractMatch: pinned parent keeps no receiver",
|
||||
_excl_gerbils[0].get("ReceiverContactId") is None)
|
||||
check("excludeContractMatch: pinned parent stays Breeding (not GivenAway)",
|
||||
_excl_gerbils[0].get("Status") == "Breeding")
|
||||
check("excludeContractMatch: pinned parent gets no GoHomeDate",
|
||||
_excl_gerbils[0].get("GoHomeDate") is None)
|
||||
# Without the exclude, the same match WOULD set the receiver (control).
|
||||
_excl_gerbils2 = [dict(_excl_gerbils[0])]
|
||||
m.enrich_from_contracts(_excl_contract, _excl_gerbils2, {}, {}, exclude_decisions=[])
|
||||
check("excludeContractMatch: control (no exclude) -> receiver IS set",
|
||||
_excl_gerbils2[0].get("ReceiverContactId") is not None)
|
||||
|
||||
|
||||
# ── Integration: assert the resolved_import.json output reflects the ticket fixes ──
|
||||
# (Only when the pipeline has already been run; tolerant if the file is absent.)
|
||||
import os as _os, json as _json
|
||||
@@ -552,13 +602,112 @@ if _os.path.exists(_resolved):
|
||||
_roni = next((g for g in _d["gerbils"]
|
||||
if g["Name"] == "Roni" and g.get("DateOfBirth") == "2022-01-27"), None)
|
||||
check("Roni: gender flipped to male", _roni is not None and _roni.get("Gender") == "male")
|
||||
# Fumi: materialised stub that is the (Wurfchronik) MOTHER of T21/Z21/22 →
|
||||
# under the isResident-Sweep (Ticket 381f7e51) a parent of her OWN litters is
|
||||
# resident. (No isResident override in conflict-decisions, so the sweep decides.)
|
||||
_fumi = _find("Fumi von den Kleinen")
|
||||
check("Fumi: materialised as a non-resident stub",
|
||||
_fumi is not None and _fumi.get("IsResident") is False)
|
||||
check("Fumi: materialised; resident as a parent of her own Wurfchronik litters",
|
||||
_fumi is not None and _fumi.get("IsResident") is True)
|
||||
|
||||
# Sunny von PZ Karl: father corrected Hiro → Bill von Privat.
|
||||
_check_parents("Sunny (parents)", "Sunny von PZ Karl", "Bill von Privat", "Melly von Privat")
|
||||
|
||||
# ── Akane (Ticket 36a3fcde): addLitters injection + isResident/notes/receiver
|
||||
# overrides + ShowInChronicle. The manually-added litter must hang off the
|
||||
# EXISTING Akane and the new Bonaparte stub, link the three children, and be
|
||||
# hidden from the Wurfchronik. ───────────────────────────────────────────
|
||||
_akane = _find("Akane", "2023-01-21")
|
||||
check("Akane: record present (*2023-01-21)", _akane is not None)
|
||||
if _akane:
|
||||
check("Akane: isResident override == False", _akane.get("IsResident") is False)
|
||||
check("Akane: receiver = Ulrike Neu",
|
||||
_akane.get("ReceiverContactId") == "33130df1-e164-54ae-be74-a9b7d2ced11b")
|
||||
check("Akane: Lebenslauf-Notes mention Ulrike Neu",
|
||||
"Ulrike Neu" in (_akane.get("Notes") or ""))
|
||||
|
||||
# The injected litter (deterministic ExternalRef from the slug).
|
||||
_alit = next((l for l in _d["litters"]
|
||||
if l.get("ExternalRef") == "decision-litter-akaneswurfbeiclanofblackforest"), None)
|
||||
check("addLitters: Akane litter injected", _alit is not None)
|
||||
if _alit:
|
||||
check("addLitters: ShowInChronicle == False", _alit.get("ShowInChronicle") is False)
|
||||
check("addLitters: mother is Akane",
|
||||
_akane is not None and _alit.get("MotherId") == _akane["Id"])
|
||||
_bona = _G.get(_alit.get("FatherId"))
|
||||
check("addLitters: father is Bonaparte von den Schlossmäusen",
|
||||
_bona is not None and _bona["Name"].startswith("Bonaparte"))
|
||||
# The three children must point their LitterId at the injected litter.
|
||||
_kids = {g["Name"]: g for g in _d["gerbils"]
|
||||
if g.get("LitterId") == _alit["Id"]}
|
||||
for _kn in ("Merle", "Fanella", "Pete"):
|
||||
check(f"addLitters: child {_kn} linked to the litter", _kn in _kids)
|
||||
check("addLitters: Merle is the new resident stub (not 'Merle of Samsimar')",
|
||||
_kids.get("Merle") is not None and _kids["Merle"].get("IsResident") is True)
|
||||
check("addLitters: Pete resident", _kids.get("Pete") is not None
|
||||
and _kids["Pete"].get("IsResident") is True)
|
||||
check("addLitters: Fanella non-resident, receiver Ulrike Neu",
|
||||
_kids.get("Fanella") is not None
|
||||
and _kids["Fanella"].get("IsResident") is False
|
||||
and _kids["Fanella"].get("ReceiverContactId") == "33130df1-e164-54ae-be74-a9b7d2ced11b")
|
||||
|
||||
# ShowInChronicle (Ticket ea41257a): present on EVERY litter. The Wurfchronik
|
||||
# shows only the breeder's own documented litters. Pure ancestor pairings
|
||||
# (reconstructed from Stammbaum diagrams, NOT from the Wurfchronik, with NO
|
||||
# resident parent) are hidden; the Akane addLitter stays hidden too; every
|
||||
# real Wurfchronik litter and any reconstructed litter with a resident parent
|
||||
# stays visible.
|
||||
check("ShowInChronicle: present on every litter",
|
||||
all("ShowInChronicle" in l for l in _d["litters"]))
|
||||
|
||||
def _prov_of(l):
|
||||
try:
|
||||
return _json.loads(l.get("Provenance") or "{}")
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def _is_reconstructed(l):
|
||||
p = _prov_of(l)
|
||||
notes = " ".join(p.get("notes") or []).lower()
|
||||
return ("rekonstruiert" in notes) and not p.get("fromWurfchronik")
|
||||
|
||||
def _has_resident_parent(l):
|
||||
for pid in (l.get("FatherId"), l.get("MotherId")):
|
||||
g = _G.get(pid)
|
||||
if g and g.get("IsResident"):
|
||||
return True
|
||||
return False
|
||||
|
||||
_hidden = [l for l in _d["litters"] if l.get("ShowInChronicle") is False]
|
||||
# (a) the manually injected Akane litter is still hidden.
|
||||
check("ShowInChronicle: Akane addLitter still hidden",
|
||||
any(l.get("ExternalRef") == "decision-litter-akaneswurfbeiclanofblackforest"
|
||||
for l in _hidden))
|
||||
# (b) every pure ancestor pairing (reconstructed, no resident parent) is hidden;
|
||||
# and there is at least one such litter (the ticket case 47f8d2b6).
|
||||
_pure_ancestor = [l for l in _d["litters"]
|
||||
if _is_reconstructed(l) and not _has_resident_parent(l)]
|
||||
check("ShowInChronicle: at least one pure ancestor pairing exists",
|
||||
len(_pure_ancestor) > 0)
|
||||
check("ShowInChronicle: every pure ancestor pairing is hidden",
|
||||
all(l.get("ShowInChronicle") is False for l in _pure_ancestor))
|
||||
# (c) the concrete ticket litter 47f8d2b6 (Antares × Charly) is hidden.
|
||||
_ticket_lit = _L.get("47f8d2b6-bf13-5f94-935c-f8860a5a1ce6")
|
||||
check("ShowInChronicle: ticket litter 47f8d2b6 present", _ticket_lit is not None)
|
||||
if _ticket_lit:
|
||||
check("ShowInChronicle: ticket litter 47f8d2b6 hidden",
|
||||
_ticket_lit.get("ShowInChronicle") is False)
|
||||
# (d) regression — a real Wurfchronik litter stays visible; and reconstructed
|
||||
# litters WITH a resident parent (the breeder's own, only charted) stay
|
||||
# visible, so her real litters are never hidden.
|
||||
_wurfchronik_lits = [l for l in _d["litters"] if _prov_of(l).get("fromWurfchronik")]
|
||||
check("ShowInChronicle: Wurfchronik litters present", len(_wurfchronik_lits) > 0)
|
||||
check("ShowInChronicle: every Wurfchronik litter visible",
|
||||
all(l.get("ShowInChronicle") is True for l in _wurfchronik_lits))
|
||||
_recon_resident = [l for l in _d["litters"]
|
||||
if _is_reconstructed(l) and _has_resident_parent(l)]
|
||||
check("ShowInChronicle: reconstructed litter with resident parent stays visible",
|
||||
all(l.get("ShowInChronicle") is True for l in _recon_resident))
|
||||
|
||||
# Danielle: mother = Ella *10.06.2019, father = Makoto (sibling pairing; Ticket 4692fd5c).
|
||||
_dan = _find("Danielle von den Kleinen")
|
||||
_df, _dm = _parents(_dan)
|
||||
@@ -570,18 +719,42 @@ if _os.path.exists(_resolved):
|
||||
check("Danielle: mother Ella is the *2019-06-10 one (not the *2023 Ella)",
|
||||
_dmom is not None and _dmom.get("DateOfBirth") == "2019-06-10")
|
||||
|
||||
# Eddy: ticket 439b02e4 & Eliza: ticket b43a5e67
|
||||
_eddy = _find("Eddy von den Kleinen")
|
||||
_eliza = _find("Eliza", "2010-08-20")
|
||||
check("Eddy: present", _eddy is not None)
|
||||
check("Eliza: present", _eliza is not None)
|
||||
if _eddy and _eliza:
|
||||
_el = _L.get(_eddy.get("LitterId"))
|
||||
check("E-Wurf 2010: present", _el is not None)
|
||||
if _el:
|
||||
check("E-Wurf 2010: father is Blacky", _el.get("FatherId") is not None and _G[_el["FatherId"]]["Name"].startswith("Blacky"))
|
||||
check("E-Wurf 2010: mother is Kruke", _el.get("MotherId") is not None and _G[_el["MotherId"]]["Name"] == "Kruke")
|
||||
check("Eliza: shares same litter with Eddy", _eliza.get("LitterId") == _eddy.get("LitterId"))
|
||||
|
||||
# Zeus + Beatrice vs Eddy + Sheila same-date litters (2012-06-13)
|
||||
_k1 = next((l for l in _d["litters"] if l.get("Name") == "Wurf K1" and l.get("Date") == "2012-06-13"), None)
|
||||
_ed_sh = next((l for l in _d["litters"] if "Eddy" in l.get("Name") and "Sheila" in l.get("Name") and l.get("Date") == "2012-06-13"), None)
|
||||
check("K1-Wurf: present on 2012-06-13", _k1 is not None)
|
||||
check("Eddy+Sheila litter: present on 2012-06-13", _ed_sh is not None)
|
||||
if _k1 and _ed_sh:
|
||||
check("K1-Wurf and Eddy+Sheila litter are distinct", _k1["Id"] != _ed_sh["Id"])
|
||||
check("K1-Wurf has correct father Zeus", _k1.get("FatherId") is not None and _G[_k1["FatherId"]]["Name"].startswith("Zeus"))
|
||||
check("K1-Wurf has correct mother Beatrice", _k1.get("MotherId") is not None and _G[_k1["MotherId"]]["Name"].startswith("Beatrice"))
|
||||
check("Eddy+Sheila litter is hidden from chronicle", _ed_sh.get("ShowInChronicle") is False)
|
||||
|
||||
# Catelyn (Ticket 7bbc045c): father Eddard Stark of Sunset Glow, mother Milena.
|
||||
_check_parents("Catelyn", "Catelyn Stark von den Kleinen", "Eddard Stark", "Milena")
|
||||
|
||||
# Gaida (Ticket ba63325a): Geschwisterverpaarung Zhuāngzǐ × Zaibunissa (beide *2020-02-21).
|
||||
_check_parents("Gaida", "Gaida von den Kleinen Chaoten", "Zhuāngzǐ", "Zaibunissa")
|
||||
|
||||
# Bentley / Alexandria / Bugatti (Ticket 09bcac78 / 45cc501b): nur Vorfahren → isResident False.
|
||||
# Bentley / Alexandria / Bugatti (Ticket 09bcac78 / 45cc501b): suppressed by decision -> None.
|
||||
for _tag, _ref in [("Bentley", "Wurfchronik Teil 1_page_0054.md-50505050-0003-4000-8000-000000000003"),
|
||||
("Alexandria", "Wurfchronik Teil 1_page_0054.md-50505050-0004-4000-8000-000000000004"),
|
||||
("Bugatti", "Wurfchronik Teil 1_page_0054.md-40404040-0003-4000-8000-000000000003")]:
|
||||
_g = next((g for g in _d["gerbils"] if g.get("ExternalRef") == _ref), None)
|
||||
check(f"{_tag}: isResident override == False", _g is not None and _g.get("IsResident") is False)
|
||||
check(f"{_tag}: suppressed by decision", _g is None)
|
||||
|
||||
# Cherry Berry's Quqquluuruu: gender override female (box colour misread).
|
||||
_cherry = _find("Cherry Berry")
|
||||
@@ -655,6 +828,90 @@ if _os.path.exists(_resolved):
|
||||
_mf, _mm = _parents(_mamta)
|
||||
check("Mamta Mini: father Geely, mother Gaida linked at the litter",
|
||||
(_mf or "").startswith("Geely") and (_mm or "").startswith("Gaida"))
|
||||
|
||||
# ── isResident-Sweep (Ticket 381f7e51): „ALLE raus, AUSSER Elterntiere" ───────
|
||||
# Datengetriebene Endregel: resident GENAU DANN, wenn (1) expliziter Override
|
||||
# ODER (2) Elternteil eines EIGENEN Wurfs (Wurfchronik/Clan/resident-Elternteil),
|
||||
# NICHT bloß eines rein-virtuellen Ahnen-Wurfs.
|
||||
_byid = lambda pref: next((g for g in _d["gerbils"]
|
||||
if g.get("Id", "").startswith(pref)), None)
|
||||
|
||||
# (a) Jungtier ohne eigene Nachzucht (namenloses ♀ *2022-01-28, O20) → False.
|
||||
_o20 = _byid("86c10cf2")
|
||||
check("Sweep: jungling 86c10cf2 (no own offspring) → isResident False",
|
||||
_o20 is not None and _o20.get("IsResident") is False)
|
||||
|
||||
# (b) Mindestens 90 % aller Tiere sind jetzt NICHT-resident (der Blanket-True-
|
||||
# Default der Wurfchronik ist weg; vorher waren ~1709/2357 resident).
|
||||
_n_res = sum(1 for g in _d["gerbils"] if g.get("IsResident"))
|
||||
check("Sweep: resident count drastically reduced (< 700)", _n_res < 700)
|
||||
|
||||
# (c) Echte Zuchttiere (Eltern dokumentierter Wurfchronik-Würfe) bleiben True.
|
||||
def _is_wurfchronik(l):
|
||||
try:
|
||||
return bool(_json.loads(l.get("Provenance") or "{}").get("fromWurfchronik"))
|
||||
except Exception:
|
||||
return False
|
||||
_wc_parents_true = 0
|
||||
_wc_parents_total = 0
|
||||
for l in _d["litters"]:
|
||||
if not _is_wurfchronik(l):
|
||||
continue
|
||||
for pid in (l.get("FatherId"), l.get("MotherId")):
|
||||
pg = _G.get(pid)
|
||||
if not pg:
|
||||
continue
|
||||
_wc_parents_total += 1
|
||||
if pg.get("IsResident"):
|
||||
_wc_parents_true += 1
|
||||
# The vast majority of Wurfchronik parents are her own breeding stock → resident
|
||||
# (a handful are explicit-false ancestors like Bentley/Alexandria).
|
||||
check("Sweep: ≥ 95% of Wurfchronik-litter parents stay resident",
|
||||
_wc_parents_total > 0 and _wc_parents_true / _wc_parents_total >= 0.95)
|
||||
|
||||
# (d) Externer Ahne, nur Elternteil eines virtuellen Ahnen-Wurfs → False.
|
||||
_antares = _find("Antares of Ulmer Strolche")
|
||||
check("Sweep: external ancestor Antares (virtual-only litter) → isResident False",
|
||||
_antares is not None and _antares.get("IsResident") is False)
|
||||
|
||||
# (e) Expliziter false-Override gewinnt über Elternschaft (Akane *2023-01-21 ist
|
||||
# Elternteil ihres Fremd-Wurfs, bleibt aber False per Override).
|
||||
_ak = _find("Akane", "2023-01-21")
|
||||
check("Sweep: explicit false override beats parenthood (Akane → False)",
|
||||
_ak is not None and _ak.get("IsResident") is False)
|
||||
|
||||
# (f) Residenten, die KEIN Elternteil irgendeines Wurfs sind, sind ausschließlich
|
||||
# explizite Overrides (z. B. die addAnimals-Stubs Merle/Pete) — der Sweep
|
||||
# macht niemanden ohne Elternschaft resident.
|
||||
_parent_ids = set()
|
||||
for l in _d["litters"]:
|
||||
for pid in (l.get("FatherId"), l.get("MotherId")):
|
||||
if pid:
|
||||
_parent_ids.add(pid)
|
||||
# Ein expliziter isResident:true-Override in conflict-decisions.json macht ein
|
||||
# NICHT-Elterntier legitim resident (Züchterin-Entscheidung, z. B. „extern"-Badge
|
||||
# entfernen). Deren Match-Keys (name / externalRef) werden neben den decision--Stubs
|
||||
# akzeptiert — sonst würde jede solche Kuratierung diese Invariante verletzen.
|
||||
_cd_path = _os.path.join(_os.path.dirname(__file__), "conflict-decisions.json")
|
||||
_ovr_names, _ovr_refs = set(), []
|
||||
if _os.path.exists(_cd_path):
|
||||
for _r in (_json.load(open(_cd_path, encoding="utf-8")).get("resolutions") or []):
|
||||
if _r.get("isResident") is True:
|
||||
if _r.get("name"):
|
||||
_ovr_names.add(m.normalize_name(_r["name"]))
|
||||
if _r.get("externalRef"):
|
||||
_ovr_refs.append(_r["externalRef"])
|
||||
|
||||
def _res_ok(g):
|
||||
er = g.get("ExternalRef") or ""
|
||||
return (er.startswith("decision-")
|
||||
or m.normalize_name(g.get("Name") or "") in _ovr_names
|
||||
or any(er.endswith(ref) for ref in _ovr_refs))
|
||||
|
||||
_res_nonparent = [g for g in _d["gerbils"]
|
||||
if g.get("IsResident") and g["Id"] not in _parent_ids]
|
||||
check("Sweep: residents that are not a parent are only explicit override stubs",
|
||||
all(_res_ok(g) for g in _res_nonparent))
|
||||
else:
|
||||
print("note: output/resolved_import.json not present — skipped integration assertions")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user