From cf5518c7ec34f0270edd0d43dfd82ed47581f757 Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Sun, 7 Jun 2026 09:53:17 -0400 Subject: [PATCH] Full-CRUD sweep: update endpoints for tree, source, citation, relationship, media MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the rule #8 gap at the API layer: PATCH endpoints + service updates for Tree (name/description/visibility), Source, Citation (page/detail/confidence), Relationship (qualifier/notes), and Media (title/attachment) — editor-gated and audited. Every core entity now has create/read/update/delete. Edit UIs for these land in the frontend batch. 37 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Justin Paul --- backend/app/api/v1/citations.py | 21 +++++- backend/app/api/v1/media.py | 22 +++++- backend/app/api/v1/relationships.py | 21 +++++- backend/app/api/v1/sources.py | 21 +++++- backend/app/api/v1/trees.py | 12 ++- backend/app/schemas/media.py | 7 ++ backend/app/schemas/relationship.py | 5 ++ backend/app/schemas/source.py | 17 +++++ backend/app/schemas/tree.py | 6 ++ backend/app/services/citation_service.py | 32 ++++++++ backend/app/services/media_service.py | 30 ++++++++ backend/app/services/relationship_service.py | 38 ++++++++++ backend/app/services/source_service.py | 36 +++++++++ backend/app/services/tree_service.py | 24 ++++++ backend/tests/test_crud_updates.py | 79 ++++++++++++++++++++ 15 files changed, 366 insertions(+), 5 deletions(-) create mode 100644 backend/tests/test_crud_updates.py diff --git a/backend/app/api/v1/citations.py b/backend/app/api/v1/citations.py index f48e99d..fdadf78 100644 --- a/backend/app/api/v1/citations.py +++ b/backend/app/api/v1/citations.py @@ -3,7 +3,7 @@ import uuid from fastapi import APIRouter, status from app.api.deps import CurrentUser, SessionDep -from app.schemas.source import CitationCreate, CitationRead +from app.schemas.source import CitationCreate, CitationRead, CitationUpdate from app.services import citation_service, tree_service router = APIRouter(prefix="/trees", tags=["citations"]) @@ -31,6 +31,25 @@ async def list_citations( return [CitationRead.model_validate(c) for c in citations] +@router.patch("/{tree_id}/citations/{citation_id}", response_model=CitationRead) +async def update_citation( + tree_id: uuid.UUID, + citation_id: uuid.UUID, + data: CitationUpdate, + session: SessionDep, + current: CurrentUser, +) -> CitationRead: + tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) + citation = await citation_service.update_citation( + session, + actor=current, + tree=tree, + citation_id=citation_id, + changes=data.model_dump(exclude_unset=True), + ) + return CitationRead.model_validate(citation) + + @router.delete("/{tree_id}/citations/{citation_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_citation( tree_id: uuid.UUID, citation_id: uuid.UUID, session: SessionDep, current: CurrentUser diff --git a/backend/app/api/v1/media.py b/backend/app/api/v1/media.py index 4de355d..ce8df8e 100644 --- a/backend/app/api/v1/media.py +++ b/backend/app/api/v1/media.py @@ -3,7 +3,7 @@ import uuid from fastapi import APIRouter, File, Form, Response, UploadFile, status from app.api.deps import CurrentUser, ObjectStoreDep, SessionDep -from app.schemas.media import MediaRead +from app.schemas.media import MediaRead, MediaUpdate from app.services import media_service, tree_service @@ -81,6 +81,26 @@ async def media_content( ) +@router.patch("/{tree_id}/media/{media_id}", response_model=MediaRead) +async def update_media( + tree_id: uuid.UUID, + media_id: uuid.UUID, + data: MediaUpdate, + session: SessionDep, + current: CurrentUser, + store: ObjectStoreDep, +) -> MediaRead: + tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) + media = await media_service.update_media( + session, + actor=current, + tree=tree, + media_id=media_id, + changes=data.model_dump(exclude_unset=True), + ) + return _read(media) + + @router.delete("/{tree_id}/media/{media_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_media( tree_id: uuid.UUID, media_id: uuid.UUID, session: SessionDep, current: CurrentUser diff --git a/backend/app/api/v1/relationships.py b/backend/app/api/v1/relationships.py index dbd4456..d7728cd 100644 --- a/backend/app/api/v1/relationships.py +++ b/backend/app/api/v1/relationships.py @@ -3,7 +3,7 @@ import uuid from fastapi import APIRouter, status from app.api.deps import CurrentUser, SessionDep -from app.schemas.relationship import RelationshipCreate, RelationshipRead +from app.schemas.relationship import RelationshipCreate, RelationshipRead, RelationshipUpdate from app.services import relationship_service, tree_service router = APIRouter(prefix="/trees", tags=["relationships"]) @@ -47,6 +47,25 @@ async def list_person_relationships( return [RelationshipRead.model_validate(r) for r in rels] +@router.patch("/{tree_id}/relationships/{relationship_id}", response_model=RelationshipRead) +async def update_relationship( + tree_id: uuid.UUID, + relationship_id: uuid.UUID, + data: RelationshipUpdate, + session: SessionDep, + current: CurrentUser, +) -> RelationshipRead: + tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) + rel = await relationship_service.update_relationship( + session, + actor=current, + tree=tree, + relationship_id=relationship_id, + changes=data.model_dump(exclude_unset=True), + ) + return RelationshipRead.model_validate(rel) + + @router.delete( "/{tree_id}/relationships/{relationship_id}", status_code=status.HTTP_204_NO_CONTENT ) diff --git a/backend/app/api/v1/sources.py b/backend/app/api/v1/sources.py index e7f598a..33a9e08 100644 --- a/backend/app/api/v1/sources.py +++ b/backend/app/api/v1/sources.py @@ -3,7 +3,7 @@ import uuid from fastapi import APIRouter, status from app.api.deps import CurrentUser, SessionDep -from app.schemas.source import SourceCreate, SourceRead +from app.schemas.source import SourceCreate, SourceRead, SourceUpdate from app.services import source_service, tree_service router = APIRouter(prefix="/trees", tags=["sources"]) @@ -40,6 +40,25 @@ async def get_source( return SourceRead.model_validate(source) +@router.patch("/{tree_id}/sources/{source_id}", response_model=SourceRead) +async def update_source( + tree_id: uuid.UUID, + source_id: uuid.UUID, + data: SourceUpdate, + session: SessionDep, + current: CurrentUser, +) -> SourceRead: + tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) + source = await source_service.update_source( + session, + actor=current, + tree=tree, + source_id=source_id, + changes=data.model_dump(exclude_unset=True), + ) + return SourceRead.model_validate(source) + + @router.delete("/{tree_id}/sources/{source_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_source( tree_id: uuid.UUID, source_id: uuid.UUID, session: SessionDep, current: CurrentUser diff --git a/backend/app/api/v1/trees.py b/backend/app/api/v1/trees.py index ab68530..b8f5439 100644 --- a/backend/app/api/v1/trees.py +++ b/backend/app/api/v1/trees.py @@ -3,7 +3,7 @@ import uuid from fastapi import APIRouter, status from app.api.deps import CurrentUser, SessionDep -from app.schemas.tree import TreeCreate, TreeRead +from app.schemas.tree import TreeCreate, TreeRead, TreeUpdate from app.services import tree_service router = APIRouter(prefix="/trees", tags=["trees"]) @@ -38,6 +38,16 @@ async def get_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser return TreeRead.model_validate(tree) +@router.patch("/{tree_id}", response_model=TreeRead) +async def update_tree( + tree_id: uuid.UUID, data: TreeUpdate, session: SessionDep, current: CurrentUser +) -> TreeRead: + tree = await tree_service.update_tree( + session, actor=current, tree_id=tree_id, changes=data.model_dump(exclude_unset=True) + ) + 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) diff --git a/backend/app/schemas/media.py b/backend/app/schemas/media.py index e7dff40..afa0909 100644 --- a/backend/app/schemas/media.py +++ b/backend/app/schemas/media.py @@ -4,6 +4,13 @@ from datetime import datetime from pydantic import BaseModel, ConfigDict +class MediaUpdate(BaseModel): + title: str | None = None + person_id: uuid.UUID | None = None + event_id: uuid.UUID | None = None + source_id: uuid.UUID | None = None + + class MediaRead(BaseModel): model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/schemas/relationship.py b/backend/app/schemas/relationship.py index 880cda4..2100b28 100644 --- a/backend/app/schemas/relationship.py +++ b/backend/app/schemas/relationship.py @@ -15,6 +15,11 @@ class RelationshipCreate(BaseModel): notes: str | None = None +class RelationshipUpdate(BaseModel): + qualifier: ParentChildQualifier | None = None + notes: str | None = None + + class RelationshipRead(BaseModel): model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/schemas/source.py b/backend/app/schemas/source.py index be35cb3..d60e84e 100644 --- a/backend/app/schemas/source.py +++ b/backend/app/schemas/source.py @@ -33,6 +33,23 @@ class SourceRead(BaseModel): created_at: datetime +class SourceUpdate(BaseModel): + title: str | None = None + author: str | None = None + source_type: str | None = None + repository: str | None = None + url: str | None = None + citation_text: str | None = None + publication_info: str | None = None + quality_note: str | None = None + + +class CitationUpdate(BaseModel): + page: str | None = None + detail: str | None = None + confidence: CitationConfidence | None = None + + class CitationCreate(BaseModel): source_id: uuid.UUID # Exactly one target fact. diff --git a/backend/app/schemas/tree.py b/backend/app/schemas/tree.py index 31007ee..2c52c89 100644 --- a/backend/app/schemas/tree.py +++ b/backend/app/schemas/tree.py @@ -12,6 +12,12 @@ class TreeCreate(BaseModel): visibility: TreeVisibility = TreeVisibility.private +class TreeUpdate(BaseModel): + name: str | None = None + description: str | None = None + visibility: TreeVisibility | None = None + + class TreeRead(BaseModel): model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/services/citation_service.py b/backend/app/services/citation_service.py index ee076ac..ccb4e20 100644 --- a/backend/app/services/citation_service.py +++ b/backend/app/services/citation_service.py @@ -113,6 +113,38 @@ async def list_citations( return list((await session.execute(stmt)).scalars().all()) +async def update_citation( + session: AsyncSession, *, actor: User, tree: Tree, citation_id: uuid.UUID, changes: dict +) -> Citation: + if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree): + raise Forbidden("not an editor of this tree") + citation = ( + await session.execute( + select(Citation).where( + Citation.id == citation_id, + Citation.tree_id == tree.id, + Citation.deleted_at.is_(None), + ) + ) + ).scalar_one_or_none() + if citation is None: + raise NotFound("citation not found") + for key in {"page", "detail", "confidence"} & changes.keys(): + setattr(citation, key, changes[key]) + record_audit( + session, + action="update", + entity_type="Citation", + entity_id=citation.id, + tree_id=tree.id, + actor_user_id=actor.id, + after=changes, + ) + await session.commit() + await session.refresh(citation) + return citation + + async def delete_citation( session: AsyncSession, *, actor: User, tree: Tree, citation_id: uuid.UUID ) -> None: diff --git a/backend/app/services/media_service.py b/backend/app/services/media_service.py index 013f8f7..6aa5ece 100644 --- a/backend/app/services/media_service.py +++ b/backend/app/services/media_service.py @@ -97,6 +97,36 @@ async def get_media( return media +async def update_media( + session: AsyncSession, *, actor: User, tree: Tree, media_id: uuid.UUID, changes: dict +) -> Media: + if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree): + raise Forbidden("not an editor of this tree") + media = ( + await session.execute( + select(Media).where( + Media.id == media_id, Media.tree_id == tree.id, Media.deleted_at.is_(None) + ) + ) + ).scalar_one_or_none() + if media is None: + raise NotFound("media not found") + for key in {"title", "person_id", "event_id", "source_id"} & changes.keys(): + setattr(media, key, changes[key]) + record_audit( + session, + action="update", + entity_type="Media", + entity_id=media.id, + tree_id=tree.id, + actor_user_id=actor.id, + after=changes, + ) + await session.commit() + await session.refresh(media) + return media + + async def delete_media( session: AsyncSession, *, actor: User, tree: Tree, media_id: uuid.UUID ) -> None: diff --git a/backend/app/services/relationship_service.py b/backend/app/services/relationship_service.py index a0e855c..0f3df4c 100644 --- a/backend/app/services/relationship_service.py +++ b/backend/app/services/relationship_service.py @@ -107,6 +107,44 @@ async def list_relationships_for_person( return list((await session.execute(stmt)).scalars().all()) +async def update_relationship( + session: AsyncSession, *, actor: User, tree: Tree, relationship_id: uuid.UUID, changes: dict +) -> Relationship: + 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") + if ( + "qualifier" in changes + and changes["qualifier"] is not None + and relationship.type is not RelationshipType.parent_child + ): + raise Conflict("qualifier only applies to parent_child relationships") + for key in {"qualifier", "notes"} & changes.keys(): + setattr(relationship, key, changes[key]) + record_audit( + session, + action="update", + entity_type="Relationship", + entity_id=relationship.id, + tree_id=tree.id, + actor_user_id=actor.id, + after=changes, + ) + await session.commit() + await session.refresh(relationship) + return relationship + + async def delete_relationship( session: AsyncSession, *, actor: User, tree: Tree, relationship_id: uuid.UUID ) -> None: diff --git a/backend/app/services/source_service.py b/backend/app/services/source_service.py index 927261b..d942903 100644 --- a/backend/app/services/source_service.py +++ b/backend/app/services/source_service.py @@ -86,6 +86,42 @@ async def get_source( return source +_SOURCE_FIELDS = { + "title", "author", "source_type", "repository", "url", "citation_text", + "publication_info", "quality_note", +} + + +async def update_source( + session: AsyncSession, *, actor: User, tree: Tree, source_id: uuid.UUID, changes: dict +) -> Source: + if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree): + raise Forbidden("not an editor of this tree") + source = ( + await session.execute( + select(Source).where( + Source.id == source_id, Source.tree_id == tree.id, Source.deleted_at.is_(None) + ) + ) + ).scalar_one_or_none() + if source is None: + raise NotFound("source not found") + for key in _SOURCE_FIELDS & changes.keys(): + setattr(source, key, changes[key]) + record_audit( + session, + action="update", + entity_type="Source", + entity_id=source.id, + tree_id=tree.id, + actor_user_id=actor.id, + after=changes, + ) + await session.commit() + await session.refresh(source) + return source + + async def delete_source( session: AsyncSession, *, actor: User, tree: Tree, source_id: uuid.UUID ) -> None: diff --git a/backend/app/services/tree_service.py b/backend/app/services/tree_service.py index f056d55..bd40824 100644 --- a/backend/app/services/tree_service.py +++ b/backend/app/services/tree_service.py @@ -62,6 +62,30 @@ async def get_tree(session: AsyncSession, *, viewer_id: uuid.UUID, tree_id: uuid return tree +async def update_tree( + session: AsyncSession, *, actor: User, tree_id: uuid.UUID, changes: dict +) -> Tree: + tree = await BaseRepository(session, Tree).get(tree_id) + if tree is None: + raise NotFound("tree not found") + if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree): + raise Forbidden("not an editor of this tree") + for key in {"name", "description", "visibility"} & changes.keys(): + setattr(tree, key, changes[key]) + record_audit( + session, + action="update", + entity_type="Tree", + entity_id=tree.id, + tree_id=tree.id, + actor_user_id=actor.id, + after=changes, + ) + await session.commit() + await session.refresh(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) diff --git a/backend/tests/test_crud_updates.py b/backend/tests/test_crud_updates.py new file mode 100644 index 0000000..4567a91 --- /dev/null +++ b/backend/tests/test_crud_updates.py @@ -0,0 +1,79 @@ +"""Update (the U in CRUD) for the remaining entities — rule #8.""" + +from tests.conftest import auth, register + + +async def _setup(client, email): + h = auth(await register(client, email)) + tid = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"] + return h, tid + + +async def test_tree_update(client): + h, tid = await _setup(client, "u-tree@example.com") + r = await client.patch( + f"/api/v1/trees/{tid}", json={"name": "Renamed", "visibility": "unlisted"}, headers=h + ) + assert r.status_code == 200 + assert r.json()["name"] == "Renamed" and r.json()["visibility"] == "unlisted" + + +async def test_source_update(client): + h, tid = await _setup(client, "u-src@example.com") + sid = ( + await client.post(f"/api/v1/trees/{tid}/sources", json={"title": "Old"}, headers=h) + ).json()["id"] + r = await client.patch( + f"/api/v1/trees/{tid}/sources/{sid}", + json={"title": "New", "repository": "NARA"}, + headers=h, + ) + assert r.status_code == 200 + assert r.json()["title"] == "New" and r.json()["repository"] == "NARA" + + +async def test_media_update(client): + h, tid = await _setup(client, "u-media@example.com") + mid = ( + await client.post( + f"/api/v1/trees/{tid}/media", + files={"file": ("a.txt", b"x", "text/plain")}, + data={"title": "old"}, + headers=h, + ) + ).json()["id"] + r = await client.patch(f"/api/v1/trees/{tid}/media/{mid}", json={"title": "new"}, headers=h) + assert r.status_code == 200 and r.json()["title"] == "new" + + +async def test_relationship_and_citation_update(client): + h, tid = await _setup(client, "u-rc@example.com") + + async def mk(path, body): + return (await client.post(f"/api/v1/trees/{tid}/{path}", json=body, headers=h)).json()["id"] + + p1 = await mk("persons", {"given": "A"}) + p2 = await mk("persons", {"given": "B"}) + rid = await mk( + "relationships", + { + "type": "parent_child", + "person_from_id": p1, + "person_to_id": p2, + "qualifier": "biological", + }, + ) + r = await client.patch( + f"/api/v1/trees/{tid}/relationships/{rid}", json={"qualifier": "adoptive"}, headers=h + ) + assert r.status_code == 200 and r.json()["qualifier"] == "adoptive" + + src = await mk("sources", {"title": "S"}) + cid = await mk("citations", {"source_id": src, "person_id": p1}) + r2 = await client.patch( + f"/api/v1/trees/{tid}/citations/{cid}", + json={"page": "p.7", "confidence": "high"}, + headers=h, + ) + assert r2.status_code == 200 + assert r2.json()["page"] == "p.7" and r2.json()["confidence"] == "high" -- 2.52.0