Visibility phase 3: redaction-safe public read API + leak test #44
@@ -40,6 +40,18 @@ async def get_current_user(request: Request, session: SessionDep) -> User:
|
||||
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||
|
||||
|
||||
async def get_current_user_or_none(request: Request, session: SessionDep) -> User | None:
|
||||
"""Optional auth for public read endpoints — never raises. Returns the user
|
||||
when a valid session is present, else None (anonymous viewer)."""
|
||||
raw_token = extract_session_token(request)
|
||||
if raw_token is None:
|
||||
return None
|
||||
return await auth_service.resolve_session_user(session, raw_token=raw_token)
|
||||
|
||||
|
||||
CurrentUserOrNone = Annotated[User | None, Depends(get_current_user_or_none)]
|
||||
|
||||
|
||||
def get_mailer() -> Mailer:
|
||||
settings = get_settings()
|
||||
if settings.mailer == "smtp" and settings.smtp_host:
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.api.v1 import (
|
||||
media,
|
||||
names,
|
||||
persons,
|
||||
public,
|
||||
relationships,
|
||||
sources,
|
||||
trees,
|
||||
@@ -30,3 +31,4 @@ api_router.include_router(citations.router)
|
||||
api_router.include_router(media.router)
|
||||
api_router.include_router(gedcom.router)
|
||||
api_router.include_router(cleanup.router)
|
||||
api_router.include_router(public.router)
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Public, read-only viewing surface.
|
||||
|
||||
Optional auth (anonymous allowed). Every response is built by
|
||||
``public_view_service``, which routes through the privacy engine and redacts
|
||||
possibly-living people. No create/update/delete here.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.deps import CurrentUserOrNone, SessionDep
|
||||
from app.schemas.event import EventRead
|
||||
from app.schemas.name import NameRead
|
||||
from app.schemas.person import PersonRead
|
||||
from app.schemas.relationship import RelationshipRead
|
||||
from app.schemas.tree import PublicTreeRead
|
||||
from app.services import public_view_service
|
||||
|
||||
router = APIRouter(prefix="/public", tags=["public"])
|
||||
|
||||
|
||||
def _vid(viewer: CurrentUserOrNone) -> uuid.UUID | None:
|
||||
return viewer.id if viewer else None
|
||||
|
||||
|
||||
@router.get("/trees", response_model=list[PublicTreeRead])
|
||||
async def public_directory(
|
||||
session: SessionDep,
|
||||
viewer: CurrentUserOrNone,
|
||||
q: str | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[PublicTreeRead]:
|
||||
trees = await public_view_service.list_public_trees(
|
||||
session, viewer_id=_vid(viewer), q=q, limit=limit, offset=offset
|
||||
)
|
||||
return [PublicTreeRead.model_validate(t) for t in trees]
|
||||
|
||||
|
||||
@router.get("/trees/{tree_id}", response_model=PublicTreeRead)
|
||||
async def public_tree(
|
||||
tree_id: uuid.UUID, session: SessionDep, viewer: CurrentUserOrNone
|
||||
) -> PublicTreeRead:
|
||||
tree = await public_view_service.get_public_tree(
|
||||
session, viewer_id=_vid(viewer), tree_id=tree_id
|
||||
)
|
||||
return PublicTreeRead.model_validate(tree)
|
||||
|
||||
|
||||
@router.get("/trees/{tree_id}/persons", response_model=list[PersonRead])
|
||||
async def public_persons(
|
||||
tree_id: uuid.UUID, session: SessionDep, viewer: CurrentUserOrNone
|
||||
) -> list[PersonRead]:
|
||||
tree = await public_view_service.get_public_tree(
|
||||
session, viewer_id=_vid(viewer), tree_id=tree_id
|
||||
)
|
||||
persons = await public_view_service.list_public_persons(
|
||||
session, viewer_id=_vid(viewer), tree=tree
|
||||
)
|
||||
return [PersonRead.model_validate(p) for p in persons]
|
||||
|
||||
|
||||
@router.get("/trees/{tree_id}/relationships", response_model=list[RelationshipRead])
|
||||
async def public_relationships(
|
||||
tree_id: uuid.UUID, session: SessionDep, viewer: CurrentUserOrNone
|
||||
) -> list[RelationshipRead]:
|
||||
tree = await public_view_service.get_public_tree(
|
||||
session, viewer_id=_vid(viewer), tree_id=tree_id
|
||||
)
|
||||
rels = await public_view_service.list_public_relationships(
|
||||
session, viewer_id=_vid(viewer), tree=tree
|
||||
)
|
||||
return [RelationshipRead.model_validate(r) for r in rels]
|
||||
|
||||
|
||||
@router.get("/trees/{tree_id}/events", response_model=list[EventRead])
|
||||
async def public_events(
|
||||
tree_id: uuid.UUID, session: SessionDep, viewer: CurrentUserOrNone
|
||||
) -> list[EventRead]:
|
||||
tree = await public_view_service.get_public_tree(
|
||||
session, viewer_id=_vid(viewer), tree_id=tree_id
|
||||
)
|
||||
events = await public_view_service.list_public_events(
|
||||
session, viewer_id=_vid(viewer), tree=tree
|
||||
)
|
||||
return [EventRead.model_validate(e) for e in events]
|
||||
|
||||
|
||||
@router.get("/trees/{tree_id}/persons/{person_id}", response_model=PersonRead)
|
||||
async def public_person(
|
||||
tree_id: uuid.UUID,
|
||||
person_id: uuid.UUID,
|
||||
session: SessionDep,
|
||||
viewer: CurrentUserOrNone,
|
||||
) -> PersonRead:
|
||||
tree = await public_view_service.get_public_tree(
|
||||
session, viewer_id=_vid(viewer), tree_id=tree_id
|
||||
)
|
||||
person = await public_view_service.get_public_person(
|
||||
session, viewer_id=_vid(viewer), tree=tree, person_id=person_id
|
||||
)
|
||||
return PersonRead.model_validate(person)
|
||||
|
||||
|
||||
@router.get("/trees/{tree_id}/persons/{person_id}/names", response_model=list[NameRead])
|
||||
async def public_person_names(
|
||||
tree_id: uuid.UUID,
|
||||
person_id: uuid.UUID,
|
||||
session: SessionDep,
|
||||
viewer: CurrentUserOrNone,
|
||||
) -> list[NameRead]:
|
||||
tree = await public_view_service.get_public_tree(
|
||||
session, viewer_id=_vid(viewer), tree_id=tree_id
|
||||
)
|
||||
names = await public_view_service.list_public_person_names(
|
||||
session, viewer_id=_vid(viewer), tree=tree, person_id=person_id
|
||||
)
|
||||
return [NameRead.model_validate(n) for n in names]
|
||||
|
||||
|
||||
@router.get("/trees/{tree_id}/persons/{person_id}/events", response_model=list[EventRead])
|
||||
async def public_person_events(
|
||||
tree_id: uuid.UUID,
|
||||
person_id: uuid.UUID,
|
||||
session: SessionDep,
|
||||
viewer: CurrentUserOrNone,
|
||||
) -> list[EventRead]:
|
||||
tree = await public_view_service.get_public_tree(
|
||||
session, viewer_id=_vid(viewer), tree_id=tree_id
|
||||
)
|
||||
events = await public_view_service.list_public_person_events(
|
||||
session, viewer_id=_vid(viewer), tree=tree, person_id=person_id
|
||||
)
|
||||
return [EventRead.model_validate(e) for e in events]
|
||||
@@ -29,3 +29,16 @@ class TreeRead(BaseModel):
|
||||
owner_id: uuid.UUID
|
||||
home_person_id: uuid.UUID | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class PublicTreeRead(BaseModel):
|
||||
"""Tree projection for the public surface — deliberately omits owner_id so a
|
||||
public/unlisted tree doesn't reveal which account owns it."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
description: str | None
|
||||
visibility: TreeVisibility
|
||||
home_person_id: uuid.UUID | None = None
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
"""Read-only, redaction-safe projections for the public viewing surface.
|
||||
|
||||
INVARIANT (CLAUDE.md #2): everything returned here has passed through
|
||||
``privacy.person_visibility``. A non-member must never receive a possibly-living
|
||||
person's real name, dates, alternate names, or media. The rules:
|
||||
|
||||
- persons : redacted (living → "Living person"); hidden dropped.
|
||||
- relationships : only when BOTH endpoints are non-hidden (a link to a
|
||||
redacted person is fine — the name is already hidden).
|
||||
- events : only for FULL-visibility persons; partnership events only
|
||||
when BOTH partners are full (a marriage date would leak a
|
||||
living partner's timeline otherwise).
|
||||
- names : only for FULL-visibility persons.
|
||||
- media : NOT exposed yet (deferred — see docs/design/tree-visibility.md).
|
||||
|
||||
A tree that isn't viewable raises NotFound (never Forbidden) so the public
|
||||
surface can't be used to probe whether a private tree exists.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.enums import TreeVisibility
|
||||
from app.models.event import Event
|
||||
from app.models.person import Name, Person
|
||||
from app.models.relationship import Relationship
|
||||
from app.models.tree import Tree
|
||||
from app.services import privacy
|
||||
from app.services.exceptions import NotFound
|
||||
from app.services.person_service import _attach_primary_name, _redact
|
||||
from app.services.privacy import Visibility
|
||||
|
||||
|
||||
async def get_public_tree(
|
||||
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree_id: uuid.UUID
|
||||
) -> Tree:
|
||||
tree = (
|
||||
await session.execute(
|
||||
select(Tree).where(Tree.id == tree_id, Tree.deleted_at.is_(None))
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
# 404 (not 403) when not viewable: don't reveal that a private tree exists.
|
||||
if tree is None or not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||
raise NotFound("tree not found")
|
||||
return tree
|
||||
|
||||
|
||||
async def _persons(session: AsyncSession, tree: Tree) -> list[Person]:
|
||||
return list(
|
||||
(
|
||||
await session.execute(
|
||||
select(Person).where(Person.tree_id == tree.id, Person.deleted_at.is_(None))
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
async def _visibility_map(
|
||||
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree, persons: list[Person]
|
||||
) -> dict[uuid.UUID, Visibility]:
|
||||
return {
|
||||
p.id: await privacy.person_visibility(
|
||||
session, user_id=viewer_id, tree=tree, person=p
|
||||
)
|
||||
for p in persons
|
||||
}
|
||||
|
||||
|
||||
async def list_public_persons(
|
||||
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree
|
||||
) -> list[Person]:
|
||||
out: list[Person] = []
|
||||
for p in await _persons(session, tree):
|
||||
vis = await privacy.person_visibility(session, user_id=viewer_id, tree=tree, person=p)
|
||||
if vis == Visibility.hidden:
|
||||
continue
|
||||
if vis == Visibility.redacted:
|
||||
_redact(p)
|
||||
else:
|
||||
await _attach_primary_name(session, p)
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
async def get_public_person(
|
||||
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree, person_id: uuid.UUID
|
||||
) -> Person:
|
||||
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")
|
||||
vis = await privacy.person_visibility(session, user_id=viewer_id, tree=tree, person=person)
|
||||
if vis == Visibility.hidden:
|
||||
raise NotFound("person not found")
|
||||
if vis == Visibility.redacted:
|
||||
_redact(person)
|
||||
else:
|
||||
await _attach_primary_name(session, person)
|
||||
return person
|
||||
|
||||
|
||||
async def _person_visibility(
|
||||
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree, person_id: uuid.UUID
|
||||
) -> Visibility | None:
|
||||
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:
|
||||
return None
|
||||
return await privacy.person_visibility(session, user_id=viewer_id, tree=tree, person=person)
|
||||
|
||||
|
||||
async def list_public_relationships(
|
||||
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree
|
||||
) -> list[Relationship]:
|
||||
persons = await _persons(session, tree)
|
||||
vis = await _visibility_map(session, viewer_id=viewer_id, tree=tree, persons=persons)
|
||||
nonhidden = {pid for pid, v in vis.items() if v != Visibility.hidden}
|
||||
rels = list(
|
||||
(
|
||||
await session.execute(
|
||||
select(Relationship).where(
|
||||
Relationship.tree_id == tree.id, Relationship.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
r for r in rels if r.person_from_id in nonhidden and r.person_to_id in nonhidden
|
||||
]
|
||||
|
||||
|
||||
async def list_public_events(
|
||||
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree
|
||||
) -> list[Event]:
|
||||
persons = await _persons(session, tree)
|
||||
vis = await _visibility_map(session, viewer_id=viewer_id, tree=tree, persons=persons)
|
||||
full = {pid for pid, v in vis.items() if v == Visibility.full}
|
||||
rels = {
|
||||
r.id: r
|
||||
for r in (
|
||||
await session.execute(
|
||||
select(Relationship).where(
|
||||
Relationship.tree_id == tree.id, Relationship.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
}
|
||||
events = list(
|
||||
(
|
||||
await session.execute(
|
||||
select(Event).where(Event.tree_id == tree.id, Event.deleted_at.is_(None))
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
out: list[Event] = []
|
||||
for e in events:
|
||||
if e.person_id is not None:
|
||||
if e.person_id in full:
|
||||
out.append(e)
|
||||
elif e.relationship_id is not None:
|
||||
r = rels.get(e.relationship_id)
|
||||
if r is not None and r.person_from_id in full and r.person_to_id in full:
|
||||
out.append(e)
|
||||
return out
|
||||
|
||||
|
||||
async def list_public_person_names(
|
||||
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree, person_id: uuid.UUID
|
||||
) -> list[Name]:
|
||||
vis = await _person_visibility(session, viewer_id=viewer_id, tree=tree, person_id=person_id)
|
||||
if vis is None:
|
||||
raise NotFound("person not found")
|
||||
if vis != Visibility.full:
|
||||
return [] # redacted/hidden → no names (the real name must not leak)
|
||||
return list(
|
||||
(
|
||||
await session.execute(
|
||||
select(Name)
|
||||
.where(Name.person_id == person_id, Name.deleted_at.is_(None))
|
||||
.order_by(Name.is_primary.desc(), Name.sort_order, Name.created_at)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
async def list_public_person_events(
|
||||
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree, person_id: uuid.UUID
|
||||
) -> list[Event]:
|
||||
vis = await _person_visibility(session, viewer_id=viewer_id, tree=tree, person_id=person_id)
|
||||
if vis is None:
|
||||
raise NotFound("person not found")
|
||||
if vis != Visibility.full:
|
||||
return [] # redacted/hidden → no dates
|
||||
return list(
|
||||
(
|
||||
await session.execute(
|
||||
select(Event)
|
||||
.where(
|
||||
Event.person_id == person_id,
|
||||
Event.tree_id == tree.id,
|
||||
Event.deleted_at.is_(None),
|
||||
)
|
||||
.order_by(Event.date_start.nulls_last(), Event.created_at)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
async def list_public_trees(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
viewer_id: uuid.UUID | None,
|
||||
q: str | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[Tree]:
|
||||
# Anonymous: only `public`. Authenticated: also `site_members`. Never list
|
||||
# `unlisted` (reachable by link only) or `private`.
|
||||
allowed = [TreeVisibility.public]
|
||||
if viewer_id is not None:
|
||||
allowed.append(TreeVisibility.site_members)
|
||||
stmt = select(Tree).where(
|
||||
Tree.deleted_at.is_(None), Tree.visibility.in_(allowed)
|
||||
)
|
||||
if q and q.strip():
|
||||
stmt = stmt.where(Tree.name.ilike(f"%{q.strip()}%"))
|
||||
stmt = stmt.order_by(Tree.name).limit(min(limit, 100)).offset(max(offset, 0))
|
||||
return list((await session.execute(stmt)).scalars().all())
|
||||
@@ -0,0 +1,180 @@
|
||||
"""The public viewing surface (/api/v1/public).
|
||||
|
||||
The central guarantee: an ANONYMOUS viewer of a public tree never receives a
|
||||
possibly-living person's real name, dates, or alternate names — while deceased
|
||||
people are shown in full. Plus the access matrix for each visibility level.
|
||||
See docs/design/tree-visibility.md.
|
||||
"""
|
||||
|
||||
from tests.conftest import auth, register
|
||||
|
||||
# Distinctive strings so we can assert they never leak anywhere anonymously.
|
||||
LIVING_GIVEN = "Younglivingsecret"
|
||||
LIVING_SURNAME = "Hiddensurname"
|
||||
LIVING_ALIAS = "Secretmaidenalias"
|
||||
LIVING_BIRTH_YEAR = "2002"
|
||||
|
||||
|
||||
async def _person(client, tid, headers, given, surname, is_living):
|
||||
r = await client.post(
|
||||
f"/api/v1/trees/{tid}/persons",
|
||||
json={"given": given, "surname": surname, "is_living": is_living},
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()["id"]
|
||||
|
||||
|
||||
async def _build_public_tree(client):
|
||||
owner = auth(await register(client, "pv-owner@ex.com"))
|
||||
tid = (
|
||||
await client.post(
|
||||
"/api/v1/trees", json={"name": "Heritage", "visibility": "public"}, headers=owner
|
||||
)
|
||||
).json()["id"]
|
||||
|
||||
old = await _person(client, tid, owner, "Olda", "Ancestor", False)
|
||||
young = await _person(client, tid, owner, LIVING_GIVEN, LIVING_SURNAME, True)
|
||||
|
||||
# Birth events for each.
|
||||
await client.post(
|
||||
f"/api/v1/trees/{tid}/events",
|
||||
json={"event_type": "birth", "person_id": old, "date_value": "1850"},
|
||||
headers=owner,
|
||||
)
|
||||
await client.post(
|
||||
f"/api/v1/trees/{tid}/events",
|
||||
json={"event_type": "birth", "person_id": young, "date_value": LIVING_BIRTH_YEAR},
|
||||
headers=owner,
|
||||
)
|
||||
# Alternate names for each.
|
||||
await client.post(
|
||||
f"/api/v1/trees/{tid}/persons/{old}/names",
|
||||
json={"name_type": "alias", "given": "Oldnickname"},
|
||||
headers=owner,
|
||||
)
|
||||
await client.post(
|
||||
f"/api/v1/trees/{tid}/persons/{young}/names",
|
||||
json={"name_type": "alias", "given": LIVING_ALIAS},
|
||||
headers=owner,
|
||||
)
|
||||
# old --parent--> young
|
||||
await client.post(
|
||||
f"/api/v1/trees/{tid}/relationships",
|
||||
json={
|
||||
"type": "parent_child",
|
||||
"person_from_id": old,
|
||||
"person_to_id": young,
|
||||
"qualifier": "biological",
|
||||
},
|
||||
headers=owner,
|
||||
)
|
||||
return tid, old, young
|
||||
|
||||
|
||||
async def test_anonymous_public_view_never_leaks_living_pii(client):
|
||||
tid, old, young = await _build_public_tree(client)
|
||||
|
||||
# --- persons: deceased full, living redacted ---
|
||||
persons = (await client.get(f"/api/v1/public/trees/{tid}/persons")).json()
|
||||
by_id = {p["id"]: p for p in persons}
|
||||
assert by_id[old]["primary_name"] == "Olda Ancestor"
|
||||
assert by_id[young]["primary_name"] == "Living person"
|
||||
assert by_id[young]["gender"] is None
|
||||
|
||||
# --- the living person's real name/alias/birth year must appear NOWHERE ---
|
||||
for path in (
|
||||
f"/api/v1/public/trees/{tid}/persons",
|
||||
f"/api/v1/public/trees/{tid}/events",
|
||||
f"/api/v1/public/trees/{tid}/relationships",
|
||||
f"/api/v1/public/trees/{tid}/persons/{young}",
|
||||
f"/api/v1/public/trees/{tid}/persons/{young}/names",
|
||||
f"/api/v1/public/trees/{tid}/persons/{young}/events",
|
||||
):
|
||||
body = (await client.get(path)).text
|
||||
assert LIVING_GIVEN not in body, path
|
||||
assert LIVING_SURNAME not in body, path
|
||||
assert LIVING_ALIAS not in body, path
|
||||
assert LIVING_BIRTH_YEAR not in body, path
|
||||
|
||||
# --- events: deceased's date present, living's dropped entirely ---
|
||||
events = (await client.get(f"/api/v1/public/trees/{tid}/events")).json()
|
||||
assert any(e["person_id"] == old for e in events)
|
||||
assert not any(e["person_id"] == young for e in events)
|
||||
|
||||
# --- per-person endpoints for the living person are emptied/redacted ---
|
||||
assert (await client.get(f"/api/v1/public/trees/{tid}/persons/{young}/names")).json() == []
|
||||
assert (await client.get(f"/api/v1/public/trees/{tid}/persons/{young}/events")).json() == []
|
||||
assert (
|
||||
await client.get(f"/api/v1/public/trees/{tid}/persons/{young}")
|
||||
).json()["primary_name"] == "Living person"
|
||||
|
||||
# --- deceased person's names/events ARE exposed ---
|
||||
old_names = (await client.get(f"/api/v1/public/trees/{tid}/persons/{old}/names")).json()
|
||||
assert any(n.get("given") == "Oldnickname" for n in old_names)
|
||||
old_events = (await client.get(f"/api/v1/public/trees/{tid}/persons/{old}/events")).json()
|
||||
assert any(e["date_value"] == "1850" for e in old_events)
|
||||
|
||||
# --- relationship kept (links to the redacted person by id, no PII) ---
|
||||
rels = (await client.get(f"/api/v1/public/trees/{tid}/relationships")).json()
|
||||
assert any(r2["person_from_id"] == old and r2["person_to_id"] == young for r2 in rels)
|
||||
|
||||
|
||||
async def test_private_tree_is_404_anonymously(client):
|
||||
owner = auth(await register(client, "priv-owner@ex.com"))
|
||||
tid = (
|
||||
await client.post(
|
||||
"/api/v1/trees", json={"name": "Secret", "visibility": "private"}, headers=owner
|
||||
)
|
||||
).json()["id"]
|
||||
assert (await client.get(f"/api/v1/public/trees/{tid}")).status_code == 404
|
||||
assert (await client.get(f"/api/v1/public/trees/{tid}/persons")).status_code == 404
|
||||
|
||||
|
||||
async def test_unlisted_viewable_by_link_but_not_in_directory(client):
|
||||
owner = auth(await register(client, "unl-owner@ex.com"))
|
||||
tid = (
|
||||
await client.post(
|
||||
"/api/v1/trees", json={"name": "ByLinkOnly", "visibility": "unlisted"}, headers=owner
|
||||
)
|
||||
).json()["id"]
|
||||
# Direct link works anonymously...
|
||||
assert (await client.get(f"/api/v1/public/trees/{tid}")).status_code == 200
|
||||
# ...but it is never listed in the directory.
|
||||
directory = (await client.get("/api/v1/public/trees")).json()
|
||||
assert all(t["id"] != tid for t in directory)
|
||||
|
||||
|
||||
async def test_site_members_requires_login(client):
|
||||
owner = auth(await register(client, "sm2-owner@ex.com"))
|
||||
stranger = auth(await register(client, "sm2-stranger@ex.com"))
|
||||
tid = (
|
||||
await client.post(
|
||||
"/api/v1/trees", json={"name": "MembersOnly", "visibility": "site_members"}, headers=owner
|
||||
)
|
||||
).json()["id"]
|
||||
assert (await client.get(f"/api/v1/public/trees/{tid}")).status_code == 404 # anonymous
|
||||
assert (await client.get(f"/api/v1/public/trees/{tid}", headers=stranger)).status_code == 200
|
||||
|
||||
|
||||
async def test_directory_visibility(client):
|
||||
owner = auth(await register(client, "dir-owner@ex.com"))
|
||||
stranger = auth(await register(client, "dir-stranger@ex.com"))
|
||||
ids = {}
|
||||
for vis in ("public", "site_members", "unlisted", "private"):
|
||||
ids[vis] = (
|
||||
await client.post(
|
||||
"/api/v1/trees", json={"name": f"dir-{vis}", "visibility": vis}, headers=owner
|
||||
)
|
||||
).json()["id"]
|
||||
|
||||
anon = {t["id"] for t in (await client.get("/api/v1/public/trees")).json()}
|
||||
assert ids["public"] in anon
|
||||
for vis in ("site_members", "unlisted", "private"):
|
||||
assert ids[vis] not in anon
|
||||
|
||||
logged_in = {t["id"] for t in (await client.get("/api/v1/public/trees", headers=stranger)).json()}
|
||||
assert ids["public"] in logged_in
|
||||
assert ids["site_members"] in logged_in
|
||||
assert ids["unlisted"] not in logged_in
|
||||
assert ids["private"] not in logged_in
|
||||
Vendored
+408
@@ -769,6 +769,142 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/public/trees": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Public Directory */
|
||||
get: operations["public_directory_api_v1_public_trees_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/public/trees/{tree_id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Public Tree */
|
||||
get: operations["public_tree_api_v1_public_trees__tree_id__get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/public/trees/{tree_id}/persons": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Public Persons */
|
||||
get: operations["public_persons_api_v1_public_trees__tree_id__persons_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/public/trees/{tree_id}/relationships": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Public Relationships */
|
||||
get: operations["public_relationships_api_v1_public_trees__tree_id__relationships_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/public/trees/{tree_id}/events": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Public Events */
|
||||
get: operations["public_events_api_v1_public_trees__tree_id__events_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/public/trees/{tree_id}/persons/{person_id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Public Person */
|
||||
get: operations["public_person_api_v1_public_trees__tree_id__persons__person_id__get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/public/trees/{tree_id}/persons/{person_id}/names": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Public Person Names */
|
||||
get: operations["public_person_names_api_v1_public_trees__tree_id__persons__person_id__names_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/public/trees/{tree_id}/persons/{person_id}/events": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Public Person Events */
|
||||
get: operations["public_person_events_api_v1_public_trees__tree_id__persons__person_id__events_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
}
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
@@ -1329,6 +1465,25 @@ export interface components {
|
||||
/** Notes */
|
||||
notes?: string | null;
|
||||
};
|
||||
/**
|
||||
* PublicTreeRead
|
||||
* @description Tree projection for the public surface — deliberately omits owner_id so a
|
||||
* public/unlisted tree doesn't reveal which account owns it.
|
||||
*/
|
||||
PublicTreeRead: {
|
||||
/**
|
||||
* Id
|
||||
* Format: uuid
|
||||
*/
|
||||
id: string;
|
||||
/** Name */
|
||||
name: string;
|
||||
/** Description */
|
||||
description: string | null;
|
||||
visibility: components["schemas"]["TreeVisibility"];
|
||||
/** Home Person Id */
|
||||
home_person_id?: string | null;
|
||||
};
|
||||
/** RegisterRequest */
|
||||
RegisterRequest: {
|
||||
/** Email */
|
||||
@@ -3633,4 +3788,257 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
public_directory_api_v1_public_trees_get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
q?: string | null;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PublicTreeRead"][];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
public_tree_api_v1_public_trees__tree_id__get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
tree_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PublicTreeRead"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
public_persons_api_v1_public_trees__tree_id__persons_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
tree_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PersonRead"][];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
public_relationships_api_v1_public_trees__tree_id__relationships_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
tree_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["RelationshipRead"][];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
public_events_api_v1_public_trees__tree_id__events_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
tree_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["EventRead"][];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
public_person_api_v1_public_trees__tree_id__persons__person_id__get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
tree_id: string;
|
||||
person_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PersonRead"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
public_person_names_api_v1_public_trees__tree_id__persons__person_id__names_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
tree_id: string;
|
||||
person_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["NameRead"][];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
public_person_events_api_v1_public_trees__tree_id__persons__person_id__events_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
tree_id: string;
|
||||
person_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["EventRead"][];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3065,6 +3065,430 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/public/trees": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"public"
|
||||
],
|
||||
"summary": "Public Directory",
|
||||
"operationId": "public_directory_api_v1_public_trees_get",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "q",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Q"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"default": 50,
|
||||
"title": "Limit"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "offset",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"default": 0,
|
||||
"title": "Offset"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PublicTreeRead"
|
||||
},
|
||||
"title": "Response Public Directory Api V1 Public Trees Get"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/public/trees/{tree_id}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"public"
|
||||
],
|
||||
"summary": "Public Tree",
|
||||
"operationId": "public_tree_api_v1_public_trees__tree_id__get",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "tree_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"title": "Tree Id"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PublicTreeRead"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/public/trees/{tree_id}/persons": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"public"
|
||||
],
|
||||
"summary": "Public Persons",
|
||||
"operationId": "public_persons_api_v1_public_trees__tree_id__persons_get",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "tree_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"title": "Tree Id"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PersonRead"
|
||||
},
|
||||
"title": "Response Public Persons Api V1 Public Trees Tree Id Persons Get"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/public/trees/{tree_id}/relationships": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"public"
|
||||
],
|
||||
"summary": "Public Relationships",
|
||||
"operationId": "public_relationships_api_v1_public_trees__tree_id__relationships_get",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "tree_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"title": "Tree Id"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/RelationshipRead"
|
||||
},
|
||||
"title": "Response Public Relationships Api V1 Public Trees Tree Id Relationships Get"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/public/trees/{tree_id}/events": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"public"
|
||||
],
|
||||
"summary": "Public Events",
|
||||
"operationId": "public_events_api_v1_public_trees__tree_id__events_get",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "tree_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"title": "Tree Id"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/EventRead"
|
||||
},
|
||||
"title": "Response Public Events Api V1 Public Trees Tree Id Events Get"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/public/trees/{tree_id}/persons/{person_id}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"public"
|
||||
],
|
||||
"summary": "Public Person",
|
||||
"operationId": "public_person_api_v1_public_trees__tree_id__persons__person_id__get",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "tree_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"title": "Tree Id"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "person_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"title": "Person Id"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PersonRead"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/public/trees/{tree_id}/persons/{person_id}/names": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"public"
|
||||
],
|
||||
"summary": "Public Person Names",
|
||||
"operationId": "public_person_names_api_v1_public_trees__tree_id__persons__person_id__names_get",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "tree_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"title": "Tree Id"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "person_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"title": "Person Id"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/NameRead"
|
||||
},
|
||||
"title": "Response Public Person Names Api V1 Public Trees Tree Id Persons Person Id Names Get"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/public/trees/{tree_id}/persons/{person_id}/events": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"public"
|
||||
],
|
||||
"summary": "Public Person Events",
|
||||
"operationId": "public_person_events_api_v1_public_trees__tree_id__persons__person_id__events_get",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "tree_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"title": "Tree Id"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "person_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"title": "Person Id"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/EventRead"
|
||||
},
|
||||
"title": "Response Public Person Events Api V1 Public Trees Tree Id Persons Person Id Events Get"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
@@ -4900,6 +5324,54 @@
|
||||
"type": "object",
|
||||
"title": "PersonUpdate"
|
||||
},
|
||||
"PublicTreeRead": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"title": "Id"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"title": "Name"
|
||||
},
|
||||
"description": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Description"
|
||||
},
|
||||
"visibility": {
|
||||
"$ref": "#/components/schemas/TreeVisibility"
|
||||
},
|
||||
"home_person_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Home Person Id"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"description",
|
||||
"visibility"
|
||||
],
|
||||
"title": "PublicTreeRead",
|
||||
"description": "Tree projection for the public surface \u2014 deliberately omits owner_id so a\npublic/unlisted tree doesn't reveal which account owns it."
|
||||
},
|
||||
"RegisterRequest": {
|
||||
"properties": {
|
||||
"email": {
|
||||
|
||||
Reference in New Issue
Block a user