Add soft-delete + recovery and tree-wide graph endpoints

Tree and person soft-delete + restore (owner-only for trees, editor for people) with recovery listings (?deleted=true); the worker already purges past the 30-day window. Adds tree-wide GET /relationships and /events so the family/pedigree view loads the whole graph in a few calls. 27 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
This commit is contained in:
2026-06-06 22:19:01 -04:00
parent b0c7c8570b
commit f2205b93f4
9 changed files with 269 additions and 4 deletions
+72
View File
@@ -4,6 +4,7 @@ person through the privacy engine. Each returned Person gets a transient
"""
import uuid
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -112,6 +113,77 @@ async def get_person(
return person
async def delete_person(
session: AsyncSession, *, actor: User, tree: Tree, person_id: uuid.UUID
) -> None:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
person = (
await session.execute(
select(Person).where(
Person.id == person_id, Person.tree_id == tree.id, Person.deleted_at.is_(None)
)
)
).scalar_one_or_none()
if person is None:
raise NotFound("person not found")
person.deleted_at = datetime.now(UTC)
record_audit(
session,
action="delete",
entity_type="Person",
entity_id=person.id,
tree_id=tree.id,
actor_user_id=actor.id,
)
await session.commit()
async def restore_person(
session: AsyncSession, *, actor: User, tree: Tree, person_id: uuid.UUID
) -> Person:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
person = (
await session.execute(
select(Person).where(
Person.id == person_id, Person.tree_id == tree.id, Person.deleted_at.is_not(None)
)
)
).scalar_one_or_none()
if person is None:
raise NotFound("deleted person not found")
person.deleted_at = None
record_audit(
session,
action="restore",
entity_type="Person",
entity_id=person.id,
tree_id=tree.id,
actor_user_id=actor.id,
)
await session.commit()
await session.refresh(person)
await _attach_primary_name(session, person)
return person
async def list_deleted_persons(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
) -> list[Person]:
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
stmt = (
select(Person)
.where(Person.tree_id == tree.id, Person.deleted_at.is_not(None))
.order_by(Person.deleted_at.desc())
)
persons = list((await session.execute(stmt)).scalars().all())
for person in persons:
await _attach_primary_name(session, person)
return persons
async def list_persons(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
) -> list[Person]: