1340d1957f
Adds a preview/apply rule to the Cleanup tool for parents who have NO birth date
of their own (so the existing born-on-or-before rule can't reach them) but who
have a child born long ago — they're necessarily deceased. This is the gap that
left ~56 parents in the Paul tree as "unknown".
- cleanup_service.preview_deceased_by_child(year): parents of any child born
on/before the cutoff, excluding already-deceased; returns child_birth_year.
- GET /trees/{id}/cleanup/deceased-by-child?born_on_or_before=1900. Apply reuses
the existing POST .../cleanup/deceased (same audited mark-deceased path).
- Frontend: a new card in the Cleanup tool (year input → preview → select →
apply), preview-first like the rest of the tool.
Test covers preview (finds the no-birthdate parent of a pre-cutoff child,
excludes modern-child parents), child_birth_year, apply, and re-preview drop.
Suite 106 passing.
Signed-off-by: Justin Paul <justin@jpaul.me>
215 lines
8.3 KiB
Python
215 lines
8.3 KiB
Python
"""Tree cleanup: preview/apply for deceased-by-year, gender-from-source, names."""
|
|
|
|
from tests.conftest import auth, register
|
|
|
|
|
|
async def _tree(client, email):
|
|
h = auth(await register(client, email))
|
|
tid = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"]
|
|
return h, tid
|
|
|
|
|
|
async def _person(client, h, tid, given, surname=None):
|
|
return (
|
|
await client.post(
|
|
f"/api/v1/trees/{tid}/persons", json={"given": given, "surname": surname}, headers=h
|
|
)
|
|
).json()["id"]
|
|
|
|
|
|
async def _birth(client, h, tid, pid, year):
|
|
await client.post(
|
|
f"/api/v1/trees/{tid}/events",
|
|
json={"event_type": "birth", "person_id": pid, "date_value": str(year)},
|
|
headers=h,
|
|
)
|
|
|
|
|
|
async def test_deceased_preview_and_apply(client):
|
|
h, tid = await _tree(client, "cl-dec@example.com")
|
|
old = await _person(client, h, tid, "Josias", "Moody")
|
|
young = await _person(client, h, tid, "Kid", "Moody")
|
|
await _birth(client, h, tid, old, 1900)
|
|
await _birth(client, h, tid, young, 1990)
|
|
|
|
prev = (
|
|
await client.get(f"/api/v1/trees/{tid}/cleanup/deceased?born_on_or_before=1930", headers=h)
|
|
).json()
|
|
assert [r["person_id"] for r in prev] == [old]
|
|
|
|
r = await client.post(
|
|
f"/api/v1/trees/{tid}/cleanup/deceased", json={"person_ids": [old]}, headers=h
|
|
)
|
|
assert r.status_code == 200 and r.json()["updated"] == 1
|
|
assert (
|
|
await client.get(f"/api/v1/trees/{tid}/persons/{old}", headers=h)
|
|
).json()["is_living"] is False
|
|
# Re-preview no longer lists the now-deceased person.
|
|
prev2 = (
|
|
await client.get(f"/api/v1/trees/{tid}/cleanup/deceased?born_on_or_before=1930", headers=h)
|
|
).json()
|
|
assert old not in [r["person_id"] for r in prev2]
|
|
|
|
|
|
async def test_deceased_by_child_preview_and_apply(client):
|
|
h, tid = await _tree(client, "cl-decchild@example.com")
|
|
# Parent with NO birth date (the gap the birth-year rule can't reach).
|
|
parent = await _person(client, h, tid, "Gesche", "Frerking")
|
|
child = await _person(client, h, tid, "Kindt", "Frerking")
|
|
await _birth(client, h, tid, child, 1880) # child born before the cutoff
|
|
await client.post(
|
|
f"/api/v1/trees/{tid}/relationships",
|
|
json={"type": "parent_child", "person_from_id": parent, "person_to_id": child},
|
|
headers=h,
|
|
)
|
|
# A parent of a modern child must NOT be flagged.
|
|
p_modern = await _person(client, h, tid, "Modern", "Parent")
|
|
c_modern = await _person(client, h, tid, "Kid", "Parent")
|
|
await _birth(client, h, tid, c_modern, 1990)
|
|
await client.post(
|
|
f"/api/v1/trees/{tid}/relationships",
|
|
json={"type": "parent_child", "person_from_id": p_modern, "person_to_id": c_modern},
|
|
headers=h,
|
|
)
|
|
|
|
prev = (
|
|
await client.get(
|
|
f"/api/v1/trees/{tid}/cleanup/deceased-by-child?born_on_or_before=1900", headers=h
|
|
)
|
|
).json()
|
|
ids = [r["person_id"] for r in prev]
|
|
assert parent in ids and p_modern not in ids
|
|
assert next(r for r in prev if r["person_id"] == parent)["child_birth_year"] == 1880
|
|
|
|
# Apply through the shared deceased endpoint.
|
|
r = await client.post(
|
|
f"/api/v1/trees/{tid}/cleanup/deceased", json={"person_ids": [parent]}, headers=h
|
|
)
|
|
assert r.status_code == 200 and r.json()["updated"] == 1
|
|
assert (
|
|
await client.get(f"/api/v1/trees/{tid}/persons/{parent}", headers=h)
|
|
).json()["is_living"] is False
|
|
# Re-preview drops the now-deceased parent.
|
|
prev2 = (
|
|
await client.get(
|
|
f"/api/v1/trees/{tid}/cleanup/deceased-by-child?born_on_or_before=1900", headers=h
|
|
)
|
|
).json()
|
|
assert parent not in [r["person_id"] for r in prev2]
|
|
|
|
|
|
async def test_gender_from_spouse_preview_and_apply(client):
|
|
h, tid = await _tree(client, "cl-spouse@example.com")
|
|
husband = (
|
|
await client.post(
|
|
f"/api/v1/trees/{tid}/persons",
|
|
json={"given": "Otto", "surname": "Frey", "gender": "male"},
|
|
headers=h,
|
|
)
|
|
).json()["id"]
|
|
wife = await _person(client, h, tid, "Bea", "Frey") # no sex
|
|
loner = await _person(client, h, tid, "Nyx", "Alone") # no sex, no partner
|
|
await client.post(
|
|
f"/api/v1/trees/{tid}/relationships",
|
|
json={"type": "partnership", "person_from_id": husband, "person_to_id": wife},
|
|
headers=h,
|
|
)
|
|
|
|
prev = (await client.get(f"/api/v1/trees/{tid}/cleanup/gender/from-spouse", headers=h)).json()
|
|
by = {r["person_id"]: r["proposed_gender"] for r in prev}
|
|
assert by.get(wife) == "female" # opposite of the confirmed-male husband
|
|
assert loner not in by # no known-sex partner → not proposed
|
|
assert husband not in by # already has a sex
|
|
|
|
r = await client.post(
|
|
f"/api/v1/trees/{tid}/cleanup/gender",
|
|
json={"updates": [{"person_id": wife, "gender": "female"}]},
|
|
headers=h,
|
|
)
|
|
assert r.status_code == 200 and r.json()["updated"] == 1
|
|
assert (
|
|
await client.get(f"/api/v1/trees/{tid}/persons/{wife}", headers=h)
|
|
).json()["gender"] == "female"
|
|
|
|
# Once set, the wife is no longer proposed.
|
|
prev2 = (await client.get(f"/api/v1/trees/{tid}/cleanup/gender/from-spouse", headers=h)).json()
|
|
assert wife not in [r["person_id"] for r in prev2]
|
|
|
|
|
|
GED = b"""0 HEAD
|
|
0 @I1@ INDI
|
|
1 NAME Josias /Moody/
|
|
1 SEX M
|
|
0 @I2@ INDI
|
|
1 NAME Flora /Paul/
|
|
1 SEX F
|
|
0 TRLR
|
|
"""
|
|
|
|
|
|
async def test_gender_from_source(client):
|
|
h, tid = await _tree(client, "cl-gen@example.com")
|
|
await _person(client, h, tid, "Josias", "Moody")
|
|
await _person(client, h, tid, "Flora", "Paul")
|
|
await _person(client, h, tid, "Nobody", "Else") # not in source
|
|
|
|
prev = await client.post(
|
|
f"/api/v1/trees/{tid}/cleanup/gender/preview",
|
|
files={"file": ("src.ged", GED, "text/plain")},
|
|
headers=h,
|
|
)
|
|
props = prev.json()
|
|
by_name = {p["name"]: p["proposed_gender"] for p in props}
|
|
assert by_name == {"Josias Moody": "male", "Flora Paul": "female"}
|
|
|
|
updates = [{"person_id": p["person_id"], "gender": p["proposed_gender"]} for p in props]
|
|
r = await client.post(
|
|
f"/api/v1/trees/{tid}/cleanup/gender", json={"updates": updates}, headers=h
|
|
)
|
|
assert r.status_code == 200 and r.json()["updated"] == 2
|
|
people = (await client.get(f"/api/v1/trees/{tid}/persons", headers=h)).json()
|
|
genders = {p["primary_name"]: p["gender"] for p in people}
|
|
assert genders["Josias Moody"] == "male" and genders["Flora Paul"] == "female"
|
|
|
|
|
|
async def test_guess_gender_from_first_name(client):
|
|
h, tid = await _tree(client, "cl-guess@example.com")
|
|
await _person(client, h, tid, "William", "Paul") # male
|
|
await _person(client, h, tid, "Flora", "Reier") # female
|
|
await _person(client, h, tid, "Marion", "Doe") # ambiguous -> skipped
|
|
# Already-gendered person is left alone even if guessable.
|
|
gendered = await _person(client, h, tid, "James", "Known")
|
|
await client.patch(
|
|
f"/api/v1/trees/{tid}/persons/{gendered}", json={"gender": "male"}, headers=h
|
|
)
|
|
|
|
prev = (await client.get(f"/api/v1/trees/{tid}/cleanup/gender/guess", headers=h)).json()
|
|
by = {p["name"]: p["proposed_gender"] for p in prev}
|
|
assert by == {"William Paul": "male", "Flora Reier": "female"}
|
|
|
|
updates = [{"person_id": p["person_id"], "gender": p["proposed_gender"]} for p in prev]
|
|
r = await client.post(
|
|
f"/api/v1/trees/{tid}/cleanup/gender", json={"updates": updates}, headers=h
|
|
)
|
|
assert r.status_code == 200 and r.json()["updated"] == 2
|
|
|
|
|
|
async def test_name_issues_preview_and_fix(client):
|
|
h, tid = await _tree(client, "cl-name@example.com")
|
|
# surname got a date; real surname landed in the given name.
|
|
bad = await _person(client, h, tid, "Henry Paul", "1859")
|
|
await _person(client, h, tid, "Normal", "Person") # should not be flagged
|
|
|
|
issues = (await client.get(f"/api/v1/trees/{tid}/cleanup/names", headers=h)).json()
|
|
assert len(issues) == 1 and issues[0]["issue"] == "date_in_surname"
|
|
name_id = issues[0]["name_id"]
|
|
|
|
r = await client.post(
|
|
f"/api/v1/trees/{tid}/cleanup/names",
|
|
json={"edits": [{"name_id": name_id, "given": "Henry", "surname": "Paul"}]},
|
|
headers=h,
|
|
)
|
|
assert r.status_code == 200 and r.json()["updated"] == 1
|
|
person = (await client.get(f"/api/v1/trees/{tid}/persons/{bad}", headers=h)).json()
|
|
assert person["primary_name"] == "Henry Paul"
|