diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py index ee7faa3..88926cc 100644 --- a/backend/app/api/v1/__init__.py +++ b/backend/app/api/v1/__init__.py @@ -2,10 +2,12 @@ from fastapi import APIRouter -from app.api.v1 import auth, persons, trees, users +from app.api.v1 import auth, events, persons, relationships, trees, users api_router = APIRouter(prefix="/api/v1") api_router.include_router(auth.router) api_router.include_router(users.router) api_router.include_router(trees.router) api_router.include_router(persons.router) +api_router.include_router(events.router) +api_router.include_router(relationships.router) diff --git a/backend/app/api/v1/events.py b/backend/app/api/v1/events.py new file mode 100644 index 0000000..90b73c6 --- /dev/null +++ b/backend/app/api/v1/events.py @@ -0,0 +1,39 @@ +import uuid + +from fastapi import APIRouter, status + +from app.api.deps import CurrentUser, SessionDep +from app.schemas.event import EventCreate, EventRead +from app.services import event_service, tree_service + +router = APIRouter(prefix="/trees", tags=["events"]) + + +@router.post("/{tree_id}/events", response_model=EventRead, status_code=status.HTTP_201_CREATED) +async def create_event( + tree_id: uuid.UUID, data: EventCreate, session: SessionDep, current: CurrentUser +) -> EventRead: + tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) + event = await event_service.create_event( + session, actor=current, tree=tree, **data.model_dump() + ) + return EventRead.model_validate(event) + + +@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 +) -> list[EventRead]: + tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) + events = await event_service.list_events_for_person( + session, viewer_id=current.id, tree=tree, person_id=person_id + ) + return [EventRead.model_validate(e) for e in events] + + +@router.delete("/{tree_id}/events/{event_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_event( + tree_id: uuid.UUID, event_id: uuid.UUID, session: SessionDep, current: CurrentUser +) -> None: + tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) + await event_service.delete_event(session, actor=current, tree=tree, event_id=event_id) diff --git a/backend/app/api/v1/persons.py b/backend/app/api/v1/persons.py index 8624918..913d266 100644 --- a/backend/app/api/v1/persons.py +++ b/backend/app/api/v1/persons.py @@ -41,3 +41,14 @@ async def list_persons( 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) return [PersonRead.model_validate(p) for p in persons] + + +@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 +) -> PersonRead: + tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) + person = await person_service.get_person( + session, viewer_id=current.id, tree=tree, person_id=person_id + ) + return PersonRead.model_validate(person) diff --git a/backend/app/api/v1/relationships.py b/backend/app/api/v1/relationships.py new file mode 100644 index 0000000..dab0146 --- /dev/null +++ b/backend/app/api/v1/relationships.py @@ -0,0 +1,50 @@ +import uuid + +from fastapi import APIRouter, status + +from app.api.deps import CurrentUser, SessionDep +from app.schemas.relationship import RelationshipCreate, RelationshipRead +from app.services import relationship_service, tree_service + +router = APIRouter(prefix="/trees", tags=["relationships"]) + + +@router.post( + "/{tree_id}/relationships", + response_model=RelationshipRead, + status_code=status.HTTP_201_CREATED, +) +async def create_relationship( + tree_id: uuid.UUID, data: RelationshipCreate, session: SessionDep, current: CurrentUser +) -> RelationshipRead: + tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) + relationship = await relationship_service.create_relationship( + session, actor=current, tree=tree, **data.model_dump() + ) + return RelationshipRead.model_validate(relationship) + + +@router.get( + "/{tree_id}/persons/{person_id}/relationships", + response_model=list[RelationshipRead], +) +async def list_person_relationships( + tree_id: uuid.UUID, person_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_for_person( + session, viewer_id=current.id, tree=tree, person_id=person_id + ) + return [RelationshipRead.model_validate(r) for r in rels] + + +@router.delete( + "/{tree_id}/relationships/{relationship_id}", status_code=status.HTTP_204_NO_CONTENT +) +async def delete_relationship( + tree_id: uuid.UUID, relationship_id: uuid.UUID, session: SessionDep, current: CurrentUser +) -> None: + tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) + await relationship_service.delete_relationship( + session, actor=current, tree=tree, relationship_id=relationship_id + ) diff --git a/backend/app/schemas/event.py b/backend/app/schemas/event.py new file mode 100644 index 0000000..9f6bd3d --- /dev/null +++ b/backend/app/schemas/event.py @@ -0,0 +1,39 @@ +import uuid +from datetime import date, datetime + +from pydantic import BaseModel, ConfigDict + + +class EventCreate(BaseModel): + event_type: str + # Exactly one subject: a person or a partnership (relationship). + person_id: uuid.UUID | None = None + relationship_id: uuid.UUID | None = None + place_id: uuid.UUID | None = None + # Verbatim date string (e.g. "ABT 1850") and/or a normalized range. + date_value: str | None = None + date_start: date | None = None + date_end: date | None = None + date_precision: str | None = None + calendar: str = "gregorian" + detail: str | None = None + notes: str | None = None + + +class EventRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + tree_id: uuid.UUID + event_type: str + person_id: uuid.UUID | None + relationship_id: uuid.UUID | None + place_id: uuid.UUID | None + date_value: str | None + date_start: date | None + date_end: date | None + date_precision: str | None + calendar: str + detail: str | None + notes: str | None + created_at: datetime diff --git a/backend/app/schemas/relationship.py b/backend/app/schemas/relationship.py new file mode 100644 index 0000000..880cda4 --- /dev/null +++ b/backend/app/schemas/relationship.py @@ -0,0 +1,28 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + +from app.models.enums import ParentChildQualifier, RelationshipType + + +class RelationshipCreate(BaseModel): + type: RelationshipType + person_from_id: uuid.UUID + person_to_id: uuid.UUID + # Only meaningful for parent_child edges (from = parent, to = child). + qualifier: ParentChildQualifier | None = None + notes: str | None = None + + +class RelationshipRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + tree_id: uuid.UUID + type: RelationshipType + person_from_id: uuid.UUID + person_to_id: uuid.UUID + qualifier: ParentChildQualifier | None + notes: str | None + created_at: datetime diff --git a/backend/app/services/event_service.py b/backend/app/services/event_service.py new file mode 100644 index 0000000..dbfdb3a --- /dev/null +++ b/backend/app/services/event_service.py @@ -0,0 +1,136 @@ +"""Event service. Writes require editor rights; reads go through the privacy +engine. Every event has exactly one subject — a Person or a partnership.""" + +import uuid +from datetime import date + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.event import Event +from app.models.person import Person +from app.models.place import Place +from app.models.relationship import Relationship +from app.models.tree import Tree +from app.models.user import User +from app.services import privacy +from app.services.audit import record_audit +from app.services.exceptions import Conflict, Forbidden, NotFound + + +async def _belongs_to_tree( + session: AsyncSession, model: type, id_: uuid.UUID, tree_id: uuid.UUID +) -> bool: + row = ( + await session.execute( + select(model.id).where( + model.id == id_, model.tree_id == tree_id, model.deleted_at.is_(None) + ) + ) + ).scalar_one_or_none() + return row is not None + + +async def create_event( + session: AsyncSession, + *, + actor: User, + tree: Tree, + event_type: str, + person_id: uuid.UUID | None = None, + relationship_id: uuid.UUID | None = None, + place_id: uuid.UUID | None = None, + date_value: str | None = None, + date_start: date | None = None, + date_end: date | None = None, + date_precision: str | None = None, + calendar: str = "gregorian", + detail: str | None = None, + notes: str | None = None, +) -> Event: + if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree): + raise Forbidden("not an editor of this tree") + if bool(person_id) == bool(relationship_id): + raise Conflict("an event needs exactly one subject: person_id or relationship_id") + if person_id and not await _belongs_to_tree(session, Person, person_id, tree.id): + raise NotFound("person not found in this tree") + if relationship_id and not await _belongs_to_tree( + session, Relationship, relationship_id, tree.id + ): + raise NotFound("relationship not found in this tree") + if place_id and not await _belongs_to_tree(session, Place, place_id, tree.id): + raise NotFound("place not found in this tree") + + event = Event( + tree_id=tree.id, + event_type=event_type, + person_id=person_id, + relationship_id=relationship_id, + place_id=place_id, + date_value=date_value, + date_start=date_start, + date_end=date_end, + date_precision=date_precision, + calendar=calendar, + detail=detail, + notes=notes, + ) + session.add(event) + await session.flush() + record_audit( + session, + action="create", + entity_type="Event", + entity_id=event.id, + tree_id=tree.id, + actor_user_id=actor.id, + after={"event_type": event_type, "person_id": str(person_id) if person_id else None}, + ) + await session.commit() + await session.refresh(event) + return event + + +async def list_events_for_person( + session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID +) -> list[Event]: + 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.person_id == person_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 delete_event( + session: AsyncSession, *, actor: User, tree: Tree, event_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") + event = ( + await session.execute( + select(Event).where( + Event.id == event_id, Event.tree_id == tree.id, Event.deleted_at.is_(None) + ) + ) + ).scalar_one_or_none() + if event is None: + raise NotFound("event not found") + from datetime import UTC, datetime + + event.deleted_at = datetime.now(UTC) + record_audit( + session, + action="delete", + entity_type="Event", + entity_id=event.id, + tree_id=tree.id, + actor_user_id=actor.id, + ) + await session.commit() diff --git a/backend/app/services/person_service.py b/backend/app/services/person_service.py index 320deb1..6338f71 100644 --- a/backend/app/services/person_service.py +++ b/backend/app/services/person_service.py @@ -14,7 +14,7 @@ from app.models.tree import Tree from app.models.user import User from app.services import privacy from app.services.audit import record_audit -from app.services.exceptions import Forbidden +from app.services.exceptions import Forbidden, NotFound from app.services.privacy import Visibility @@ -86,6 +86,32 @@ async def create_person( return person +async def get_person( + session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID +) -> Person: + if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree): + raise Forbidden("not permitted to view 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") + # Run the single person through the privacy engine (redaction lands Phase 2). + if ( + await privacy.person_visibility(session, user_id=viewer_id, tree=tree, person=person) + == Visibility.hidden + ): + raise NotFound("person not found") + await _attach_primary_name(session, person) + return person + + 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 new file mode 100644 index 0000000..129a39a --- /dev/null +++ b/backend/app/services/relationship_service.py @@ -0,0 +1,121 @@ +"""Relationship service. Typed, qualified edges between two Persons in a tree. +Writes require editor rights; reads go through the privacy engine.""" + +import uuid +from datetime import UTC, datetime + +from sqlalchemy import or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.enums import ParentChildQualifier, RelationshipType +from app.models.person import Person +from app.models.relationship import Relationship +from app.models.tree import Tree +from app.models.user import User +from app.services import privacy +from app.services.audit import record_audit +from app.services.exceptions import Conflict, Forbidden, NotFound + + +async def _person_in_tree(session: AsyncSession, person_id: uuid.UUID, tree_id: uuid.UUID) -> bool: + row = ( + await session.execute( + select(Person.id).where( + Person.id == person_id, Person.tree_id == tree_id, Person.deleted_at.is_(None) + ) + ) + ).scalar_one_or_none() + return row is not None + + +async def create_relationship( + session: AsyncSession, + *, + actor: User, + tree: Tree, + type: RelationshipType, + person_from_id: uuid.UUID, + person_to_id: uuid.UUID, + qualifier: ParentChildQualifier | None = None, + notes: str | None = None, +) -> Relationship: + if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree): + raise Forbidden("not an editor of this tree") + if person_from_id == person_to_id: + raise Conflict("a relationship needs two different people") + if qualifier is not None and type is not RelationshipType.parent_child: + raise Conflict("qualifier only applies to parent_child relationships") + for pid in (person_from_id, person_to_id): + if not await _person_in_tree(session, pid, tree.id): + raise NotFound("person not found in this tree") + + relationship = Relationship( + tree_id=tree.id, + type=type, + person_from_id=person_from_id, + person_to_id=person_to_id, + qualifier=qualifier, + notes=notes, + ) + session.add(relationship) + await session.flush() + record_audit( + session, + action="create", + entity_type="Relationship", + entity_id=relationship.id, + tree_id=tree.id, + actor_user_id=actor.id, + after={"type": type.value, "from": str(person_from_id), "to": str(person_to_id)}, + ) + await session.commit() + await session.refresh(relationship) + return relationship + + +async def list_relationships_for_person( + session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID +) -> list[Relationship]: + 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), + or_( + Relationship.person_from_id == person_id, + Relationship.person_to_id == person_id, + ), + ) + .order_by(Relationship.created_at) + ) + return list((await session.execute(stmt)).scalars().all()) + + +async def delete_relationship( + session: AsyncSession, *, actor: User, tree: Tree, relationship_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") + relationship = ( + await session.execute( + select(Relationship).where( + Relationship.id == relationship_id, + Relationship.tree_id == tree.id, + Relationship.deleted_at.is_(None), + ) + ) + ).scalar_one_or_none() + if relationship is None: + raise NotFound("relationship not found") + relationship.deleted_at = datetime.now(UTC) + record_audit( + session, + action="delete", + entity_type="Relationship", + entity_id=relationship.id, + tree_id=tree.id, + actor_user_id=actor.id, + ) + await session.commit() diff --git a/backend/tests/test_graph.py b/backend/tests/test_graph.py new file mode 100644 index 0000000..9af83a0 --- /dev/null +++ b/backend/tests/test_graph.py @@ -0,0 +1,116 @@ +"""Events and relationships through the API.""" + +from tests.conftest import auth, register + + +async def _setup_tree_with_two_people(client, email: str): + token = await register(client, email) + h = auth(token) + tree_id = ( + await client.post("/api/v1/trees", json={"name": "Graph"}, headers=h) + ).json()["id"] + parent = ( + await client.post( + f"/api/v1/trees/{tree_id}/persons", + json={"given": "Anna", "surname": "Vogel"}, + headers=h, + ) + ).json()["id"] + child = ( + await client.post( + f"/api/v1/trees/{tree_id}/persons", + json={"given": "Beth", "surname": "Vogel"}, + headers=h, + ) + ).json()["id"] + return h, tree_id, parent, child + + +async def test_event_create_list_delete(client): + h, tree_id, parent, _ = await _setup_tree_with_two_people(client, "ev1@example.com") + + resp = await client.post( + f"/api/v1/trees/{tree_id}/events", + json={"event_type": "birth", "person_id": parent, "date_value": "ABT 1850"}, + headers=h, + ) + assert resp.status_code == 201, resp.text + event_id = resp.json()["id"] + + listed = await client.get(f"/api/v1/trees/{tree_id}/persons/{parent}/events", headers=h) + assert listed.status_code == 200 + assert len(listed.json()) == 1 + assert listed.json()[0]["event_type"] == "birth" + + resp = await client.delete(f"/api/v1/trees/{tree_id}/events/{event_id}", headers=h) + assert resp.status_code == 204 + listed = await client.get(f"/api/v1/trees/{tree_id}/persons/{parent}/events", headers=h) + assert len(listed.json()) == 0 + + +async def test_event_requires_exactly_one_subject(client): + h, tree_id, _, _ = await _setup_tree_with_two_people(client, "ev2@example.com") + resp = await client.post( + f"/api/v1/trees/{tree_id}/events", json={"event_type": "birth"}, headers=h + ) + assert resp.status_code == 409 + + +async def test_relationship_create_and_list(client): + h, tree_id, parent, child = await _setup_tree_with_two_people(client, "rel1@example.com") + + resp = await client.post( + f"/api/v1/trees/{tree_id}/relationships", + json={ + "type": "parent_child", + "person_from_id": parent, + "person_to_id": child, + "qualifier": "biological", + }, + headers=h, + ) + assert resp.status_code == 201, resp.text + + for pid in (parent, child): + listed = await client.get( + f"/api/v1/trees/{tree_id}/persons/{pid}/relationships", headers=h + ) + assert listed.status_code == 200 + assert len(listed.json()) == 1 + assert listed.json()[0]["qualifier"] == "biological" + + +async def test_relationship_validation(client): + h, tree_id, parent, _ = await _setup_tree_with_two_people(client, "rel2@example.com") + # Same person on both ends. + resp = await client.post( + f"/api/v1/trees/{tree_id}/relationships", + json={"type": "sibling", "person_from_id": parent, "person_to_id": parent}, + headers=h, + ) + assert resp.status_code == 409 + + # Qualifier on a non-parent_child edge. + h2, t2, p_a, p_b = await _setup_tree_with_two_people(client, "rel3@example.com") + resp = await client.post( + f"/api/v1/trees/{t2}/relationships", + json={ + "type": "partnership", + "person_from_id": p_a, + "person_to_id": p_b, + "qualifier": "biological", + }, + headers=h2, + ) + assert resp.status_code == 409 + + +async def test_non_member_cannot_write_graph(client): + h, tree_id, parent, _ = await _setup_tree_with_two_people(client, "rel4@example.com") + other = auth(await register(client, "intruder@example.com")) + resp = await client.post( + f"/api/v1/trees/{tree_id}/events", + json={"event_type": "birth", "person_id": parent}, + headers=other, + ) + assert resp.status_code == 403