Close citation/source living-person leak; add on-demand tree purge #245
@@ -2,8 +2,8 @@ import uuid
|
|||||||
|
|
||||||
from fastapi import APIRouter, status
|
from fastapi import APIRouter, status
|
||||||
|
|
||||||
from app.api.deps import CurrentUser, SessionDep
|
from app.api.deps import CurrentUser, ObjectStoreDep, SessionDep
|
||||||
from app.schemas.tree import TreeCreate, TreeRead, TreeUpdate
|
from app.schemas.tree import TreeCreate, TreePurge, TreeRead, TreeUpdate
|
||||||
from app.services import tree_service
|
from app.services import tree_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/trees", tags=["trees"])
|
router = APIRouter(prefix="/trees", tags=["trees"])
|
||||||
@@ -57,3 +57,18 @@ async def delete_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentU
|
|||||||
async def restore_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> TreeRead:
|
async def restore_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> TreeRead:
|
||||||
tree = await tree_service.restore_tree(session, actor=current, tree_id=tree_id)
|
tree = await tree_service.restore_tree(session, actor=current, tree_id=tree_id)
|
||||||
return TreeRead.model_validate(tree)
|
return TreeRead.model_validate(tree)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{tree_id}/purge", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def purge_tree(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
data: TreePurge,
|
||||||
|
session: SessionDep,
|
||||||
|
current: CurrentUser,
|
||||||
|
store: ObjectStoreDep,
|
||||||
|
) -> None:
|
||||||
|
"""Permanently delete a soft-deleted tree and all its data — irreversible.
|
||||||
|
Owner-only; the tree must be in the trash and `confirm_name` must match."""
|
||||||
|
await tree_service.purge_tree(
|
||||||
|
session, store, actor=current, tree_id=tree_id, confirm_name=data.confirm_name
|
||||||
|
)
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ class TreeUpdate(BaseModel):
|
|||||||
home_person_id: uuid.UUID | None = None
|
home_person_id: uuid.UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TreePurge(BaseModel):
|
||||||
|
# Retype the tree's name to confirm a permanent, irreversible delete.
|
||||||
|
confirm_name: str
|
||||||
|
|
||||||
|
|
||||||
class TreeRead(BaseModel):
|
class TreeRead(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|||||||
@@ -105,6 +105,15 @@ async def list_citations(
|
|||||||
indicators in a single round-trip."""
|
indicators in a single round-trip."""
|
||||||
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
raise Forbidden("not permitted to view this tree")
|
raise Forbidden("not permitted to view this tree")
|
||||||
|
# Non-members get only citations whose cited fact resolves to a full-
|
||||||
|
# visibility person — a citation on a redacted living person's fact would
|
||||||
|
# otherwise leak that the person has that sourced fact.
|
||||||
|
if await privacy.get_membership_role(session, viewer_id, tree.id) is None:
|
||||||
|
from app.services import public_view_service
|
||||||
|
|
||||||
|
return await public_view_service.list_public_citations(
|
||||||
|
session, viewer_id=viewer_id, tree=tree
|
||||||
|
)
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Citation)
|
select(Citation)
|
||||||
.where(Citation.tree_id == tree.id, Citation.deleted_at.is_(None))
|
.where(Citation.tree_id == tree.id, Citation.deleted_at.is_(None))
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ person's real name, dates, alternate names, or media. The rules:
|
|||||||
living partner's timeline otherwise).
|
living partner's timeline otherwise).
|
||||||
- names : only for FULL-visibility persons.
|
- names : only for FULL-visibility persons.
|
||||||
- media : NOT exposed yet (deferred — see docs/design/tree-visibility.md).
|
- media : NOT exposed yet (deferred — see docs/design/tree-visibility.md).
|
||||||
|
- citations : only when the cited fact resolves to FULL person(s).
|
||||||
|
- sources : only when they back at least one visible citation.
|
||||||
|
|
||||||
A tree that isn't viewable raises NotFound (never Forbidden) so the public
|
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.
|
surface can't be used to probe whether a private tree exists.
|
||||||
@@ -27,6 +29,7 @@ from app.models.event import Event
|
|||||||
from app.models.media import Media
|
from app.models.media import Media
|
||||||
from app.models.person import Name, Person
|
from app.models.person import Name, Person
|
||||||
from app.models.relationship import Relationship
|
from app.models.relationship import Relationship
|
||||||
|
from app.models.source import Citation, Source
|
||||||
from app.models.tree import Tree
|
from app.models.tree import Tree
|
||||||
from app.services import privacy
|
from app.services import privacy
|
||||||
from app.services.exceptions import NotFound
|
from app.services.exceptions import NotFound
|
||||||
@@ -296,6 +299,95 @@ async def can_view_media(
|
|||||||
return vis == Visibility.full
|
return vis == Visibility.full
|
||||||
|
|
||||||
|
|
||||||
|
async def _full_person_ids(
|
||||||
|
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree
|
||||||
|
) -> set[uuid.UUID]:
|
||||||
|
persons = await _persons(session, tree)
|
||||||
|
vis = await _visibility_map(session, viewer_id=viewer_id, tree=tree, persons=persons)
|
||||||
|
return {pid for pid, v in vis.items() if v == Visibility.full}
|
||||||
|
|
||||||
|
|
||||||
|
async def list_public_citations(
|
||||||
|
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree
|
||||||
|
) -> list[Citation]:
|
||||||
|
"""Only citations whose cited fact resolves to FULL-visibility person(s). A
|
||||||
|
citation on a redacted/hidden person's fact (or a partnership where either
|
||||||
|
partner isn't full) is dropped — its existence plus page/detail would leak
|
||||||
|
that the person has that sourced fact. Mirrors the events/names rule (FULL
|
||||||
|
only)."""
|
||||||
|
full = await _full_person_ids(session, viewer_id=viewer_id, tree=tree)
|
||||||
|
|
||||||
|
async def _by_id(model):
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
select(model).where(model.tree_id == tree.id, model.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
return {r.id: r for r in rows}
|
||||||
|
|
||||||
|
names = await _by_id(Name)
|
||||||
|
rels = await _by_id(Relationship)
|
||||||
|
events = await _by_id(Event)
|
||||||
|
|
||||||
|
def target_is_full(c: Citation) -> bool:
|
||||||
|
if c.person_id is not None:
|
||||||
|
return c.person_id in full
|
||||||
|
if c.name_id is not None:
|
||||||
|
n = names.get(c.name_id)
|
||||||
|
return n is not None and n.person_id in full
|
||||||
|
if c.event_id is not None:
|
||||||
|
e = events.get(c.event_id)
|
||||||
|
if e is None:
|
||||||
|
return False
|
||||||
|
if e.person_id is not None:
|
||||||
|
return e.person_id in full
|
||||||
|
if e.relationship_id is not None:
|
||||||
|
r = rels.get(e.relationship_id)
|
||||||
|
return r is not None and r.person_from_id in full and r.person_to_id in full
|
||||||
|
return False
|
||||||
|
if c.relationship_id is not None:
|
||||||
|
r = rels.get(c.relationship_id)
|
||||||
|
return r is not None and r.person_from_id in full and r.person_to_id in full
|
||||||
|
return False
|
||||||
|
|
||||||
|
citations = (
|
||||||
|
await session.execute(
|
||||||
|
select(Citation)
|
||||||
|
.where(Citation.tree_id == tree.id, Citation.deleted_at.is_(None))
|
||||||
|
.order_by(Citation.created_at)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
return [c for c in citations if target_is_full(c)]
|
||||||
|
|
||||||
|
|
||||||
|
async def list_public_sources(
|
||||||
|
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree
|
||||||
|
) -> list[Source]:
|
||||||
|
"""Only sources backing at least one visible citation. A source used solely
|
||||||
|
for a redacted/hidden person's facts is withheld — its title or notes could
|
||||||
|
name that living person."""
|
||||||
|
visible = await list_public_citations(session, viewer_id=viewer_id, tree=tree)
|
||||||
|
cited = {c.source_id for c in visible}
|
||||||
|
sources = (
|
||||||
|
await session.execute(
|
||||||
|
select(Source)
|
||||||
|
.where(Source.tree_id == tree.id, Source.deleted_at.is_(None))
|
||||||
|
.order_by(Source.title)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
return [s for s in sources if s.id in cited]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_public_source(
|
||||||
|
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree, source_id: uuid.UUID
|
||||||
|
) -> Source:
|
||||||
|
for s in await list_public_sources(session, viewer_id=viewer_id, tree=tree):
|
||||||
|
if s.id == source_id:
|
||||||
|
return s
|
||||||
|
# 404 (not 403): don't reveal that a withheld source exists.
|
||||||
|
raise NotFound("source not found")
|
||||||
|
|
||||||
|
|
||||||
async def list_public_trees(
|
async def list_public_trees(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -61,6 +61,14 @@ async def create_source(
|
|||||||
async def list_sources(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree) -> list[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):
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
raise Forbidden("not permitted to view this tree")
|
raise Forbidden("not permitted to view this tree")
|
||||||
|
# Non-members see only sources backing a visible citation (see citation
|
||||||
|
# redaction) — a source used solely for a redacted person could name them.
|
||||||
|
if await privacy.get_membership_role(session, viewer_id, tree.id) is None:
|
||||||
|
from app.services import public_view_service
|
||||||
|
|
||||||
|
return await public_view_service.list_public_sources(
|
||||||
|
session, viewer_id=viewer_id, tree=tree
|
||||||
|
)
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Source)
|
select(Source)
|
||||||
.where(Source.tree_id == tree.id, Source.deleted_at.is_(None))
|
.where(Source.tree_id == tree.id, Source.deleted_at.is_(None))
|
||||||
@@ -74,6 +82,12 @@ async def get_source(
|
|||||||
) -> Source:
|
) -> Source:
|
||||||
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
raise Forbidden("not permitted to view this tree")
|
raise Forbidden("not permitted to view this tree")
|
||||||
|
if await privacy.get_membership_role(session, viewer_id, tree.id) is None:
|
||||||
|
from app.services import public_view_service
|
||||||
|
|
||||||
|
return await public_view_service.get_public_source(
|
||||||
|
session, viewer_id=viewer_id, tree=tree, source_id=source_id
|
||||||
|
)
|
||||||
source = (
|
source = (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
select(Source).where(
|
select(Source).where(
|
||||||
|
|||||||
@@ -5,16 +5,18 @@ authorization basis) and an audit entry. Reads go through the privacy engine.
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import delete, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.integrations.objectstore.base import ObjectStore
|
||||||
from app.models.enums import MembershipRole, TreeVisibility
|
from app.models.enums import MembershipRole, TreeVisibility
|
||||||
|
from app.models.media import Media
|
||||||
from app.models.tree import Tree, TreeMembership
|
from app.models.tree import Tree, TreeMembership
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.repositories.base import BaseRepository
|
from app.repositories.base import BaseRepository
|
||||||
from app.services import privacy
|
from app.services import privacy
|
||||||
from app.services.audit import record_audit
|
from app.services.audit import record_audit
|
||||||
from app.services.exceptions import Forbidden, NotFound
|
from app.services.exceptions import Conflict, Forbidden, NotFound
|
||||||
|
|
||||||
|
|
||||||
async def create_tree(
|
async def create_tree(
|
||||||
@@ -128,6 +130,50 @@ async def restore_tree(session: AsyncSession, *, actor: User, tree_id: uuid.UUID
|
|||||||
return tree
|
return tree
|
||||||
|
|
||||||
|
|
||||||
|
async def purge_tree(
|
||||||
|
session: AsyncSession,
|
||||||
|
store: ObjectStore,
|
||||||
|
*,
|
||||||
|
actor: User,
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
confirm_name: str,
|
||||||
|
) -> None:
|
||||||
|
"""Permanently delete a soft-deleted tree and ALL its data — irreversible.
|
||||||
|
Owner-only. The tree must already be in the trash (soft-deleted) and the
|
||||||
|
caller must retype its name. Tree-owned rows are removed by the `tree_id`
|
||||||
|
ON DELETE CASCADE; we delete the media objects from storage first (the DB
|
||||||
|
cascade drops the rows but not the bytes). Audit entries survive with their
|
||||||
|
`tree_id` nulled (ON DELETE SET NULL), so the purge stays in the log."""
|
||||||
|
tree = await _owned_tree(session, actor=actor, tree_id=tree_id)
|
||||||
|
if tree.deleted_at is None:
|
||||||
|
raise Conflict("delete the tree first, then purge it from the trash")
|
||||||
|
if confirm_name.strip() != (tree.name or "").strip():
|
||||||
|
raise Forbidden("tree name confirmation does not match")
|
||||||
|
|
||||||
|
keys = list(
|
||||||
|
(
|
||||||
|
await session.execute(select(Media.storage_key).where(Media.tree_id == tree.id))
|
||||||
|
).scalars().all()
|
||||||
|
)
|
||||||
|
for key in keys:
|
||||||
|
try:
|
||||||
|
await store.delete_object(key=key)
|
||||||
|
except Exception: # noqa: BLE001 — best-effort; a missing object must not block the purge
|
||||||
|
pass
|
||||||
|
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="purge",
|
||||||
|
entity_type="Tree",
|
||||||
|
entity_id=tree.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
before={"name": tree.name},
|
||||||
|
)
|
||||||
|
await session.execute(delete(Tree).where(Tree.id == tree.id))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
async def list_deleted_trees_for_user(session: AsyncSession, *, user: User) -> list[Tree]:
|
async def list_deleted_trees_for_user(session: AsyncSession, *, user: User) -> list[Tree]:
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Tree)
|
select(Tree)
|
||||||
|
|||||||
@@ -106,6 +106,142 @@ async def test_authed_nonmember_does_not_see_living_pii(client):
|
|||||||
).status_code == 200
|
).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
async def _setup_sources(client):
|
||||||
|
owner = auth(await register(client, "anmcs-owner@ex.com"))
|
||||||
|
tid = (
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/trees", json={"name": "PubCS", "visibility": "public"}, headers=owner
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
old = (
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/persons",
|
||||||
|
json={"given": "Oldcs", "surname": "Gonecs", "is_living": False},
|
||||||
|
headers=owner,
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
young = (
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/persons",
|
||||||
|
json={"given": "Youngcs", "surname": "Csleaksurname", "is_living": True},
|
||||||
|
headers=owner,
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
for pid, year in ((old, "1851"), (young, "2004")):
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/events",
|
||||||
|
json={"event_type": "birth", "person_id": pid, "date_value": year},
|
||||||
|
headers=owner,
|
||||||
|
)
|
||||||
|
s_old = (
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/sources", json={"title": "Oldsource record"}, headers=owner
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
s_young = (
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/sources",
|
||||||
|
json={"title": "Youngsource Csleaktitle"}, # title names the living person
|
||||||
|
headers=owner,
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/citations",
|
||||||
|
json={"source_id": s_old, "person_id": old, "page": "p.1"},
|
||||||
|
headers=owner,
|
||||||
|
)
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/citations",
|
||||||
|
json={"source_id": s_young, "person_id": young, "page": "p.2"},
|
||||||
|
headers=owner,
|
||||||
|
)
|
||||||
|
return owner, tid, old, young, s_old, s_young
|
||||||
|
|
||||||
|
|
||||||
|
async def test_authed_nonmember_citation_source_redaction(client):
|
||||||
|
"""A non-member must not see citations on a redacted living person's facts,
|
||||||
|
nor sources used only for them."""
|
||||||
|
owner, tid, old, young, s_old, s_young = await _setup_sources(client)
|
||||||
|
stranger = auth(await register(client, "anmcs-stranger@ex.com"))
|
||||||
|
|
||||||
|
cites = (await client.get(f"/api/v1/trees/{tid}/citations", headers=stranger)).json()
|
||||||
|
cited = {c.get("person_id") for c in cites}
|
||||||
|
assert old in cited
|
||||||
|
assert young not in cited # living person's citation dropped
|
||||||
|
|
||||||
|
srcs = (await client.get(f"/api/v1/trees/{tid}/sources", headers=stranger))
|
||||||
|
src_ids = {s["id"] for s in srcs.json()}
|
||||||
|
assert s_old in src_ids
|
||||||
|
assert s_young not in src_ids # source used only for the living person withheld
|
||||||
|
assert "Csleaktitle" not in srcs.text # its title (which names them) must not leak
|
||||||
|
|
||||||
|
# The withheld source 404s — don't reveal it exists; the visible one is fine.
|
||||||
|
assert (
|
||||||
|
await client.get(f"/api/v1/trees/{tid}/sources/{s_young}", headers=stranger)
|
||||||
|
).status_code == 404
|
||||||
|
assert (
|
||||||
|
await client.get(f"/api/v1/trees/{tid}/sources/{s_old}", headers=stranger)
|
||||||
|
).status_code == 200
|
||||||
|
|
||||||
|
# Members still see everything.
|
||||||
|
mc = {c.get("person_id") for c in (await client.get(f"/api/v1/trees/{tid}/citations", headers=owner)).json()}
|
||||||
|
assert {old, young} <= mc
|
||||||
|
ms = {s["id"] for s in (await client.get(f"/api/v1/trees/{tid}/sources", headers=owner)).json()}
|
||||||
|
assert {s_old, s_young} <= ms
|
||||||
|
|
||||||
|
|
||||||
|
async def test_citation_redaction_via_indirect_targets(client):
|
||||||
|
"""Citations targeting a living person *indirectly* (via their event or name,
|
||||||
|
not person_id) must also be dropped for non-members."""
|
||||||
|
owner = auth(await register(client, "anmind-owner@ex.com"))
|
||||||
|
tid = (
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/trees", json={"name": "PubInd", "visibility": "public"}, headers=owner
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
young = (
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/persons",
|
||||||
|
json={"given": "Youngind", "surname": "Indsurname", "is_living": True},
|
||||||
|
headers=owner,
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
ev = (
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/events",
|
||||||
|
json={"event_type": "birth", "person_id": young, "date_value": "2005"},
|
||||||
|
headers=owner,
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
nm = (
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/persons/{young}/names",
|
||||||
|
json={"name_type": "alias", "given": "Indalias"},
|
||||||
|
headers=owner,
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
s_ev = (await client.post(f"/api/v1/trees/{tid}/sources", json={"title": "EvSrc"}, headers=owner)).json()["id"]
|
||||||
|
s_nm = (await client.post(f"/api/v1/trees/{tid}/sources", json={"title": "NmSrc"}, headers=owner)).json()["id"]
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/citations", json={"source_id": s_ev, "event_id": ev}, headers=owner
|
||||||
|
)
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/citations", json={"source_id": s_nm, "name_id": nm}, headers=owner
|
||||||
|
)
|
||||||
|
|
||||||
|
stranger = auth(await register(client, "anmind-stranger@ex.com"))
|
||||||
|
cites = (await client.get(f"/api/v1/trees/{tid}/citations", headers=stranger)).json()
|
||||||
|
# Neither the event-citation nor the name-citation may surface.
|
||||||
|
assert not any(c.get("event_id") == ev for c in cites)
|
||||||
|
assert not any(c.get("name_id") == nm for c in cites)
|
||||||
|
src_ids = {s["id"] for s in (await client.get(f"/api/v1/trees/{tid}/sources", headers=stranger)).json()}
|
||||||
|
assert s_ev not in src_ids and s_nm not in src_ids
|
||||||
|
|
||||||
|
# Owner (member) sees both citations and both sources.
|
||||||
|
mc = (await client.get(f"/api/v1/trees/{tid}/citations", headers=owner)).json()
|
||||||
|
assert any(c.get("event_id") == ev for c in mc) and any(c.get("name_id") == nm for c in mc)
|
||||||
|
|
||||||
|
|
||||||
async def test_member_still_sees_everything(client):
|
async def test_member_still_sees_everything(client):
|
||||||
owner, tid, old, young, om, ym = await _setup(client)
|
owner, tid, old, young, om, ym = await _setup(client)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""On-demand purge of a soft-deleted tree: permanent, owner-only, name-confirmed,
|
||||||
|
and cascades to all tree data."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from app.models.person import Person
|
||||||
|
from app.models.tree import Tree
|
||||||
|
from tests.conftest import auth, register
|
||||||
|
|
||||||
|
|
||||||
|
async def _tree_with_person(client, owner):
|
||||||
|
tid = (await client.post("/api/v1/trees", json={"name": "Purge Me"}, headers=owner)).json()["id"]
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/persons", json={"given": "Doomed", "surname": "Soul"}, headers=owner
|
||||||
|
)
|
||||||
|
return tid
|
||||||
|
|
||||||
|
|
||||||
|
async def test_purge_requires_soft_delete_first(client):
|
||||||
|
owner = auth(await register(client, "purge-a@ex.com"))
|
||||||
|
tid = await _tree_with_person(client, owner)
|
||||||
|
# A live tree can't be purged — it must be trashed first.
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/purge", json={"confirm_name": "Purge Me"}, headers=owner
|
||||||
|
)
|
||||||
|
assert r.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
async def test_purge_name_must_match(client):
|
||||||
|
owner = auth(await register(client, "purge-b@ex.com"))
|
||||||
|
tid = await _tree_with_person(client, owner)
|
||||||
|
await client.delete(f"/api/v1/trees/{tid}", headers=owner) # soft-delete
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/purge", json={"confirm_name": "WRONG"}, headers=owner
|
||||||
|
)
|
||||||
|
assert r.status_code == 403
|
||||||
|
# Still in the trash — nothing destroyed.
|
||||||
|
deleted = (await client.get("/api/v1/trees", params={"deleted": True}, headers=owner)).json()
|
||||||
|
assert any(t["id"] == tid for t in deleted)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_purge_owner_only(client):
|
||||||
|
owner = auth(await register(client, "purge-c@ex.com"))
|
||||||
|
other = auth(await register(client, "purge-c2@ex.com"))
|
||||||
|
tid = await _tree_with_person(client, owner)
|
||||||
|
await client.delete(f"/api/v1/trees/{tid}", headers=owner)
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/purge", json={"confirm_name": "Purge Me"}, headers=other
|
||||||
|
)
|
||||||
|
assert r.status_code in (403, 404)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_purge_removes_tree_and_cascades(client, db_session):
|
||||||
|
owner = auth(await register(client, "purge-d@ex.com"))
|
||||||
|
tid = await _tree_with_person(client, owner)
|
||||||
|
await client.delete(f"/api/v1/trees/{tid}", headers=owner)
|
||||||
|
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/purge", json={"confirm_name": "Purge Me"}, headers=owner
|
||||||
|
)
|
||||||
|
assert r.status_code == 204
|
||||||
|
|
||||||
|
# Gone from the trash...
|
||||||
|
deleted = (await client.get("/api/v1/trees", params={"deleted": True}, headers=owner)).json()
|
||||||
|
assert not any(t["id"] == tid for t in deleted)
|
||||||
|
|
||||||
|
# ...and cascaded: no tree row, no person rows.
|
||||||
|
tuuid = uuid.UUID(tid)
|
||||||
|
assert (
|
||||||
|
await db_session.execute(select(func.count()).select_from(Tree).where(Tree.id == tuuid))
|
||||||
|
).scalar() == 0
|
||||||
|
assert (
|
||||||
|
await db_session.execute(
|
||||||
|
select(func.count()).select_from(Person).where(Person.tree_id == tuuid)
|
||||||
|
)
|
||||||
|
).scalar() == 0
|
||||||
+5
-6
@@ -44,10 +44,9 @@ These two doc edits are themselves trivial quick wins (see §3).
|
|||||||
- **No place as a usable first-class entity** (model exists, created by GEDCOM, but no read/edit/delete — a create-only entity, which is a bug per NN#8).
|
- **No place as a usable first-class entity** (model exists, created by GEDCOM, but no read/edit/delete — a create-only entity, which is a bug per NN#8).
|
||||||
- **No research log, to-do/task planner, kinship calculator, data-quality checker, or i18n/string externalization** (the last is a documented day-one commitment that is currently unmet).
|
- **No research log, to-do/task planner, kinship calculator, data-quality checker, or i18n/string externalization** (the last is a documented day-one commitment that is currently unmet).
|
||||||
|
|
||||||
**Security-priority correctness fixes (do these first, regardless of phase).** Most of the original redaction defects shipped this cycle (#46); two items remain — one a narrowed PII gap, one a config switch:
|
**Security-priority correctness fixes (do these first, regardless of phase).** The redaction defects all shipped — child resources (#46) and now citations/sources too — leaving one config switch:
|
||||||
|
|
||||||
1. **Citation/source redaction gap (§2.10)** — `list_media`/`get_media`/`media_content`, plus the event/name/relationship endpoints, now apply `person_visibility` for non-members (#46), closing the media leak. The `citation`/`source` list endpoints still gate only on `can_view_tree`, so a non-member on a public/unlisted tree can still enumerate citations/sources tied to redacted living people — the remaining living-person leak.
|
1. **Self-registration approval-mode switch (§2.10)** — the read-side enforcement now exists: `REQUIRE_EMAIL_VERIFICATION` gates login/session on `email_verified_at` (#53). The remaining gap is the env switch to choose open vs admin-approval vs closed self-registration. *(The citation/source living-person leak is now closed — citation/source list endpoints apply `person_visibility` for non-members via `public_view_service`.)*
|
||||||
2. **Self-registration approval-mode switch (§2.10)** — the read-side enforcement now exists: `REQUIRE_EMAIL_VERIFICATION` gates login/session on `email_verified_at` (#53). The remaining gap is the env switch to choose open vs admin-approval vs closed self-registration.
|
|
||||||
|
|
||||||
**Strategic posture.** The differentiators worth pressing — property chain-of-title, the ChangeProposal AI model, the anonymous mutual-consent hint system, and true self-host data ownership — are mostly still ahead on the roadmap. The near-term job is (a) close the **privacy/auth correctness** and **collaboration** gaps that the architecture already implies, (b) ship the **maps + reports + merge** table stakes, and (c) finish the back-half spine — the **connector framework** plus wiring the now-landed **ChangeProposal/ModelProvider** into the assistant — that unlocks the entire back half of the roadmap.
|
**Strategic posture.** The differentiators worth pressing — property chain-of-title, the ChangeProposal AI model, the anonymous mutual-consent hint system, and true self-host data ownership — are mostly still ahead on the roadmap. The near-term job is (a) close the **privacy/auth correctness** and **collaboration** gaps that the architecture already implies, (b) ship the **maps + reports + merge** table stakes, and (c) finish the back-half spine — the **connector framework** plus wiring the now-landed **ChangeProposal/ModelProvider** into the assistant — that unlocks the entire back half of the roadmap.
|
||||||
|
|
||||||
@@ -250,7 +249,7 @@ The architecture is correct (single engine, tenant mixin, audit, soft-delete + p
|
|||||||
|
|
||||||
| Item | Description | Status | Imp | Eff | Phase | Non-negotiable |
|
| Item | Description | Status | Imp | Eff | Phase | Non-negotiable |
|
||||||
|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|
|
||||||
| **Uniform living-person redaction across child resources** | `person_visibility` now runs for non-members on the event, media, name, and relationship endpoints (#46), which delegate to `public_view_service`. Remaining: the `citation`/`source` list endpoints still gate only on `can_view_tree`, so citations tied to a redacted living person are still enumerable. | Partial | High | S | 1–2 | **Mostly resolved (NN#3/NN#2).** Apply `person_visibility` to the citation/source list paths to close the residual leak. |
|
| **Uniform living-person redaction across child resources** | `person_visibility` now runs for non-members on the event, media, name, relationship endpoints (#46) and the citation/source list endpoints, all delegating to `public_view_service`: citations resolve to FULL-visibility person(s); sources show only when they back a visible citation. | Have | High | S | 1–2 | **Resolved (NN#3/NN#2).** No child-resource path leaks a redacted living person's facts. |
|
||||||
| **Email-verification enforcement gate** | Read-side check now ships (#53): `REQUIRE_EMAIL_VERIFICATION` gates login/session on `email_verified_at` (`auth_service.py`). Opt-in (default off) so SMTP-less self-hosts still work. | Have | **High** | S | 1–2 | Read-side trust path now enforced (NN#7); the registration-mode switch below is the separate larger piece. |
|
| **Email-verification enforcement gate** | Read-side check now ships (#53): `REQUIRE_EMAIL_VERIFICATION` gates login/session on `email_verified_at` (`auth_service.py`). Opt-in (default off) so SMTP-less self-hosts still work. | Have | **High** | S | 1–2 | Read-side trust path now enforced (NN#7); the registration-mode switch below is the separate larger piece. |
|
||||||
| Self-registration mode gating (approve / open / closed) | No env switch to choose open vs admin-approval vs closed registration. | Partial | High | M | 2/5 | Twelve-factor registration control (NN#7); pairs with the verification gate above. |
|
| Self-registration mode gating (approve / open / closed) | No env switch to choose open vs admin-approval vs closed registration. | Partial | High | M | 2/5 | Twelve-factor registration control (NN#7); pairs with the verification gate above. |
|
||||||
| Instance owner / operator role | `OWNER_EMAIL`-declared operator (#240): `is_instance_owner` on `/users/me`, owner-only `GET /api/v1/admin/instance`, `/admin` UI. | Have | Med | S | 2/5 | Owner-only operational surface, twelve-factor via env (NN#7); reads stay through the service layer. |
|
| Instance owner / operator role | `OWNER_EMAIL`-declared operator (#240): `is_instance_owner` on `/users/me`, owner-only `GET /api/v1/admin/instance`, `/admin` UI. | Have | Med | S | 2/5 | Owner-only operational surface, twelve-factor via env (NN#7); reads stay through the service layer. |
|
||||||
@@ -412,7 +411,7 @@ Ordered by leverage. All are S-effort or a thin slice of a larger item, and most
|
|||||||
12. **Sort the merged person timeline** (Research workflow, Med/S) — `shownEvents.sort()` on `date_start`; currently appended unsorted.
|
12. **Sort the merged person timeline** (Research workflow, Med/S) — `shownEvents.sort()` on `date_start`; currently appended unsorted.
|
||||||
13. **Doc corrections (docs-vs-code)** (Meta, trivial/S) — edit CLAUDE.md / ARCHITECTURE so the pgvector "used" claim and the i18n "from day one" claim match reality. The repo convention requires docs to travel with code.
|
13. **Doc corrections (docs-vs-code)** (Meta, trivial/S) — edit CLAUDE.md / ARCHITECTURE so the pgvector "used" claim and the i18n "from day one" claim match reality. The repo convention requires docs to travel with code.
|
||||||
|
|
||||||
> **Mostly shipped this cycle (#46):** the **media privacy leak** (§2.4) and the broad **child-resource redaction gap** (§2.10) are now closed for the person/event/media/name/relationship endpoints. The narrowed remainder — applying `person_visibility` to the **citation/source list endpoints** — is an S-effort follow-up; treat it as a security-priority Phase 1–2 fix regardless of the quick-win list.
|
> **Shipped this cycle:** the **media privacy leak** (§2.4) and the **child-resource redaction gap** (§2.10) are fully closed — person/event/media/name/relationship (#46) and citation/source endpoints all apply `person_visibility` for non-members. No residual living-person leak on the read surface.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -426,6 +425,6 @@ Where to invest to make Provenance distinct rather than a webtrees clone. Each l
|
|||||||
|
|
||||||
**3. Anonymous, mutual-consent cross-tree hints.** The privacy model already redacts living people for anonymous viewers, so a hint system that reveals *nothing identifying* until both sides opt in is achievable by construction — and is a categorically more trustworthy version of MyHeritage Smart Matches / Ancestry hints. Requires the matching engine (pgvector enablement + candidate generation, Phase 7), the notification/event-dispatch substrate (§2.9), and the messaging channel that opens only post-consent.
|
**3. Anonymous, mutual-consent cross-tree hints.** The privacy model already redacts living people for anonymous viewers, so a hint system that reveals *nothing identifying* until both sides opt in is achievable by construction — and is a categorically more trustworthy version of MyHeritage Smart Matches / Ancestry hints. Requires the matching engine (pgvector enablement + candidate generation, Phase 7), the notification/event-dispatch substrate (§2.9), and the messaging channel that opens only post-consent.
|
||||||
|
|
||||||
**4. True self-hosting + data ownership.** Full account export/import, soft-delete recovery, GEDCOM round-trip, env-driven everything, a one-command operator backup, and (to-build) scheduled off-host backup + ARM support make Provenance the genealogy app you actually own. The two correctness items that gated the promise have **landed**: GEDCOM export now preserves citations (the Provenance→Provenance round-trip keeps the sources graph), and operator backup moved from "documented procedure" to a one-command dump (`deploy/backup.sh`). What remains is scheduled/verified-restore tooling and ARM builds. The Ollama/self-hosted ModelProvider path means even the AI assistant runs without tree data leaving the deployment — a promise no commercial competitor can make.
|
**4. True self-hosting + data ownership.** Full account export/import, soft-delete recovery (with owner-confirmed on-demand purge to delete a trashed tree immediately rather than waiting out the 30-day window), GEDCOM round-trip, env-driven everything, a one-command operator backup, and (to-build) scheduled off-host backup + ARM support make Provenance the genealogy app you actually own. The two correctness items that gated the promise have **landed**: GEDCOM export now preserves citations (the Provenance→Provenance round-trip keeps the sources graph), and operator backup moved from "documented procedure" to a one-command dump (`deploy/backup.sh`). What remains is scheduled/verified-restore tooling and ARM builds. The Ollama/self-hosted ModelProvider path means even the AI assistant runs without tree data leaving the deployment — a promise no commercial competitor can make.
|
||||||
|
|
||||||
**5. Sources-first as a felt experience.** The two-tier model is built, and citations now **survive GEDCOM export** (#232); the remaining differentiator is making sourcing *visible and low-friction*: a guided Evidence-Explained citation builder, transcription/abstract fields, source-driven data entry (transcribe a document into the tree), and per-fact confidence surfaced in the UI. These turn "every fact links to where it came from" from an architecture note into the product's personality.
|
**5. Sources-first as a felt experience.** The two-tier model is built, and citations now **survive GEDCOM export** (#232); the remaining differentiator is making sourcing *visible and low-friction*: a guided Evidence-Explained citation builder, transcription/abstract fields, source-driven data entry (transcribe a document into the tree), and per-fact confidence surfaced in the UI. These turn "every fact links to where it came from" from an architecture note into the product's personality.
|
||||||
|
|||||||
@@ -53,6 +53,26 @@ export default function TreesPage() {
|
|||||||
await api.POST("/api/v1/trees/{tree_id}/restore", { params: { path: { tree_id: id } } });
|
await api.POST("/api/v1/trees/{tree_id}/restore", { params: { path: { tree_id: id } } });
|
||||||
load();
|
load();
|
||||||
}
|
}
|
||||||
|
async function purge(id: string, treeName: string) {
|
||||||
|
const typed = window.prompt(
|
||||||
|
`Permanently delete "${treeName}" and ALL its data (people, sources, media, …)?\n\n` +
|
||||||
|
"This CANNOT be undone. Type the tree name to confirm:",
|
||||||
|
);
|
||||||
|
if (typed == null) return; // cancelled
|
||||||
|
const { error, response } = await api.POST("/api/v1/trees/{tree_id}/purge", {
|
||||||
|
params: { path: { tree_id: id } },
|
||||||
|
body: { confirm_name: typed },
|
||||||
|
});
|
||||||
|
if (error) {
|
||||||
|
window.alert(
|
||||||
|
response.status === 403
|
||||||
|
? "The name didn't match — nothing was deleted."
|
||||||
|
: "Couldn't purge that tree.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
}
|
||||||
// Optimistic visibility change so the dropdown reflects the pick immediately.
|
// Optimistic visibility change so the dropdown reflects the pick immediately.
|
||||||
async function setVisibility(id: string, visibility: NonNullable<Tree["visibility"]>) {
|
async function setVisibility(id: string, visibility: NonNullable<Tree["visibility"]>) {
|
||||||
setTrees((cur) => cur.map((t) => (t.id === id ? { ...t, visibility } : t)));
|
setTrees((cur) => cur.map((t) => (t.id === id ? { ...t, visibility } : t)));
|
||||||
@@ -139,15 +159,28 @@ export default function TreesPage() {
|
|||||||
<h2 className="font-serif text-base font-semibold text-[var(--muted)]">
|
<h2 className="font-serif text-base font-semibold text-[var(--muted)]">
|
||||||
Recently deleted
|
Recently deleted
|
||||||
</h2>
|
</h2>
|
||||||
|
<p className="text-xs text-[var(--muted)]">
|
||||||
|
Restorable for 30 days, after which they're purged automatically. Use
|
||||||
|
Delete forever to purge one now.
|
||||||
|
</p>
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{deleted.map((tree) => (
|
{deleted.map((tree) => (
|
||||||
<li key={tree.id}>
|
<li key={tree.id}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex items-center justify-between p-4">
|
<CardContent className="flex items-center justify-between gap-2 p-4">
|
||||||
<span className="text-[var(--muted)]">{tree.name}</span>
|
<span className="min-w-0 flex-1 truncate text-[var(--muted)]">{tree.name}</span>
|
||||||
<Button variant="outline" size="sm" onClick={() => restore(tree.id)}>
|
<Button variant="outline" size="sm" onClick={() => restore(tree.id)}>
|
||||||
Restore
|
Restore
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => purge(tree.id, tree.name)}
|
||||||
|
className="border-bronze/40 text-bronze hover:bg-bronze/10"
|
||||||
|
title="Permanently delete this tree and all its data"
|
||||||
|
>
|
||||||
|
Delete forever
|
||||||
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
Vendored
+59
@@ -293,6 +293,27 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/v1/trees/{tree_id}/purge": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Purge Tree
|
||||||
|
* @description Permanently delete a soft-deleted tree and all its data — irreversible.
|
||||||
|
* Owner-only; the tree must be in the trash and `confirm_name` must match.
|
||||||
|
*/
|
||||||
|
post: operations["purge_tree_api_v1_trees__tree_id__purge_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/v1/trees/{tree_id}/persons": {
|
"/api/v1/trees/{tree_id}/persons": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1977,6 +1998,11 @@ export interface components {
|
|||||||
/** @default private */
|
/** @default private */
|
||||||
visibility?: components["schemas"]["TreeVisibility"];
|
visibility?: components["schemas"]["TreeVisibility"];
|
||||||
};
|
};
|
||||||
|
/** TreePurge */
|
||||||
|
TreePurge: {
|
||||||
|
/** Confirm Name */
|
||||||
|
confirm_name: string;
|
||||||
|
};
|
||||||
/** TreeRead */
|
/** TreeRead */
|
||||||
TreeRead: {
|
TreeRead: {
|
||||||
/**
|
/**
|
||||||
@@ -2655,6 +2681,39 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
purge_tree_api_v1_trees__tree_id__purge_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
tree_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["TreePurge"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
list_persons_api_v1_trees__tree_id__persons_get: {
|
list_persons_api_v1_trees__tree_id__persons_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
|
|||||||
@@ -710,6 +710,53 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/trees/{tree_id}/purge": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"trees"
|
||||||
|
],
|
||||||
|
"summary": "Purge Tree",
|
||||||
|
"description": "Permanently delete a soft-deleted tree and all its data \u2014 irreversible.\nOwner-only; the tree must be in the trash and `confirm_name` must match.",
|
||||||
|
"operationId": "purge_tree_api_v1_trees__tree_id__purge_post",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "tree_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Tree Id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/TreePurge"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "Successful Response"
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Validation Error",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/trees/{tree_id}/persons": {
|
"/api/v1/trees/{tree_id}/persons": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -7101,6 +7148,19 @@
|
|||||||
],
|
],
|
||||||
"title": "TreeCreate"
|
"title": "TreeCreate"
|
||||||
},
|
},
|
||||||
|
"TreePurge": {
|
||||||
|
"properties": {
|
||||||
|
"confirm_name": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Confirm Name"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"confirm_name"
|
||||||
|
],
|
||||||
|
"title": "TreePurge"
|
||||||
|
},
|
||||||
"TreeRead": {
|
"TreeRead": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": {
|
"id": {
|
||||||
|
|||||||
Reference in New Issue
Block a user