Tree Cleanup tool: bulk fixes with preview → approve
A new per-tree Cleanup page (and cleanup_service + endpoints), each fix preview-first per the propose-then-approve rule: - Mark deceased by birth year: lists people born ≤ a cutoff (default 1930) not already deceased; apply sets is_living=false for the ones you keep checked. - Set sex from a source GEDCOM: upload the source .ged (it carries SEX); matches by name and proposes sex only where it's missing — far more accurate than guessing from first names. Review, then apply. - Names that look broken: flags date-in-surname / date-in-given / no-surname / packed given names, with inline editable given+surname; fix the checked ones. No migration (uses existing columns). 55 backend tests pass (preview+apply for all three); frontend builds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ from fastapi import APIRouter
|
||||
from app.api.v1 import (
|
||||
auth,
|
||||
citations,
|
||||
cleanup,
|
||||
events,
|
||||
gedcom,
|
||||
media,
|
||||
@@ -28,3 +29,4 @@ api_router.include_router(sources.router)
|
||||
api_router.include_router(citations.router)
|
||||
api_router.include_router(media.router)
|
||||
api_router.include_router(gedcom.router)
|
||||
api_router.include_router(cleanup.router)
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, File, UploadFile
|
||||
|
||||
from app.api.deps import CurrentUser, SessionDep
|
||||
from app.schemas.cleanup import (
|
||||
CleanupResult,
|
||||
DeceasedApply,
|
||||
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.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.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)
|
||||
Reference in New Issue
Block a user