diff --git a/backend/app/api/v1/events.py b/backend/app/api/v1/events.py index 90b73c6..928cfd3 100644 --- a/backend/app/api/v1/events.py +++ b/backend/app/api/v1/events.py @@ -20,6 +20,15 @@ async def create_event( return EventRead.model_validate(event) +@router.get("/{tree_id}/events", response_model=list[EventRead]) +async def list_tree_events( + tree_id: uuid.UUID, session: SessionDep, current: CurrentUser +) -> list[EventRead]: + tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) + events = await event_service.list_events(session, viewer_id=current.id, tree=tree) + return [EventRead.model_validate(e) for e in events] + + @router.get("/{tree_id}/persons/{person_id}/events", response_model=list[EventRead]) async def list_person_events( tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser diff --git a/backend/app/api/v1/persons.py b/backend/app/api/v1/persons.py index 913d266..43f66a0 100644 --- a/backend/app/api/v1/persons.py +++ b/backend/app/api/v1/persons.py @@ -36,13 +36,37 @@ async def create_person( @router.get("/{tree_id}/persons", response_model=list[PersonRead]) async def list_persons( - tree_id: uuid.UUID, session: SessionDep, current: CurrentUser + tree_id: uuid.UUID, session: SessionDep, current: CurrentUser, deleted: bool = False ) -> list[PersonRead]: tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) - persons = await person_service.list_persons(session, viewer_id=current.id, tree=tree) + if deleted: + persons = await person_service.list_deleted_persons( + session, viewer_id=current.id, tree=tree + ) + else: + persons = await person_service.list_persons(session, viewer_id=current.id, tree=tree) return [PersonRead.model_validate(p) for p in persons] +@router.delete("/{tree_id}/persons/{person_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_person( + tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser +) -> None: + tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) + await person_service.delete_person(session, actor=current, tree=tree, person_id=person_id) + + +@router.post("/{tree_id}/persons/{person_id}/restore", response_model=PersonRead) +async def restore_person( + tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser +) -> PersonRead: + tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) + person = await person_service.restore_person( + session, actor=current, tree=tree, person_id=person_id + ) + return PersonRead.model_validate(person) + + @router.get("/{tree_id}/persons/{person_id}", response_model=PersonRead) async def get_person( tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser diff --git a/backend/app/api/v1/relationships.py b/backend/app/api/v1/relationships.py index dab0146..dbd4456 100644 --- a/backend/app/api/v1/relationships.py +++ b/backend/app/api/v1/relationships.py @@ -24,6 +24,15 @@ async def create_relationship( return RelationshipRead.model_validate(relationship) +@router.get("/{tree_id}/relationships", response_model=list[RelationshipRead]) +async def list_relationships( + tree_id: uuid.UUID, session: SessionDep, current: CurrentUser +) -> list[RelationshipRead]: + tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) + rels = await relationship_service.list_relationships(session, viewer_id=current.id, tree=tree) + return [RelationshipRead.model_validate(r) for r in rels] + + @router.get( "/{tree_id}/persons/{person_id}/relationships", response_model=list[RelationshipRead], diff --git a/backend/app/api/v1/trees.py b/backend/app/api/v1/trees.py index 05ea73b..ab68530 100644 --- a/backend/app/api/v1/trees.py +++ b/backend/app/api/v1/trees.py @@ -22,8 +22,13 @@ async def create_tree(data: TreeCreate, session: SessionDep, current: CurrentUse @router.get("", response_model=list[TreeRead]) -async def list_my_trees(session: SessionDep, current: CurrentUser) -> list[TreeRead]: - trees = await tree_service.list_trees_for_user(session, user=current) +async def list_my_trees( + session: SessionDep, current: CurrentUser, deleted: bool = False +) -> list[TreeRead]: + if deleted: + trees = await tree_service.list_deleted_trees_for_user(session, user=current) + else: + trees = await tree_service.list_trees_for_user(session, user=current) return [TreeRead.model_validate(t) for t in trees] @@ -31,3 +36,14 @@ async def list_my_trees(session: SessionDep, current: CurrentUser) -> list[TreeR async def get_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> TreeRead: tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) return TreeRead.model_validate(tree) + + +@router.delete("/{tree_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> None: + await tree_service.delete_tree(session, actor=current, tree_id=tree_id) + + +@router.post("/{tree_id}/restore", response_model=TreeRead) +async def restore_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> TreeRead: + tree = await tree_service.restore_tree(session, actor=current, tree_id=tree_id) + return TreeRead.model_validate(tree) diff --git a/backend/app/services/event_service.py b/backend/app/services/event_service.py index dbfdb3a..ebde8a8 100644 --- a/backend/app/services/event_service.py +++ b/backend/app/services/event_service.py @@ -91,6 +91,20 @@ async def create_event( return event +async def list_events( + session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree +) -> list[Event]: + """All events in the tree — lets the family view compute birth/death years.""" + if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree): + raise Forbidden("not permitted to view this tree") + stmt = ( + select(Event) + .where(Event.tree_id == tree.id, Event.deleted_at.is_(None)) + .order_by(Event.date_start.nulls_last(), Event.created_at) + ) + return list((await session.execute(stmt)).scalars().all()) + + async def list_events_for_person( session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID ) -> list[Event]: diff --git a/backend/app/services/person_service.py b/backend/app/services/person_service.py index 6338f71..78ba7e5 100644 --- a/backend/app/services/person_service.py +++ b/backend/app/services/person_service.py @@ -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]: diff --git a/backend/app/services/relationship_service.py b/backend/app/services/relationship_service.py index 129a39a..a0e855c 100644 --- a/backend/app/services/relationship_service.py +++ b/backend/app/services/relationship_service.py @@ -73,6 +73,20 @@ async def create_relationship( return relationship +async def list_relationships( + session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree +) -> list[Relationship]: + """All relationships in the tree — powers the family/pedigree view in one call.""" + if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree): + raise Forbidden("not permitted to view this tree") + stmt = ( + select(Relationship) + .where(Relationship.tree_id == tree.id, Relationship.deleted_at.is_(None)) + .order_by(Relationship.created_at) + ) + return list((await session.execute(stmt)).scalars().all()) + + async def list_relationships_for_person( session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID ) -> list[Relationship]: diff --git a/backend/app/services/tree_service.py b/backend/app/services/tree_service.py index ffc4c59..f056d55 100644 --- a/backend/app/services/tree_service.py +++ b/backend/app/services/tree_service.py @@ -3,6 +3,7 @@ authorization basis) and an audit entry. Reads go through the privacy engine. """ import uuid +from datetime import UTC, datetime from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -59,3 +60,55 @@ async def get_tree(session: AsyncSession, *, viewer_id: uuid.UUID, tree_id: uuid if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree): raise Forbidden("not permitted to view this tree") return tree + + +async def _owned_tree(session: AsyncSession, *, actor: User, tree_id: uuid.UUID) -> Tree: + """Load a tree (including soft-deleted) and require the actor be its owner.""" + tree = await BaseRepository(session, Tree).get(tree_id, include_deleted=True) + if tree is None: + raise NotFound("tree not found") + role = await privacy.get_membership_role(session, actor.id, tree.id) + if role is not MembershipRole.owner: + raise Forbidden("only the owner can delete or restore a tree") + return tree + + +async def delete_tree(session: AsyncSession, *, actor: User, tree_id: uuid.UUID) -> None: + tree = await _owned_tree(session, actor=actor, tree_id=tree_id) + if tree.deleted_at is None: + tree.deleted_at = datetime.now(UTC) + record_audit( + session, + action="delete", + entity_type="Tree", + entity_id=tree.id, + tree_id=tree.id, + actor_user_id=actor.id, + ) + await session.commit() + + +async def restore_tree(session: AsyncSession, *, actor: User, tree_id: uuid.UUID) -> Tree: + tree = await _owned_tree(session, actor=actor, tree_id=tree_id) + if tree.deleted_at is not None: + tree.deleted_at = None + record_audit( + session, + action="restore", + entity_type="Tree", + entity_id=tree.id, + tree_id=tree.id, + actor_user_id=actor.id, + ) + await session.commit() + return tree + + +async def list_deleted_trees_for_user(session: AsyncSession, *, user: User) -> list[Tree]: + stmt = ( + select(Tree) + .join(TreeMembership, TreeMembership.tree_id == Tree.id) + .where(TreeMembership.user_id == user.id, Tree.deleted_at.is_not(None)) + .order_by(Tree.deleted_at.desc()) + ) + return list((await session.execute(stmt)).scalars().all()) diff --git a/backend/tests/test_recovery.py b/backend/tests/test_recovery.py new file mode 100644 index 0000000..ff4a91e --- /dev/null +++ b/backend/tests/test_recovery.py @@ -0,0 +1,54 @@ +"""Soft-delete + recovery for trees and people.""" + +from tests.conftest import auth, register + + +async def test_tree_delete_and_restore(client): + h = auth(await register(client, "rec1@example.com")) + tree_id = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"] + + # Delete -> gone from active lists, present in the recovery list. + assert (await client.delete(f"/api/v1/trees/{tree_id}", headers=h)).status_code == 204 + assert len((await client.get("/api/v1/trees", headers=h)).json()) == 0 + # A soft-deleted tree is no longer visible (404 to the would-be viewer). + gone = await client.get(f"/api/v1/trees/{tree_id}", headers=h) + assert gone.status_code == 404 + deleted = (await client.get("/api/v1/trees?deleted=true", headers=h)).json() + assert len(deleted) == 1 and deleted[0]["id"] == tree_id + + # Restore -> back in active lists. + assert (await client.post(f"/api/v1/trees/{tree_id}/restore", headers=h)).status_code == 200 + assert len((await client.get("/api/v1/trees", headers=h)).json()) == 1 + assert (await client.get(f"/api/v1/trees/{tree_id}", headers=h)).status_code == 200 + + +async def test_only_owner_can_delete_tree(client): + owner = auth(await register(client, "rec-owner@example.com")) + tree_id = (await client.post("/api/v1/trees", json={"name": "T"}, headers=owner)).json()["id"] + other = auth(await register(client, "rec-other@example.com")) + blocked = await client.delete(f"/api/v1/trees/{tree_id}", headers=other) + assert blocked.status_code in (403, 404) + + +async def test_person_delete_and_restore(client): + h = auth(await register(client, "rec2@example.com")) + tree_id = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"] + person_id = ( + await client.post( + f"/api/v1/trees/{tree_id}/persons", json={"given": "Ada"}, headers=h + ) + ).json()["id"] + + assert ( + await client.delete(f"/api/v1/trees/{tree_id}/persons/{person_id}", headers=h) + ).status_code == 204 + assert len((await client.get(f"/api/v1/trees/{tree_id}/persons", headers=h)).json()) == 0 + deleted = ( + await client.get(f"/api/v1/trees/{tree_id}/persons?deleted=true", headers=h) + ).json() + assert len(deleted) == 1 and deleted[0]["primary_name"] == "Ada" + + assert ( + await client.post(f"/api/v1/trees/{tree_id}/persons/{person_id}/restore", headers=h) + ).status_code == 200 + assert len((await client.get(f"/api/v1/trees/{tree_id}/persons", headers=h)).json()) == 1