diff --git a/backend/app/api/v1/cleanup.py b/backend/app/api/v1/cleanup.py index e3d6cd5..82129b6 100644 --- a/backend/app/api/v1/cleanup.py +++ b/backend/app/api/v1/cleanup.py @@ -6,6 +6,7 @@ from app.api.deps import CurrentUser, SessionDep from app.schemas.cleanup import ( CleanupResult, DeceasedApply, + DeceasedByChildCandidate, DeceasedCandidate, GenderApply, GenderProposal, @@ -31,6 +32,24 @@ async def preview_deceased( return [DeceasedCandidate(**r) for r in rows] +@router.get( + "/{tree_id}/cleanup/deceased-by-child", response_model=list[DeceasedByChildCandidate] +) +async def preview_deceased_by_child( + tree_id: uuid.UUID, + session: SessionDep, + current: CurrentUser, + born_on_or_before: int = 1900, +) -> list[DeceasedByChildCandidate]: + """People with a child born on/before the cutoff — necessarily deceased even + when their own birth date is missing. Apply via POST .../cleanup/deceased.""" + tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) + rows = await cleanup_service.preview_deceased_by_child( + session, actor=current, tree=tree, year=born_on_or_before + ) + return [DeceasedByChildCandidate(**r) for r in rows] + + @router.post("/{tree_id}/cleanup/deceased", response_model=CleanupResult) async def apply_deceased( tree_id: uuid.UUID, data: DeceasedApply, session: SessionDep, current: CurrentUser diff --git a/backend/app/schemas/cleanup.py b/backend/app/schemas/cleanup.py index f402b33..86f8a5e 100644 --- a/backend/app/schemas/cleanup.py +++ b/backend/app/schemas/cleanup.py @@ -9,6 +9,12 @@ class DeceasedCandidate(BaseModel): birth_year: int +class DeceasedByChildCandidate(BaseModel): + person_id: uuid.UUID + name: str + child_birth_year: int + + class DeceasedApply(BaseModel): person_ids: list[uuid.UUID] diff --git a/backend/app/services/cleanup_service.py b/backend/app/services/cleanup_service.py index befee4e..45d1d00 100644 --- a/backend/app/services/cleanup_service.py +++ b/backend/app/services/cleanup_service.py @@ -133,6 +133,51 @@ async def apply_deceased( return len(persons) +# ---- 1b. Mark deceased by a CHILD's birth year ------------------------------------- +# For parents whose own birth date is missing (so the birth-year rule can't reach +# them) but who have a child born long ago — they're necessarily deceased. Applies +# through the same apply_deceased() path. + +async def preview_deceased_by_child( + session: AsyncSession, *, actor: User, tree: Tree, year: int +) -> list[dict]: + await _require_editor(session, actor=actor, tree=tree) + names = await _primary_name_by_person(session, tree.id) + years = await _birth_year_by_person(session, tree.id) + rels = ( + await session.execute( + select(Relationship).where( + Relationship.tree_id == tree.id, + Relationship.deleted_at.is_(None), + Relationship.type == RelationshipType.parent_child, + ) + ) + ).scalars().all() + # parent id -> earliest child birth year, among children born on/before `year`. + earliest_child: dict[uuid.UUID, int] = {} + for r in rels: + cy = years.get(r.person_to_id) # the child's birth year + if cy is None or cy > year: + continue + if r.person_from_id not in earliest_child or cy < earliest_child[r.person_from_id]: + earliest_child[r.person_from_id] = cy + persons = {p.id: p for p in await _persons(session, tree.id)} + out: list[dict] = [] + for parent_id, cy in earliest_child.items(): + p = persons.get(parent_id) + if p is None or p.is_living is False: # gone or already deceased + continue + out.append( + { + "person_id": str(parent_id), + "name": _display(names.get(parent_id)), + "child_birth_year": cy, + } + ) + out.sort(key=lambda r: r["child_birth_year"]) + return out + + # ---- 2. Re-derive gender from a source GEDCOM (matches by name) ---------------------- async def preview_gender( diff --git a/backend/tests/test_cleanup.py b/backend/tests/test_cleanup.py index d9338af..5851d34 100644 --- a/backend/tests/test_cleanup.py +++ b/backend/tests/test_cleanup.py @@ -51,6 +51,53 @@ async def test_deceased_preview_and_apply(client): 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 = ( diff --git a/frontend/app/trees/[id]/cleanup/page.tsx b/frontend/app/trees/[id]/cleanup/page.tsx index f2d8085..192b7a9 100644 --- a/frontend/app/trees/[id]/cleanup/page.tsx +++ b/frontend/app/trees/[id]/cleanup/page.tsx @@ -10,6 +10,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; type Deceased = components["schemas"]["DeceasedCandidate"]; +type DeceasedByChild = components["schemas"]["DeceasedByChildCandidate"]; type GenderProp = components["schemas"]["GenderProposal"]; type NameIssue = components["schemas"]["NameIssue"]; type Person = components["schemas"]["PersonRead"]; @@ -31,6 +32,12 @@ export default function CleanupPage() { const [decSel, setDecSel] = useState>(new Set()); const [decMsg, setDecMsg] = useState(null); + // 1b) Deceased by a child's birth year (for parents with no birth date) + const [childYear, setChildYear] = useState(1900); + const [decByChild, setDecByChild] = useState(null); + const [dbcSel, setDbcSel] = useState>(new Set()); + const [dbcMsg, setDbcMsg] = useState(null); + // 2) Gender from source GEDCOM const [gender, setGender] = useState(null); const [genSel, setGenSel] = useState>(new Set()); @@ -63,6 +70,23 @@ export default function CleanupPage() { setDeceased(null); } + async function previewDeceasedByChild() { + setDbcMsg(null); + const { data } = await api.GET("/api/v1/trees/{tree_id}/cleanup/deceased-by-child", { + params: { path: { tree_id: treeId }, query: { born_on_or_before: childYear } }, + }); + setDecByChild(data ?? []); + setDbcSel(new Set((data ?? []).map((d) => d.person_id))); + } + async function applyDeceasedByChild() { + const { data } = await api.POST("/api/v1/trees/{tree_id}/cleanup/deceased", { + params: { path: { tree_id: treeId } }, + body: { person_ids: [...dbcSel] }, + }); + setDbcMsg(`Marked ${data?.updated ?? 0} people deceased.`); + setDecByChild(null); + } + async function previewGender(e: React.ChangeEvent) { const file = e.target.files?.[0]; if (genFile.current) genFile.current.value = ""; @@ -231,6 +255,59 @@ export default function CleanupPage() { + {/* 1b) Deceased by a child's birth year */} + + + Mark deceased by a child’s birth year + + +

+ Catches parents who have no birth date of their own (so the rule + above can’t reach them) but who have a child born long ago — they’re necessarily + deceased. +

+
+ + +
+ {dbcMsg &&

{dbcMsg}

} + {decByChild && ( +
+

+ {decByChild.length} people with a child born ≤ {childYear} (not already marked + deceased). +

+
    + {decByChild.map((d) => ( +
  • + toggle(dbcSel, d.person_id, setDbcSel)} + /> + {d.name} + child b. {d.child_birth_year} +
  • + ))} +
+ {decByChild.length > 0 && ( + + )} +
+ )} +
+
+ {/* 2) Gender from source */} diff --git a/frontend/lib/api/schema.d.ts b/frontend/lib/api/schema.d.ts index 32bdc11..5eb0187 100644 --- a/frontend/lib/api/schema.d.ts +++ b/frontend/lib/api/schema.d.ts @@ -718,6 +718,27 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/trees/{tree_id}/cleanup/deceased-by-child": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Preview Deceased By Child + * @description People with a child born on/before the cutoff — necessarily deceased even + * when their own birth date is missing. Apply via POST .../cleanup/deceased. + */ + get: operations["preview_deceased_by_child_api_v1_trees__tree_id__cleanup_deceased_by_child_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/trees/{tree_id}/cleanup/gender/preview": { parameters: { query?: never; @@ -1286,6 +1307,18 @@ export interface components { /** Person Ids */ person_ids: string[]; }; + /** DeceasedByChildCandidate */ + DeceasedByChildCandidate: { + /** + * Person Id + * Format: uuid + */ + person_id: string; + /** Name */ + name: string; + /** Child Birth Year */ + child_birth_year: number; + }; /** DeceasedCandidate */ DeceasedCandidate: { /** @@ -4013,6 +4046,39 @@ export interface operations { }; }; }; + preview_deceased_by_child_api_v1_trees__tree_id__cleanup_deceased_by_child_get: { + parameters: { + query?: { + born_on_or_before?: number; + }; + header?: never; + path: { + tree_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DeceasedByChildCandidate"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; preview_gender_api_v1_trees__tree_id__cleanup_gender_preview_post: { parameters: { query?: never; diff --git a/frontend/openapi.json b/frontend/openapi.json index fadea88..cf07f18 100644 --- a/frontend/openapi.json +++ b/frontend/openapi.json @@ -2873,6 +2873,64 @@ } } }, + "/api/v1/trees/{tree_id}/cleanup/deceased-by-child": { + "get": { + "tags": [ + "cleanup" + ], + "summary": "Preview Deceased By Child", + "description": "People with a child born on/before the cutoff \u2014 necessarily deceased even\nwhen their own birth date is missing. Apply via POST .../cleanup/deceased.", + "operationId": "preview_deceased_by_child_api_v1_trees__tree_id__cleanup_deceased_by_child_get", + "parameters": [ + { + "name": "tree_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Tree Id" + } + }, + { + "name": "born_on_or_before", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1900, + "title": "Born On Or Before" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DeceasedByChildCandidate" + }, + "title": "Response Preview Deceased By Child Api V1 Trees Tree Id Cleanup Deceased By Child Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/v1/trees/{tree_id}/cleanup/gender/preview": { "post": { "tags": [ @@ -4897,6 +4955,30 @@ ], "title": "DeceasedApply" }, + "DeceasedByChildCandidate": { + "properties": { + "person_id": { + "type": "string", + "format": "uuid", + "title": "Person Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "child_birth_year": { + "type": "integer", + "title": "Child Birth Year" + } + }, + "type": "object", + "required": [ + "person_id", + "name", + "child_birth_year" + ], + "title": "DeceasedByChildCandidate" + }, "DeceasedCandidate": { "properties": { "person_id": {