Add events and relationships API (Phase 1: flesh out the graph)

Events (create/list-per-person/soft-delete) and relationships (create/list-per-person/soft-delete) through the layered stack: editor-gated writes, privacy-engine reads, audit on every change. Events carry exactly one subject (person XOR partnership); relationships are typed qualified edges (parent_child gets a biological/adoptive/step/foster/donor/guardian qualifier). Adds a single-person GET. 18 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 12:10:56 -04:00
parent a799d101b5
commit d6e2df4a61
10 changed files with 570 additions and 2 deletions
+3 -1
View File
@@ -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)
+39
View File
@@ -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)
+11
View File
@@ -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)
+50
View File
@@ -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
)
+39
View File
@@ -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
+28
View File
@@ -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
+136
View File
@@ -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()
+27 -1
View File
@@ -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]:
@@ -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()
+116
View File
@@ -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