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>
131 lines
4.9 KiB
Python
131 lines
4.9 KiB
Python
import uuid
|
|
|
|
from fastapi import APIRouter, File, UploadFile
|
|
|
|
from app.api.deps import CurrentUser, SessionDep
|
|
from app.schemas.cleanup import (
|
|
CleanupResult,
|
|
DeceasedApply,
|
|
DeceasedByChildCandidate,
|
|
DeceasedCandidate,
|
|
GenderApply,
|
|
GenderProposal,
|
|
NameApply,
|
|
NameIssue,
|
|
)
|
|
from app.services import cleanup_service, tree_service
|
|
|
|
router = APIRouter(prefix="/trees", tags=["cleanup"])
|
|
|
|
|
|
@router.get("/{tree_id}/cleanup/deceased", response_model=list[DeceasedCandidate])
|
|
async def preview_deceased(
|
|
tree_id: uuid.UUID,
|
|
session: SessionDep,
|
|
current: CurrentUser,
|
|
born_on_or_before: int = 1930,
|
|
) -> list[DeceasedCandidate]:
|
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
|
rows = await cleanup_service.preview_deceased(
|
|
session, actor=current, tree=tree, year=born_on_or_before
|
|
)
|
|
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
|
|
) -> CleanupResult:
|
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
|
n = await cleanup_service.apply_deceased(
|
|
session, actor=current, tree=tree, person_ids=data.person_ids
|
|
)
|
|
return CleanupResult(updated=n)
|
|
|
|
|
|
@router.post("/{tree_id}/cleanup/gender/preview", response_model=list[GenderProposal])
|
|
async def preview_gender(
|
|
tree_id: uuid.UUID,
|
|
session: SessionDep,
|
|
current: CurrentUser,
|
|
file: UploadFile = File(...),
|
|
) -> list[GenderProposal]:
|
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
|
text = (await file.read()).decode("utf-8", errors="replace")
|
|
rows = await cleanup_service.preview_gender(
|
|
session, actor=current, tree=tree, gedcom_text=text
|
|
)
|
|
return [GenderProposal(**r) for r in rows]
|
|
|
|
|
|
@router.get("/{tree_id}/cleanup/gender/guess", response_model=list[GenderProposal])
|
|
async def guess_gender(
|
|
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
|
) -> list[GenderProposal]:
|
|
"""Best-guess sex from first names (bundled dictionary) for people missing it."""
|
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
|
rows = await cleanup_service.guess_gender_by_name(session, actor=current, tree=tree)
|
|
return [GenderProposal(**r) for r in rows]
|
|
|
|
|
|
@router.get("/{tree_id}/cleanup/gender/from-spouse", response_model=list[GenderProposal])
|
|
async def guess_gender_from_spouse(
|
|
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
|
) -> list[GenderProposal]:
|
|
"""Infer a missing sex from a partner whose sex is set (opposite-sex couple)."""
|
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
|
rows = await cleanup_service.guess_gender_by_spouse(session, actor=current, tree=tree)
|
|
return [GenderProposal(**r) for r in rows]
|
|
|
|
|
|
@router.post("/{tree_id}/cleanup/gender", response_model=CleanupResult)
|
|
async def apply_gender(
|
|
tree_id: uuid.UUID, data: GenderApply, session: SessionDep, current: CurrentUser
|
|
) -> CleanupResult:
|
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
|
n = await cleanup_service.apply_gender(
|
|
session,
|
|
actor=current,
|
|
tree=tree,
|
|
updates=[u.model_dump() for u in data.updates],
|
|
)
|
|
return CleanupResult(updated=n)
|
|
|
|
|
|
@router.get("/{tree_id}/cleanup/names", response_model=list[NameIssue])
|
|
async def preview_names(
|
|
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
|
) -> list[NameIssue]:
|
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
|
rows = await cleanup_service.preview_names(session, actor=current, tree=tree)
|
|
return [NameIssue(**r) for r in rows]
|
|
|
|
|
|
@router.post("/{tree_id}/cleanup/names", response_model=CleanupResult)
|
|
async def apply_names(
|
|
tree_id: uuid.UUID, data: NameApply, session: SessionDep, current: CurrentUser
|
|
) -> CleanupResult:
|
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
|
n = await cleanup_service.apply_names(
|
|
session, actor=current, tree=tree, edits=[e.model_dump() for e in data.edits]
|
|
)
|
|
return CleanupResult(updated=n)
|