diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py index 88926cc..5a67ae3 100644 --- a/backend/app/api/v1/__init__.py +++ b/backend/app/api/v1/__init__.py @@ -2,7 +2,16 @@ from fastapi import APIRouter -from app.api.v1 import auth, events, persons, relationships, trees, users +from app.api.v1 import ( + auth, + citations, + events, + persons, + relationships, + sources, + trees, + users, +) api_router = APIRouter(prefix="/api/v1") api_router.include_router(auth.router) @@ -11,3 +20,5 @@ api_router.include_router(trees.router) api_router.include_router(persons.router) api_router.include_router(events.router) api_router.include_router(relationships.router) +api_router.include_router(sources.router) +api_router.include_router(citations.router) diff --git a/backend/app/api/v1/citations.py b/backend/app/api/v1/citations.py new file mode 100644 index 0000000..f48e99d --- /dev/null +++ b/backend/app/api/v1/citations.py @@ -0,0 +1,41 @@ +import uuid + +from fastapi import APIRouter, status + +from app.api.deps import CurrentUser, SessionDep +from app.schemas.source import CitationCreate, CitationRead +from app.services import citation_service, tree_service + +router = APIRouter(prefix="/trees", tags=["citations"]) + + +@router.post( + "/{tree_id}/citations", response_model=CitationRead, status_code=status.HTTP_201_CREATED +) +async def create_citation( + tree_id: uuid.UUID, data: CitationCreate, session: SessionDep, current: CurrentUser +) -> CitationRead: + tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) + citation = await citation_service.create_citation( + session, actor=current, tree=tree, **data.model_dump() + ) + return CitationRead.model_validate(citation) + + +@router.get("/{tree_id}/citations", response_model=list[CitationRead]) +async def list_citations( + tree_id: uuid.UUID, session: SessionDep, current: CurrentUser +) -> list[CitationRead]: + tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) + citations = await citation_service.list_citations(session, viewer_id=current.id, tree=tree) + return [CitationRead.model_validate(c) for c in citations] + + +@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 +) -> None: + tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) + await citation_service.delete_citation( + session, actor=current, tree=tree, citation_id=citation_id + ) diff --git a/backend/app/api/v1/sources.py b/backend/app/api/v1/sources.py new file mode 100644 index 0000000..e7f598a --- /dev/null +++ b/backend/app/api/v1/sources.py @@ -0,0 +1,48 @@ +import uuid + +from fastapi import APIRouter, status + +from app.api.deps import CurrentUser, SessionDep +from app.schemas.source import SourceCreate, SourceRead +from app.services import source_service, tree_service + +router = APIRouter(prefix="/trees", tags=["sources"]) + + +@router.post("/{tree_id}/sources", response_model=SourceRead, status_code=status.HTTP_201_CREATED) +async def create_source( + tree_id: uuid.UUID, data: SourceCreate, session: SessionDep, current: CurrentUser +) -> SourceRead: + tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) + source = await source_service.create_source( + session, actor=current, tree=tree, **data.model_dump() + ) + return SourceRead.model_validate(source) + + +@router.get("/{tree_id}/sources", response_model=list[SourceRead]) +async def list_sources( + tree_id: uuid.UUID, session: SessionDep, current: CurrentUser +) -> list[SourceRead]: + tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) + sources = await source_service.list_sources(session, viewer_id=current.id, tree=tree) + return [SourceRead.model_validate(s) for s in sources] + + +@router.get("/{tree_id}/sources/{source_id}", response_model=SourceRead) +async def get_source( + tree_id: uuid.UUID, source_id: uuid.UUID, session: SessionDep, current: CurrentUser +) -> SourceRead: + tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) + source = await source_service.get_source( + session, viewer_id=current.id, tree=tree, source_id=source_id + ) + 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 +) -> None: + tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) + await source_service.delete_source(session, actor=current, tree=tree, source_id=source_id) diff --git a/backend/app/schemas/source.py b/backend/app/schemas/source.py new file mode 100644 index 0000000..be35cb3 --- /dev/null +++ b/backend/app/schemas/source.py @@ -0,0 +1,61 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + +from app.models.enums import CitationConfidence + + +class SourceCreate(BaseModel): + title: str + 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 SourceRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + tree_id: uuid.UUID + title: str + author: str | None + source_type: str | None + repository: str | None + url: str | None + citation_text: str | None + publication_info: str | None + quality_note: str | None + created_at: datetime + + +class CitationCreate(BaseModel): + source_id: uuid.UUID + # Exactly one target fact. + person_id: uuid.UUID | None = None + event_id: uuid.UUID | None = None + name_id: uuid.UUID | None = None + relationship_id: uuid.UUID | None = None + page: str | None = None + detail: str | None = None + confidence: CitationConfidence | None = None + + +class CitationRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + tree_id: uuid.UUID + source_id: uuid.UUID + person_id: uuid.UUID | None + event_id: uuid.UUID | None + name_id: uuid.UUID | None + relationship_id: uuid.UUID | None + page: str | None + detail: str | None + confidence: CitationConfidence | None + created_at: datetime diff --git a/backend/app/services/citation_service.py b/backend/app/services/citation_service.py new file mode 100644 index 0000000..ee076ac --- /dev/null +++ b/backend/app/services/citation_service.py @@ -0,0 +1,141 @@ +"""Citation service. A citation links one Source to exactly one fact (person, +event, name, or relationship) within a tree — the provenance spine.""" + +import uuid +from datetime import UTC, datetime + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.enums import CitationConfidence +from app.models.event import Event +from app.models.person import Name, Person +from app.models.relationship import Relationship +from app.models.source import Citation, Source +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 + +# Citation target column -> model, for tenant/existence validation. +_TARGET_MODELS = { + "person_id": Person, + "event_id": Event, + "name_id": Name, + "relationship_id": Relationship, +} + + +async def _in_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_citation( + session: AsyncSession, + *, + actor: User, + tree: Tree, + source_id: uuid.UUID, + person_id: uuid.UUID | None = None, + event_id: uuid.UUID | None = None, + name_id: uuid.UUID | None = None, + relationship_id: uuid.UUID | None = None, + page: str | None = None, + detail: str | None = None, + confidence: CitationConfidence | None = None, +) -> Citation: + if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree): + raise Forbidden("not an editor of this tree") + + targets = { + "person_id": person_id, + "event_id": event_id, + "name_id": name_id, + "relationship_id": relationship_id, + } + set_targets = {k: v for k, v in targets.items() if v is not None} + if len(set_targets) != 1: + raise Conflict("a citation must reference exactly one fact") + + if not await _in_tree(session, Source, source_id, tree.id): + raise NotFound("source not found in this tree") + (target_col, target_id), = set_targets.items() + if not await _in_tree(session, _TARGET_MODELS[target_col], target_id, tree.id): + raise NotFound("cited fact not found in this tree") + + citation = Citation( + tree_id=tree.id, + source_id=source_id, + person_id=person_id, + event_id=event_id, + name_id=name_id, + relationship_id=relationship_id, + page=page, + detail=detail, + confidence=confidence, + ) + session.add(citation) + await session.flush() + record_audit( + session, + action="create", + entity_type="Citation", + entity_id=citation.id, + tree_id=tree.id, + actor_user_id=actor.id, + after={"source_id": str(source_id), target_col: str(target_id)}, + ) + await session.commit() + await session.refresh(citation) + return citation + + +async def list_citations( + session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree +) -> list[Citation]: + """All citations in the tree — the UI maps them to facts to show 'sourced' + indicators in a single round-trip.""" + if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree): + raise Forbidden("not permitted to view this tree") + stmt = ( + select(Citation) + .where(Citation.tree_id == tree.id, Citation.deleted_at.is_(None)) + .order_by(Citation.created_at) + ) + return list((await session.execute(stmt)).scalars().all()) + + +async def delete_citation( + session: AsyncSession, *, actor: User, tree: Tree, citation_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") + 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") + citation.deleted_at = datetime.now(UTC) + record_audit( + session, + action="delete", + entity_type="Citation", + entity_id=citation.id, + tree_id=tree.id, + actor_user_id=actor.id, + ) + await session.commit() diff --git a/backend/app/services/source_service.py b/backend/app/services/source_service.py new file mode 100644 index 0000000..927261b --- /dev/null +++ b/backend/app/services/source_service.py @@ -0,0 +1,112 @@ +"""Source service. Sources are reusable, tree-scoped records of an origin. +Writes require editor rights; reads go through the privacy engine.""" + +import uuid +from datetime import UTC, datetime + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.source import Source +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, NotFound + + +async def create_source( + session: AsyncSession, + *, + actor: User, + tree: Tree, + title: str, + 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, +) -> Source: + if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree): + raise Forbidden("not an editor of this tree") + source = Source( + tree_id=tree.id, + title=title, + author=author, + source_type=source_type, + repository=repository, + url=url, + citation_text=citation_text, + publication_info=publication_info, + quality_note=quality_note, + ) + session.add(source) + await session.flush() + record_audit( + session, + action="create", + entity_type="Source", + entity_id=source.id, + tree_id=tree.id, + actor_user_id=actor.id, + after={"title": title}, + ) + await session.commit() + await session.refresh(source) + return source + + +async def list_sources(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree) -> list[Source]: + if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree): + raise Forbidden("not permitted to view this tree") + stmt = ( + select(Source) + .where(Source.tree_id == tree.id, Source.deleted_at.is_(None)) + .order_by(Source.title) + ) + return list((await session.execute(stmt)).scalars().all()) + + +async def get_source( + session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, source_id: uuid.UUID +) -> Source: + if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree): + raise Forbidden("not permitted to view 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") + return source + + +async def delete_source( + session: AsyncSession, *, actor: User, tree: Tree, source_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") + 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") + source.deleted_at = datetime.now(UTC) + record_audit( + session, + action="delete", + entity_type="Source", + entity_id=source.id, + tree_id=tree.id, + actor_user_id=actor.id, + ) + await session.commit() diff --git a/backend/tests/test_sources.py b/backend/tests/test_sources.py new file mode 100644 index 0000000..bc4226f --- /dev/null +++ b/backend/tests/test_sources.py @@ -0,0 +1,96 @@ +"""Sources and citations (the provenance spine).""" + +import uuid + +from tests.conftest import auth, register + + +async def _setup(client, email): + h = auth(await register(client, email)) + tree_id = (await client.post("/api/v1/trees", json={"name": "S"}, headers=h)).json()["id"] + person = ( + await client.post( + f"/api/v1/trees/{tree_id}/persons", json={"given": "Cy"}, headers=h + ) + ).json()["id"] + event = ( + await client.post( + f"/api/v1/trees/{tree_id}/events", + json={"event_type": "birth", "person_id": person}, + headers=h, + ) + ).json()["id"] + return h, tree_id, person, event + + +async def test_source_and_citation_flow(client): + h, tree_id, person, event = await _setup(client, "src1@example.com") + + src = await client.post( + f"/api/v1/trees/{tree_id}/sources", + json={"title": "1880 Census", "repository": "NARA"}, + headers=h, + ) + assert src.status_code == 201, src.text + source_id = src.json()["id"] + + assert len((await client.get(f"/api/v1/trees/{tree_id}/sources", headers=h)).json()) == 1 + + # Cite the source on the birth event. + cite = await client.post( + f"/api/v1/trees/{tree_id}/citations", + json={"source_id": source_id, "event_id": event, "page": "p. 4"}, + headers=h, + ) + assert cite.status_code == 201, cite.text + citation_id = cite.json()["id"] + + citations = (await client.get(f"/api/v1/trees/{tree_id}/citations", headers=h)).json() + assert len(citations) == 1 + assert citations[0]["event_id"] == event + assert citations[0]["source_id"] == source_id + + resp = await client.delete(f"/api/v1/trees/{tree_id}/citations/{citation_id}", headers=h) + assert resp.status_code == 204 + assert len((await client.get(f"/api/v1/trees/{tree_id}/citations", headers=h)).json()) == 0 + + +async def test_citation_needs_exactly_one_target(client): + h, tree_id, person, event = await _setup(client, "src2@example.com") + source_id = ( + await client.post( + f"/api/v1/trees/{tree_id}/sources", json={"title": "X"}, headers=h + ) + ).json()["id"] + + # No target. + r = await client.post( + f"/api/v1/trees/{tree_id}/citations", json={"source_id": source_id}, headers=h + ) + assert r.status_code == 409 + # Two targets. + r = await client.post( + f"/api/v1/trees/{tree_id}/citations", + json={"source_id": source_id, "person_id": person, "event_id": event}, + headers=h, + ) + assert r.status_code == 409 + + +async def test_citation_unknown_source_404(client): + h, tree_id, person, _ = await _setup(client, "src3@example.com") + r = await client.post( + f"/api/v1/trees/{tree_id}/citations", + json={"source_id": str(uuid.uuid4()), "person_id": person}, + headers=h, + ) + assert r.status_code == 404 + + +async def test_non_member_cannot_create_source(client): + h, tree_id, _, _ = await _setup(client, "src4@example.com") + other = auth(await register(client, "src-intruder@example.com")) + r = await client.post( + f"/api/v1/trees/{tree_id}/sources", json={"title": "nope"}, headers=other + ) + assert r.status_code == 403 diff --git a/frontend/app/trees/[id]/page.tsx b/frontend/app/trees/[id]/page.tsx index b7907d7..1d54034 100644 --- a/frontend/app/trees/[id]/page.tsx +++ b/frontend/app/trees/[id]/page.tsx @@ -56,9 +56,14 @@ export default function TreeDetailPage() { return (
Loading…
; if (!person) returnNot found.
; + // Inline "cite" control: a badge with count, a toggle, and the picker form. + function citeControl(key: string, target: PartialNo events yet.
) : ( -