Alternate names, self-person link, deletion integrity + dangling people #20

Merged
justin merged 1 commits from names-deletion-self into main 2026-06-07 10:41:03 -04:00
19 changed files with 2004 additions and 33 deletions
+2
View File
@@ -8,6 +8,7 @@ from app.api.v1 import (
events,
gedcom,
media,
names,
persons,
relationships,
sources,
@@ -20,6 +21,7 @@ api_router.include_router(auth.router)
api_router.include_router(users.router)
api_router.include_router(trees.router)
api_router.include_router(persons.router)
api_router.include_router(names.router)
api_router.include_router(events.router)
api_router.include_router(relationships.router)
api_router.include_router(sources.router)
+90
View File
@@ -0,0 +1,90 @@
import uuid
from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep
from app.schemas.name import NameCreate, NameRead, NameUpdate
from app.services import name_service, tree_service
# Names are nested under their person (which is nested under the tree tenant).
router = APIRouter(prefix="/trees", tags=["names"])
@router.get("/{tree_id}/persons/{person_id}/names", response_model=list[NameRead])
async def list_names(
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> list[NameRead]:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
names = await name_service.list_names(
session, viewer_id=current.id, tree=tree, person_id=person_id
)
return [NameRead.model_validate(n) for n in names]
@router.post(
"/{tree_id}/persons/{person_id}/names",
response_model=NameRead,
status_code=status.HTTP_201_CREATED,
)
async def create_name(
tree_id: uuid.UUID,
person_id: uuid.UUID,
data: NameCreate,
session: SessionDep,
current: CurrentUser,
) -> NameRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
name = await name_service.create_name(
session,
actor=current,
tree=tree,
person_id=person_id,
name_type=data.name_type,
given=data.given,
surname=data.surname,
prefix=data.prefix,
suffix=data.suffix,
nickname=data.nickname,
is_primary=data.is_primary,
)
return NameRead.model_validate(name)
@router.patch(
"/{tree_id}/persons/{person_id}/names/{name_id}", response_model=NameRead
)
async def update_name(
tree_id: uuid.UUID,
person_id: uuid.UUID,
name_id: uuid.UUID,
data: NameUpdate,
session: SessionDep,
current: CurrentUser,
) -> NameRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
name = await name_service.update_name(
session,
actor=current,
tree=tree,
person_id=person_id,
name_id=name_id,
changes=data.model_dump(exclude_unset=True),
)
return NameRead.model_validate(name)
@router.delete(
"/{tree_id}/persons/{person_id}/names/{name_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_name(
tree_id: uuid.UUID,
person_id: uuid.UUID,
name_id: uuid.UUID,
session: SessionDep,
current: CurrentUser,
) -> None:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
await name_service.delete_name(
session, actor=current, tree=tree, person_id=person_id, name_id=name_id
)
+13 -4
View File
@@ -75,12 +75,21 @@ async def update_person(
return PersonRead.model_validate(person)
@router.delete("/{tree_id}/persons/{person_id}", status_code=status.HTTP_204_NO_CONTENT)
@router.delete("/{tree_id}/persons/{person_id}")
async def delete_person(
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> None:
tree_id: uuid.UUID,
person_id: uuid.UUID,
session: SessionDep,
current: CurrentUser,
cascade: bool = False,
) -> dict[str, int]:
"""Delete a person. ``cascade=true`` also deletes all descendants. Returns
the number of persons deleted (1 unless cascading)."""
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
await person_service.delete_person(session, actor=current, tree=tree, person_id=person_id)
deleted = await person_service.delete_person(
session, actor=current, tree=tree, person_id=person_id, cascade=cascade
)
return {"deleted": deleted}
@router.post("/{tree_id}/persons/{person_id}/restore", response_model=PersonRead)
+14 -2
View File
@@ -1,7 +1,8 @@
from fastapi import APIRouter
from app.api.deps import CurrentUser
from app.schemas.user import UserRead
from app.api.deps import CurrentUser, SessionDep
from app.schemas.user import UserRead, UserSelfPersonUpdate
from app.services import user_service
router = APIRouter(prefix="/users", tags=["users"])
@@ -9,3 +10,14 @@ router = APIRouter(prefix="/users", tags=["users"])
@router.get("/me", response_model=UserRead)
async def read_me(current: CurrentUser) -> UserRead:
return UserRead.model_validate(current)
@router.patch("/me/self-person", response_model=UserRead)
async def set_self_person(
data: UserSelfPersonUpdate, session: SessionDep, current: CurrentUser
) -> UserRead:
"""Link (or unlink) the Person record that represents this account."""
user = await user_service.set_self_person(
session, user=current, person_id=data.self_person_id
)
return UserRead.model_validate(user)
+14 -1
View File
@@ -3,9 +3,10 @@ multiple auth providers later (the provider-link table arrives with the auth
slice). ``hashed_password`` is nullable: external/OIDC users have none.
"""
import uuid
from datetime import datetime
from sqlalchemy import DateTime, String
from sqlalchemy import DateTime, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
@@ -19,3 +20,15 @@ class User(Base, UUIDPrimaryKey, Timestamps, SoftDelete):
email_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
display_name: Mapped[str | None] = mapped_column(String(255))
hashed_password: Mapped[str | None] = mapped_column(String(255))
# The Person record that *is* this user ("home person"). Cleared if that
# person is deleted, so the link can never dangle.
self_person_id: Mapped[uuid.UUID | None] = mapped_column(
# use_alter + explicit name: users<->persons<->trees form an FK cycle,
# so this constraint must be created/dropped via ALTER, not inline.
ForeignKey(
"persons.id",
ondelete="SET NULL",
name="fk_users_self_person_id",
use_alter=True,
)
)
+42
View File
@@ -0,0 +1,42 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class NameCreate(BaseModel):
# Open vocabulary: birth/maiden, married, alias, religious, nickname, ...
name_type: str = "birth"
given: str | None = None
surname: str | None = None
prefix: str | None = None
suffix: str | None = None
nickname: str | None = None
is_primary: bool = False
class NameUpdate(BaseModel):
name_type: str | None = None
given: str | None = None
surname: str | None = None
prefix: str | None = None
suffix: str | None = None
nickname: str | None = None
is_primary: bool | None = None
class NameRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
tree_id: uuid.UUID
person_id: uuid.UUID
name_type: str
given: str | None
surname: str | None
prefix: str | None
suffix: str | None
nickname: str | None
is_primary: bool
sort_order: int
created_at: datetime
+6
View File
@@ -19,4 +19,10 @@ class UserRead(BaseModel):
email: str
display_name: str | None
email_verified_at: datetime | None
self_person_id: uuid.UUID | None = None
created_at: datetime
class UserSelfPersonUpdate(BaseModel):
# null clears the link; otherwise the Person that represents this account.
self_person_id: uuid.UUID | None = None
+208
View File
@@ -0,0 +1,208 @@
"""Name service. A Person carries one or more Name rows — a primary (typically
the birth/maiden name) plus typed alternates (married, alias, religious, …).
Exactly one name is primary at a time; it drives display everywhere. Writes
require editor rights; reads go through the tree's view check.
"""
import uuid
from datetime import UTC, datetime
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.person import Name, Person
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 _get_person(session: AsyncSession, *, 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")
return person
async def _clear_primary(
session: AsyncSession, *, person_id: uuid.UUID, keep: uuid.UUID | None
) -> None:
"""Demote every other name so exactly one stays primary."""
stmt = (
update(Name)
.where(Name.person_id == person_id, Name.deleted_at.is_(None), Name.is_primary.is_(True))
.values(is_primary=False)
)
if keep is not None:
stmt = stmt.where(Name.id != keep)
await session.execute(stmt)
async def list_names(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID
) -> list[Name]:
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
await _get_person(session, tree=tree, person_id=person_id)
stmt = (
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)
)
return list((await session.execute(stmt)).scalars().all())
async def create_name(
session: AsyncSession,
*,
actor: User,
tree: Tree,
person_id: uuid.UUID,
name_type: str = "birth",
given: str | None = None,
surname: str | None = None,
prefix: str | None = None,
suffix: str | None = None,
nickname: str | None = None,
is_primary: bool = False,
) -> Name:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
await _get_person(session, tree=tree, person_id=person_id)
# First name for a person is always primary; otherwise honor the flag.
existing = (
await session.execute(
select(Name.id).where(Name.person_id == person_id, Name.deleted_at.is_(None))
)
).first()
primary = is_primary or existing is None
if primary:
await _clear_primary(session, person_id=person_id, keep=None)
name = Name(
tree_id=tree.id,
person_id=person_id,
name_type=name_type,
given=given,
surname=surname,
prefix=prefix,
suffix=suffix,
nickname=nickname,
is_primary=primary,
)
session.add(name)
await session.flush()
record_audit(
session,
action="create",
entity_type="Name",
entity_id=name.id,
tree_id=tree.id,
actor_user_id=actor.id,
after={"name_type": name_type, "given": given, "surname": surname},
)
await session.commit()
await session.refresh(name)
return name
_NAME_FIELDS = {"name_type", "given", "surname", "prefix", "suffix", "nickname"}
async def update_name(
session: AsyncSession,
*,
actor: User,
tree: Tree,
person_id: uuid.UUID,
name_id: uuid.UUID,
changes: dict,
) -> Name:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
name = (
await session.execute(
select(Name).where(
Name.id == name_id,
Name.person_id == person_id,
Name.tree_id == tree.id,
Name.deleted_at.is_(None),
)
)
).scalar_one_or_none()
if name is None:
raise NotFound("name not found")
for key in _NAME_FIELDS & changes.keys():
setattr(name, key, changes[key])
if changes.get("is_primary") is True:
await _clear_primary(session, person_id=person_id, keep=name.id)
name.is_primary = True
record_audit(
session,
action="update",
entity_type="Name",
entity_id=name.id,
tree_id=tree.id,
actor_user_id=actor.id,
after=changes,
)
await session.commit()
await session.refresh(name)
return name
async def delete_name(
session: AsyncSession,
*,
actor: User,
tree: Tree,
person_id: uuid.UUID,
name_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")
name = (
await session.execute(
select(Name).where(
Name.id == name_id,
Name.person_id == person_id,
Name.tree_id == tree.id,
Name.deleted_at.is_(None),
)
)
).scalar_one_or_none()
if name is None:
raise NotFound("name not found")
name.deleted_at = datetime.now(UTC)
was_primary = name.is_primary
name.is_primary = False
record_audit(
session,
action="delete",
entity_type="Name",
entity_id=name.id,
tree_id=tree.id,
actor_user_id=actor.id,
)
# Promote another name to primary so the person never loses their display name.
if was_primary:
nxt = (
await session.execute(
select(Name)
.where(Name.person_id == person_id, Name.deleted_at.is_(None))
.order_by(Name.sort_order, Name.created_at)
)
).scalars().first()
if nxt is not None:
nxt.is_primary = True
await session.commit()
+102 -12
View File
@@ -6,11 +6,12 @@ person through the privacy engine. Each returned Person gets a transient
import uuid
from datetime import UTC, datetime
from sqlalchemy import func, or_, select
from sqlalchemy import func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.enums import PersonPrivacy
from app.models.enums import PersonPrivacy, RelationshipType
from app.models.person import Name, Person
from app.models.relationship import Relationship
from app.models.tree import Tree
from app.models.user import User
from app.services import privacy
@@ -177,9 +178,65 @@ async def get_person(
return person
async def delete_person(
session: AsyncSession, *, actor: User, tree: Tree, person_id: uuid.UUID
async def _children_of(
session: AsyncSession, *, tree_id: uuid.UUID, parent_id: uuid.UUID
) -> list[uuid.UUID]:
rows = (
await session.execute(
select(Relationship.person_to_id).where(
Relationship.tree_id == tree_id,
Relationship.deleted_at.is_(None),
Relationship.type == RelationshipType.parent_child,
Relationship.person_from_id == parent_id,
)
)
).scalars().all()
return list(rows)
async def _soft_delete_one(
session: AsyncSession, *, actor: User, tree: Tree, person: Person, now: datetime
) -> None:
"""Soft-delete a single person and the relationships touching them, so no
dangling edges are left to break the tree view."""
person.deleted_at = now
rels = (
await session.execute(
select(Relationship).where(
Relationship.tree_id == tree.id,
Relationship.deleted_at.is_(None),
or_(
Relationship.person_from_id == person.id,
Relationship.person_to_id == person.id,
),
)
)
).scalars().all()
for rel in rels:
rel.deleted_at = now
record_audit(
session,
action="delete",
entity_type="Person",
entity_id=person.id,
tree_id=tree.id,
actor_user_id=actor.id,
after={"cascaded_relationships": len(rels)},
)
async def delete_person(
session: AsyncSession,
*,
actor: User,
tree: Tree,
person_id: uuid.UUID,
cascade: bool = False,
) -> int:
"""Soft-delete a person. Always removes the relationships that touch them
(preventing dangling edges). With ``cascade=True``, recursively deletes
their descendants too — handy for pruning a bad GEDCOM import. Returns the
number of persons deleted."""
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
person = (
@@ -191,16 +248,49 @@ async def delete_person(
).scalar_one_or_none()
if person is None:
raise NotFound("person not found")
person.deleted_at = datetime.now(UTC)
record_audit(
session,
action="delete",
entity_type="Person",
entity_id=person.id,
tree_id=tree.id,
actor_user_id=actor.id,
now = datetime.now(UTC)
# Gather the set of persons to delete. For cascade, walk descendants
# breadth-first, guarding against cycles.
to_delete: list[Person] = [person]
if cascade:
seen = {person.id}
frontier = [person.id]
while frontier:
nxt: list[uuid.UUID] = []
for pid in frontier:
for child_id in await _children_of(session, tree_id=tree.id, parent_id=pid):
if child_id not in seen:
seen.add(child_id)
nxt.append(child_id)
frontier = nxt
extra_ids = [pid for pid in seen if pid != person.id]
if extra_ids:
extra = (
await session.execute(
select(Person).where(
Person.id.in_(extra_ids),
Person.tree_id == tree.id,
Person.deleted_at.is_(None),
)
)
).scalars().all()
to_delete.extend(extra)
for p in to_delete:
await _soft_delete_one(session, actor=actor, tree=tree, person=p, now=now)
# Soft delete leaves the row in place, so the DB-level "ON DELETE SET NULL"
# never fires — clear any account's self-person link to a deleted person.
await session.execute(
update(User)
.where(User.self_person_id.in_([p.id for p in to_delete]))
.values(self_person_id=None)
)
await session.commit()
return len(to_delete)
async def restore_person(
+40 -1
View File
@@ -8,10 +8,13 @@ import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.person import Person
from app.models.tree import Tree
from app.models.user import User
from app.repositories.base import BaseRepository
from app.services import privacy
from app.services.audit import record_audit
from app.services.exceptions import Conflict
from app.services.exceptions import Conflict, Forbidden, NotFound
async def create_user(
@@ -42,3 +45,39 @@ async def create_user(
async def get_user(session: AsyncSession, user_id: uuid.UUID) -> User | None:
return await BaseRepository(session, User).get(user_id)
async def set_self_person(
session: AsyncSession, *, user: User, person_id: uuid.UUID | None
) -> User:
"""Point a user's account at the Person record that *is* them ("home
person"), or clear it with ``None``. The person must live in a tree the
user can view."""
if person_id is not None:
person = (
await session.execute(
select(Person).where(Person.id == person_id, Person.deleted_at.is_(None))
)
).scalar_one_or_none()
if person is None:
raise NotFound("person not found")
tree = (
await session.execute(select(Tree).where(Tree.id == person.tree_id))
).scalar_one_or_none()
if tree is None or not await privacy.can_view_tree(
session, user_id=user.id, tree=tree
):
raise Forbidden("not permitted to link this person")
user.self_person_id = person_id
record_audit(
session,
action="update",
entity_type="User",
entity_id=user.id,
actor_user_id=user.id,
after={"self_person_id": str(person_id) if person_id else None},
)
await session.commit()
await session.refresh(user)
return user
@@ -0,0 +1,36 @@
"""user.self_person_id ("home person" link)
Revision ID: b3d5f8a1c920
Revises: 9a2b1c7d4e10
Create Date: 2026-06-07
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "b3d5f8a1c920"
down_revision: str | None = "9a2b1c7d4e10"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"users",
sa.Column("self_person_id", sa.Uuid(), nullable=True),
)
op.create_foreign_key(
"fk_users_self_person_id",
"users",
"persons",
["self_person_id"],
["id"],
ondelete="SET NULL",
)
def downgrade() -> None:
op.drop_constraint("fk_users_self_person_id", "users", type_="foreignkey")
op.drop_column("users", "self_person_id")
+83
View File
@@ -0,0 +1,83 @@
"""Deletion integrity (relationship cleanup + cascade) and the self-person link."""
from tests.conftest import auth, register
async def _setup(client, email):
h = auth(await register(client, email))
tid = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"]
return h, tid
async def _person(client, h, tid, given):
return (
await client.post(f"/api/v1/trees/{tid}/persons", json={"given": given}, headers=h)
).json()["id"]
async def _link_parent(client, h, tid, parent, child):
await client.post(
f"/api/v1/trees/{tid}/relationships",
json={"type": "parent_child", "person_from_id": parent, "person_to_id": child},
headers=h,
)
async def test_delete_removes_relationships(client):
h, tid = await _setup(client, "d-rels@example.com")
gp = await _person(client, h, tid, "Grandpa")
dad = await _person(client, h, tid, "Dad")
await _link_parent(client, h, tid, gp, dad)
r = await client.delete(f"/api/v1/trees/{tid}/persons/{gp}", headers=h)
assert r.status_code == 200 and r.json()["deleted"] == 1
# The dangling edge is gone, so the tree view can't break on it.
rels = (
await client.get(f"/api/v1/trees/{tid}/relationships", headers=h)
).json()
assert rels == []
# Dad survives.
ppl = {p["id"] for p in (await client.get(f"/api/v1/trees/{tid}/persons", headers=h)).json()}
assert dad in ppl and gp not in ppl
async def test_cascade_deletes_descendants(client):
h, tid = await _setup(client, "d-cascade@example.com")
gp = await _person(client, h, tid, "Grandpa")
dad = await _person(client, h, tid, "Dad")
kid = await _person(client, h, tid, "Kid")
await _link_parent(client, h, tid, gp, dad)
await _link_parent(client, h, tid, dad, kid)
r = await client.delete(f"/api/v1/trees/{tid}/persons/{gp}?cascade=true", headers=h)
assert r.status_code == 200 and r.json()["deleted"] == 3
ppl = (await client.get(f"/api/v1/trees/{tid}/persons", headers=h)).json()
assert ppl == []
async def test_self_person_link(client):
h, tid = await _setup(client, "self@example.com")
me = await _person(client, h, tid, "Me")
r = await client.patch(
"/api/v1/users/me/self-person", json={"self_person_id": me}, headers=h
)
assert r.status_code == 200 and r.json()["self_person_id"] == me
# Reflected on /me.
assert (await client.get("/api/v1/users/me", headers=h)).json()["self_person_id"] == me
# Deleting that person clears the link (SET NULL).
await client.delete(f"/api/v1/trees/{tid}/persons/{me}", headers=h)
assert (await client.get("/api/v1/users/me", headers=h)).json()["self_person_id"] is None
async def test_self_person_clear(client):
h, tid = await _setup(client, "self-clear@example.com")
me = await _person(client, h, tid, "Me")
await client.patch("/api/v1/users/me/self-person", json={"self_person_id": me}, headers=h)
r = await client.patch(
"/api/v1/users/me/self-person", json={"self_person_id": None}, headers=h
)
assert r.status_code == 200 and r.json()["self_person_id"] is None
+92
View File
@@ -0,0 +1,92 @@
"""Multiple typed names per person: maiden (primary) + married/alias alternates."""
from tests.conftest import auth, register
async def _setup(client, email):
h = auth(await register(client, email))
tid = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"]
pid = (
await client.post(
f"/api/v1/trees/{tid}/persons", json={"given": "Mary", "surname": "Smith"}, headers=h
)
).json()["id"]
return h, tid, pid
async def test_create_lists_and_primary(client):
h, tid, pid = await _setup(client, "n-create@example.com")
base = f"/api/v1/trees/{tid}/persons/{pid}/names"
# The person was created with a primary birth name.
names = (await client.get(base, headers=h)).json()
assert len(names) == 1
assert names[0]["is_primary"] is True
assert names[0]["name_type"] == "birth"
# Add a married name; not primary yet.
r = await client.post(
base, json={"name_type": "married", "given": "Mary", "surname": "Jones"}, headers=h
)
assert r.status_code == 201
assert r.json()["is_primary"] is False
names = (await client.get(base, headers=h)).json()
assert len(names) == 2
# Primary first.
assert names[0]["surname"] == "Smith" and names[0]["is_primary"] is True
async def test_set_primary_demotes_others(client):
h, tid, pid = await _setup(client, "n-primary@example.com")
base = f"/api/v1/trees/{tid}/persons/{pid}/names"
married = (
await client.post(
base, json={"name_type": "married", "given": "Mary", "surname": "Jones"}, headers=h
)
).json()
r = await client.patch(f"{base}/{married['id']}", json={"is_primary": True}, headers=h)
assert r.status_code == 200 and r.json()["is_primary"] is True
names = {n["surname"]: n["is_primary"] for n in (await client.get(base, headers=h)).json()}
assert names == {"Jones": True, "Smith": False}
# The person's display name now reflects the new primary.
person = (
await client.get(f"/api/v1/trees/{tid}/persons/{pid}", headers=h)
).json()
assert person["primary_name"] == "Mary Jones"
async def test_update_fields(client):
h, tid, pid = await _setup(client, "n-update@example.com")
base = f"/api/v1/trees/{tid}/persons/{pid}/names"
nid = (
await client.post(base, json={"name_type": "alias", "given": "Polly"}, headers=h)
).json()["id"]
r = await client.patch(
f"{base}/{nid}", json={"surname": "Smith", "nickname": "Poll"}, headers=h
)
assert r.status_code == 200
assert r.json()["surname"] == "Smith" and r.json()["nickname"] == "Poll"
async def test_delete_promotes_new_primary(client):
h, tid, pid = await _setup(client, "n-delete@example.com")
base = f"/api/v1/trees/{tid}/persons/{pid}/names"
alt = (
await client.post(
base, json={"name_type": "married", "given": "Mary", "surname": "Jones"}, headers=h
)
).json()["id"]
# Delete the (primary) birth name; the married name should be promoted.
primary = next(
n for n in (await client.get(base, headers=h)).json() if n["is_primary"]
)
r = await client.delete(f"{base}/{primary['id']}", headers=h)
assert r.status_code == 204
names = (await client.get(base, headers=h)).json()
assert len(names) == 1 and names[0]["id"] == alt and names[0]["is_primary"] is True
+1 -1
View File
@@ -41,7 +41,7 @@ async def test_person_delete_and_restore(client):
assert (
await client.delete(f"/api/v1/trees/{tree_id}/persons/{person_id}", headers=h)
).status_code == 204
).status_code == 200
assert len((await client.get(f"/api/v1/trees/{tree_id}/persons", headers=h)).json()) == 0
deleted = (
await client.get(f"/api/v1/trees/{tree_id}/persons?deleted=true", headers=h)
+45
View File
@@ -317,6 +317,17 @@ export default function FamilyViewPage() {
const partners = partnersOf(focus.id);
const children = childrenOf(focus.id);
// "Dangling" people: not linked to anyone. Common after a GEDCOM import or a
// mistaken delete — surface them so they're not lost in the directory.
const connected = new Set<string>();
for (const r of rels) {
connected.add(r.person_from_id);
connected.add(r.person_to_id);
}
const unconnected = people
.filter((p) => !connected.has(p.id))
.sort((a, b) => (a.primary_name ?? "").localeCompare(b.primary_name ?? ""));
const sorted = [...people].sort((a, b) =>
(a.primary_name ?? "").localeCompare(b.primary_name ?? ""),
);
@@ -380,6 +391,40 @@ export default function FamilyViewPage() {
</Card>
</div>
{/* Unconnected people — not linked to anyone in the tree */}
{unconnected.length > 0 && (
<Card className="border-bronze/40">
<CardContent className="space-y-3 p-6">
<div className="flex items-center justify-between">
<h2 className="font-serif text-base font-semibold">
Not connected to anyone ({unconnected.length})
</h2>
<span className="text-xs text-[var(--muted)]">
Open one and add a relationship, or delete it.
</span>
</div>
<div className="flex flex-wrap gap-3">
{unconnected.slice(0, 60).map((p) => (
<div key={p.id} className="flex items-center gap-1">
<PersonBox id={p.id} muted />
<Link
href={`/trees/${treeId}/persons/${p.id}`}
className="text-xs text-bronze hover:underline"
>
open
</Link>
</div>
))}
</div>
{unconnected.length > 60 && (
<p className="text-xs text-[var(--muted)]">
Showing 60 of {unconnected.length}.
</p>
)}
</CardContent>
</Card>
)}
{/* Scrollable, searchable people directory (scales to large trees) */}
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3">
@@ -12,6 +12,8 @@ import { Input } from "@/components/ui/input";
import { PersonCombobox } from "@/components/person-combobox";
type Person = components["schemas"]["PersonRead"];
type Name = components["schemas"]["NameRead"];
type Me = components["schemas"]["UserRead"];
type Event = components["schemas"]["EventRead"];
type Relationship = components["schemas"]["RelationshipRead"];
type Qualifier = components["schemas"]["ParentChildQualifier"];
@@ -23,6 +25,21 @@ type CitationCreate = components["schemas"]["CitationCreate"];
const fieldCls = "h-9 rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 text-sm";
const QUALIFIERS: Qualifier[] = ["biological", "adoptive", "step", "foster", "donor", "guardian"];
// Typed name vocabulary. "birth" is the maiden/birth name; "married" etc. are
// alternates. The maiden name stays primary by convention (Ancestry/FamilySearch).
const NAME_TYPES: { value: string; label: string }[] = [
{ value: "birth", label: "Birth / maiden" },
{ value: "married", label: "Married" },
{ value: "alias", label: "Also known as" },
{ value: "nickname", label: "Nickname" },
{ value: "religious", label: "Religious" },
{ value: "immigration", label: "Anglicized" },
];
const nameTypeLabel = (t: string) =>
NAME_TYPES.find((n) => n.value === t)?.label ?? t;
const formatName = (n: Name) =>
[n.given, n.surname].filter(Boolean).join(" ") || "—";
// Curated genealogical event vocabulary (with an escape hatch).
const EVENT_TYPES = [
"birth", "death", "marriage", "divorce", "engagement", "baptism", "burial",
@@ -89,6 +106,8 @@ export default function PersonDetailPage() {
const [person, setPerson] = useState<Person | null>(null);
const [people, setPeople] = useState<Person[]>([]);
const [names, setNames] = useState<Name[]>([]);
const [me, setMe] = useState<Me | null>(null);
const [events, setEvents] = useState<Event[]>([]);
const [rels, setRels] = useState<Relationship[]>([]);
const [sources, setSources] = useState<Source[]>([]);
@@ -123,6 +142,18 @@ export default function PersonDetailPage() {
const [relOther, setRelOther] = useState("");
const [relQual, setRelQual] = useState<Qualifier>("biological");
// Add-name form + inline edit.
const [nameType, setNameType] = useState("married");
const [nGiven, setNGiven] = useState("");
const [nSurname, setNSurname] = useState("");
const [editNameId, setEditNameId] = useState<string | null>(null);
const [enType, setEnType] = useState("married");
const [enGiven, setEnGiven] = useState("");
const [enSurname, setEnSurname] = useState("");
// Delete confirmation (with optional cascade to descendants).
const [confirmingDelete, setConfirmingDelete] = useState(false);
// Inline citation form: which fact is being cited ("p" = person, `e:<id>`).
const [citeFor, setCiteFor] = useState<string | null>(null);
const [citeSource, setCiteSource] = useState("");
@@ -137,8 +168,12 @@ export default function PersonDetailPage() {
return;
}
setPerson(p.data ?? null);
const [all, ev, rl, src, cit] = await Promise.all([
const [all, nm, mine, ev, rl, src, cit] = await Promise.all([
api.GET("/api/v1/trees/{tree_id}/persons", { params: { path: { tree_id: treeId } } }),
api.GET("/api/v1/trees/{tree_id}/persons/{person_id}/names", {
params: { path: { tree_id: treeId, person_id: personId } },
}),
api.GET("/api/v1/users/me"),
api.GET("/api/v1/trees/{tree_id}/persons/{person_id}/events", {
params: { path: { tree_id: treeId, person_id: personId } },
}),
@@ -149,6 +184,8 @@ export default function PersonDetailPage() {
api.GET("/api/v1/trees/{tree_id}/citations", { params: { path: { tree_id: treeId } } }),
]);
setPeople(all.data ?? []);
setNames(nm.data ?? []);
setMe(mine.data ?? null);
setEvents(ev.data ?? []);
setRels(rl.data ?? []);
setSources(src.data ?? []);
@@ -284,13 +321,72 @@ export default function PersonDetailPage() {
load();
}
async function removePerson() {
async function removePerson(cascade: boolean) {
await api.DELETE("/api/v1/trees/{tree_id}/persons/{person_id}", {
params: { path: { tree_id: treeId, person_id: personId } },
params: { path: { tree_id: treeId, person_id: personId }, query: { cascade } },
});
router.push(`/trees/${treeId}`);
}
async function addName(e: React.FormEvent) {
e.preventDefault();
if (!nGiven.trim() && !nSurname.trim()) return;
const { error } = await api.POST("/api/v1/trees/{tree_id}/persons/{person_id}/names", {
params: { path: { tree_id: treeId, person_id: personId } },
body: { name_type: nameType, given: nGiven || null, surname: nSurname || null },
});
if (!error) {
setNGiven("");
setNSurname("");
setNameType("married");
load();
}
}
function startEditName(n: Name) {
setEditNameId(n.id);
setEnType(n.name_type);
setEnGiven(n.given ?? "");
setEnSurname(n.surname ?? "");
}
async function saveName() {
if (!editNameId) return;
const { error } = await api.PATCH(
"/api/v1/trees/{tree_id}/persons/{person_id}/names/{name_id}",
{
params: { path: { tree_id: treeId, person_id: personId, name_id: editNameId } },
body: { name_type: enType, given: enGiven || null, surname: enSurname || null },
},
);
if (!error) {
setEditNameId(null);
load();
}
}
async function makePrimaryName(id: string) {
await api.PATCH("/api/v1/trees/{tree_id}/persons/{person_id}/names/{name_id}", {
params: { path: { tree_id: treeId, person_id: personId, name_id: id } },
body: { is_primary: true },
});
load();
}
async function removeName(id: string) {
await api.DELETE("/api/v1/trees/{tree_id}/persons/{person_id}/names/{name_id}", {
params: { path: { tree_id: treeId, person_id: personId, name_id: id } },
});
load();
}
async function setSelf(link: boolean) {
await api.PATCH("/api/v1/users/me/self-person", {
body: { self_person_id: link ? personId : null },
});
load();
}
function startEditPerson(current: Person) {
const t = (current.primary_name ?? "").trim().split(/\s+/).filter(Boolean);
setPGiven(t.length > 1 ? t.slice(0, -1).join(" ") : (t[0] ?? ""));
@@ -321,6 +417,8 @@ export default function PersonDetailPage() {
if (!ready) return <p className="text-[var(--muted)]">Loading</p>;
if (!person) return <p className="text-[var(--muted)]">Not found.</p>;
const isSelf = me?.self_person_id === personId;
// Inline "cite" control: a badge with count, a toggle, and the picker form.
function citeControl(key: string, target: Partial<CitationCreate>, cites: Citation[]) {
return (
@@ -463,19 +561,195 @@ export default function PersonDetailPage() {
</form>
) : (
<div className="flex flex-wrap items-center justify-between gap-2">
<h1 className="text-3xl font-semibold">{person.primary_name ?? "Unnamed person"}</h1>
<h1 className="flex items-center gap-3 text-3xl font-semibold">
{person.primary_name ?? "Unnamed person"}
{isSelf && (
<span className="rounded-full bg-bronze/15 px-2.5 py-1 text-xs font-medium text-bronze">
This is you
</span>
)}
</h1>
<div className="flex items-center gap-3">
{citeControl("p", { person_id: personId }, personCites)}
{isSelf ? (
<Button variant="ghost" size="sm" onClick={() => setSelf(false)}>
Unlink me
</Button>
) : (
<Button variant="outline" size="sm" onClick={() => setSelf(true)}>
This is me
</Button>
)}
<Button variant="outline" size="sm" onClick={() => startEditPerson(person)}>
Edit
</Button>
<Button variant="ghost" size="sm" onClick={removePerson}>
<Button variant="ghost" size="sm" onClick={() => setConfirmingDelete(true)}>
Delete
</Button>
</div>
</div>
)}
{confirmingDelete && (
<div className="space-y-3 rounded-lg border border-bronze/40 bg-bronze/[0.05] p-4">
<p className="text-sm">
Delete <strong>{person.primary_name ?? "this person"}</strong>? Their relationships
will be removed too. This can be undone from Recovery.
</p>
<div className="flex flex-wrap gap-2">
<Button variant="ghost" size="sm" onClick={() => removePerson(false)}>
Delete only this person
</Button>
<Button variant="outline" size="sm" onClick={() => removePerson(true)}>
Delete with all descendants
</Button>
<button
type="button"
onClick={() => setConfirmingDelete(false)}
className="text-xs text-[var(--muted)]"
>
cancel
</button>
</div>
</div>
)}
<Card>
<CardHeader>
<CardTitle className="text-base">Names</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{names.length === 0 ? (
<p className="text-sm text-[var(--muted)]">No names yet.</p>
) : (
<ul className="space-y-2">
{names.map((n) =>
editNameId === n.id ? (
<li key={n.id}>
<form
onSubmit={(e) => {
e.preventDefault();
saveName();
}}
className="flex flex-wrap items-center gap-2"
>
<select
className={fieldCls}
value={enType}
onChange={(e) => setEnType(e.target.value)}
>
{NAME_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
<Input
className="h-9 w-36"
placeholder="Given"
value={enGiven}
onChange={(e) => setEnGiven(e.target.value)}
/>
<Input
className="h-9 w-36"
placeholder="Surname"
value={enSurname}
onChange={(e) => setEnSurname(e.target.value)}
/>
<Button type="submit" size="sm">
Save
</Button>
<button
type="button"
onClick={() => setEditNameId(null)}
className="text-xs text-[var(--muted)]"
>
cancel
</button>
</form>
</li>
) : (
<li
key={n.id}
className="flex flex-wrap items-center justify-between gap-2 text-sm"
>
<span className="flex items-center gap-2">
<span className="font-medium">{formatName(n)}</span>
<span className="rounded bg-[var(--border)]/50 px-1.5 py-0.5 text-xs text-[var(--muted)]">
{nameTypeLabel(n.name_type)}
</span>
{n.is_primary && (
<span className="rounded bg-bronze/15 px-1.5 py-0.5 text-xs text-bronze">
primary
</span>
)}
</span>
<span className="flex items-center gap-3">
{!n.is_primary && (
<button
onClick={() => makePrimaryName(n.id)}
className="text-xs text-bronze hover:underline"
>
make primary
</button>
)}
<button
onClick={() => startEditName(n)}
className="text-xs text-bronze hover:underline"
>
edit
</button>
<button
onClick={() => removeName(n.id)}
className="text-[var(--muted)] hover:text-bronze"
aria-label="Remove"
>
×
</button>
</span>
</li>
),
)}
</ul>
)}
<form onSubmit={addName} className="flex flex-wrap items-end gap-2">
<label className="flex flex-col gap-1">
<span className="text-xs text-[var(--muted)]">Type</span>
<select
className={fieldCls}
value={nameType}
onChange={(e) => setNameType(e.target.value)}
>
{NAME_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-[var(--muted)]">Given</span>
<Input
className="h-9 w-36"
placeholder="Given"
value={nGiven}
onChange={(e) => setNGiven(e.target.value)}
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-[var(--muted)]">Surname</span>
<Input
className="h-9 w-36"
placeholder="Surname"
value={nSurname}
onChange={(e) => setNSurname(e.target.value)}
/>
</label>
<Button type="submit">Add name</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Life events</CardTitle>
+10 -1
View File
@@ -104,6 +104,11 @@ export default function TreePage() {
if (status !== "ready" || mode === "fan" || !containerRef.current) return;
let cancelled = false;
(async () => {
// Only link to people that still exist — a soft-deleted person leaves
// dangling relationship rows, and family-chart breaks on an id with no
// matching datum. Filter them out so a deletion never blanks the tree.
const alive = new Set(people.map((pp) => pp.id));
const keep = (ids: string[]) => ids.filter((id) => alive.has(id));
const data = people.map((pp) => {
const [fn, ln] = splitName(pp.primary_name);
return {
@@ -114,7 +119,11 @@ export default function TreePage() {
birthday: years.get(pp.id) ?? "",
gender: pp.gender === "female" ? "F" : "M",
},
rels: { spouses: partnersOf(pp.id), parents: parentsOf(pp.id), children: childrenOf(pp.id) },
rels: {
spouses: keep(partnersOf(pp.id)),
parents: keep(parentsOf(pp.id)),
children: keep(childrenOf(pp.id)),
},
};
});
const f3 = await import("family-chart");
+325 -4
View File
@@ -157,6 +157,26 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/users/me/self-person": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
/**
* Set Self Person
* @description Link (or unlink) the Person record that represents this account.
*/
patch: operations["set_self_person_api_v1_users_me_self_person_patch"];
trace?: never;
};
"/api/v1/trees": {
parameters: {
query?: never;
@@ -240,7 +260,11 @@ export interface paths {
get: operations["get_person_api_v1_trees__tree_id__persons__person_id__get"];
put?: never;
post?: never;
/** Delete Person */
/**
* Delete Person
* @description Delete a person. ``cascade=true`` also deletes all descendants. Returns
* the number of persons deleted (1 unless cascading).
*/
delete: operations["delete_person_api_v1_trees__tree_id__persons__person_id__delete"];
options?: never;
head?: never;
@@ -265,6 +289,42 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/trees/{tree_id}/persons/{person_id}/names": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List Names */
get: operations["list_names_api_v1_trees__tree_id__persons__person_id__names_get"];
put?: never;
/** Create Name */
post: operations["create_name_api_v1_trees__tree_id__persons__person_id__names_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/trees/{tree_id}/persons/{person_id}/names/{name_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
/** Delete Name */
delete: operations["delete_name_api_v1_trees__tree_id__persons__person_id__names__name_id__delete"];
options?: never;
head?: never;
/** Update Name */
patch: operations["update_name_api_v1_trees__tree_id__persons__person_id__names__name_id__patch"];
trace?: never;
};
"/api/v1/trees/{tree_id}/events": {
parameters: {
query?: never;
@@ -780,6 +840,85 @@ export interface components {
/** Source Id */
source_id?: string | null;
};
/** NameCreate */
NameCreate: {
/**
* Name Type
* @default birth
*/
name_type?: string;
/** Given */
given?: string | null;
/** Surname */
surname?: string | null;
/** Prefix */
prefix?: string | null;
/** Suffix */
suffix?: string | null;
/** Nickname */
nickname?: string | null;
/**
* Is Primary
* @default false
*/
is_primary?: boolean;
};
/** NameRead */
NameRead: {
/**
* Id
* Format: uuid
*/
id: string;
/**
* Tree Id
* Format: uuid
*/
tree_id: string;
/**
* Person Id
* Format: uuid
*/
person_id: string;
/** Name Type */
name_type: string;
/** Given */
given: string | null;
/** Surname */
surname: string | null;
/** Prefix */
prefix: string | null;
/** Suffix */
suffix: string | null;
/** Nickname */
nickname: string | null;
/** Is Primary */
is_primary: boolean;
/** Sort Order */
sort_order: number;
/**
* Created At
* Format: date-time
*/
created_at: string;
};
/** NameUpdate */
NameUpdate: {
/** Name Type */
name_type?: string | null;
/** Given */
given?: string | null;
/** Surname */
surname?: string | null;
/** Prefix */
prefix?: string | null;
/** Suffix */
suffix?: string | null;
/** Nickname */
nickname?: string | null;
/** Is Primary */
is_primary?: boolean | null;
};
/**
* ParentChildQualifier
* @description Qualifies a parent_child edge so adoption/donor/blended families are
@@ -1074,12 +1213,19 @@ export interface components {
display_name: string | null;
/** Email Verified At */
email_verified_at: string | null;
/** Self Person Id */
self_person_id?: string | null;
/**
* Created At
* Format: date-time
*/
created_at: string;
};
/** UserSelfPersonUpdate */
UserSelfPersonUpdate: {
/** Self Person Id */
self_person_id?: string | null;
};
/** ValidationError */
ValidationError: {
/** Location */
@@ -1347,6 +1493,39 @@ export interface operations {
};
};
};
set_self_person_api_v1_users_me_self_person_patch: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["UserSelfPersonUpdate"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["UserRead"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
list_my_trees_api_v1_trees_get: {
parameters: {
query?: {
@@ -1640,7 +1819,9 @@ export interface operations {
};
delete_person_api_v1_trees__tree_id__persons__person_id__delete: {
parameters: {
query?: never;
query?: {
cascade?: boolean;
};
header?: never;
path: {
tree_id: string;
@@ -1651,11 +1832,15 @@ export interface operations {
requestBody?: never;
responses: {
/** @description Successful Response */
204: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
content: {
"application/json": {
[key: string]: number;
};
};
};
/** @description Validation Error */
422: {
@@ -1736,6 +1921,142 @@ export interface operations {
};
};
};
list_names_api_v1_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"];
};
};
};
};
create_name_api_v1_trees__tree_id__persons__person_id__names_post: {
parameters: {
query?: never;
header?: never;
path: {
tree_id: string;
person_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["NameCreate"];
};
};
responses: {
/** @description Successful Response */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["NameRead"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
delete_name_api_v1_trees__tree_id__persons__person_id__names__name_id__delete: {
parameters: {
query?: never;
header?: never;
path: {
tree_id: string;
person_id: string;
name_id: string;
};
cookie?: never;
};
requestBody?: never;
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"];
};
};
};
};
update_name_api_v1_trees__tree_id__persons__person_id__names__name_id__patch: {
parameters: {
query?: never;
header?: never;
path: {
tree_id: string;
person_id: string;
name_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["NameUpdate"];
};
};
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"];
};
};
};
};
list_tree_events_api_v1_trees__tree_id__events_get: {
parameters: {
query?: never;
+602 -2
View File
@@ -280,6 +280,48 @@
}
}
},
"/api/v1/users/me/self-person": {
"patch": {
"tags": [
"users"
],
"summary": "Set Self Person",
"description": "Link (or unlink) the Person record that represents this account.",
"operationId": "set_self_person_api_v1_users_me_self_person_patch",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserSelfPersonUpdate"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserRead"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/v1/trees": {
"post": {
"tags": [
@@ -728,6 +770,7 @@
"persons"
],
"summary": "Delete Person",
"description": "Delete a person. ``cascade=true`` also deletes all descendants. Returns\nthe number of persons deleted (1 unless cascading).",
"operationId": "delete_person_api_v1_trees__tree_id__persons__person_id__delete",
"parameters": [
{
@@ -749,11 +792,32 @@
"format": "uuid",
"title": "Person Id"
}
},
{
"name": "cascade",
"in": "query",
"required": false,
"schema": {
"type": "boolean",
"default": false,
"title": "Cascade"
}
}
],
"responses": {
"204": {
"description": "Successful Response"
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": {
"type": "integer"
},
"title": "Response Delete Person Api V1 Trees Tree Id Persons Person Id Delete"
}
}
}
},
"422": {
"description": "Validation Error",
@@ -872,6 +936,251 @@
}
}
},
"/api/v1/trees/{tree_id}/persons/{person_id}/names": {
"get": {
"tags": [
"names"
],
"summary": "List Names",
"operationId": "list_names_api_v1_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 List Names Api V1 Trees Tree Id Persons Person Id Names Get"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
},
"post": {
"tags": [
"names"
],
"summary": "Create Name",
"operationId": "create_name_api_v1_trees__tree_id__persons__person_id__names_post",
"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"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NameCreate"
}
}
}
},
"responses": {
"201": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NameRead"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/v1/trees/{tree_id}/persons/{person_id}/names/{name_id}": {
"patch": {
"tags": [
"names"
],
"summary": "Update Name",
"operationId": "update_name_api_v1_trees__tree_id__persons__person_id__names__name_id__patch",
"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"
}
},
{
"name": "name_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid",
"title": "Name Id"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NameUpdate"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NameRead"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
},
"delete": {
"tags": [
"names"
],
"summary": "Delete Name",
"operationId": "delete_name_api_v1_trees__tree_id__persons__person_id__names__name_id__delete",
"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"
}
},
{
"name": "name_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid",
"title": "Name Id"
}
}
],
"responses": {
"204": {
"description": "Successful Response"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/v1/trees/{tree_id}/events": {
"post": {
"tags": [
@@ -3140,6 +3449,267 @@
"type": "object",
"title": "MediaUpdate"
},
"NameCreate": {
"properties": {
"name_type": {
"type": "string",
"title": "Name Type",
"default": "birth"
},
"given": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Given"
},
"surname": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Surname"
},
"prefix": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Prefix"
},
"suffix": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Suffix"
},
"nickname": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Nickname"
},
"is_primary": {
"type": "boolean",
"title": "Is Primary",
"default": false
}
},
"type": "object",
"title": "NameCreate"
},
"NameRead": {
"properties": {
"id": {
"type": "string",
"format": "uuid",
"title": "Id"
},
"tree_id": {
"type": "string",
"format": "uuid",
"title": "Tree Id"
},
"person_id": {
"type": "string",
"format": "uuid",
"title": "Person Id"
},
"name_type": {
"type": "string",
"title": "Name Type"
},
"given": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Given"
},
"surname": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Surname"
},
"prefix": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Prefix"
},
"suffix": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Suffix"
},
"nickname": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Nickname"
},
"is_primary": {
"type": "boolean",
"title": "Is Primary"
},
"sort_order": {
"type": "integer",
"title": "Sort Order"
},
"created_at": {
"type": "string",
"format": "date-time",
"title": "Created At"
}
},
"type": "object",
"required": [
"id",
"tree_id",
"person_id",
"name_type",
"given",
"surname",
"prefix",
"suffix",
"nickname",
"is_primary",
"sort_order",
"created_at"
],
"title": "NameRead"
},
"NameUpdate": {
"properties": {
"name_type": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Name Type"
},
"given": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Given"
},
"surname": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Surname"
},
"prefix": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Prefix"
},
"suffix": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Suffix"
},
"nickname": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Nickname"
},
"is_primary": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "null"
}
],
"title": "Is Primary"
}
},
"type": "object",
"title": "NameUpdate"
},
"ParentChildQualifier": {
"type": "string",
"enum": [
@@ -4063,6 +4633,18 @@
],
"title": "Email Verified At"
},
"self_person_id": {
"anyOf": [
{
"type": "string",
"format": "uuid"
},
{
"type": "null"
}
],
"title": "Self Person Id"
},
"created_at": {
"type": "string",
"format": "date-time",
@@ -4079,6 +4661,24 @@
],
"title": "UserRead"
},
"UserSelfPersonUpdate": {
"properties": {
"self_person_id": {
"anyOf": [
{
"type": "string",
"format": "uuid"
},
{
"type": "null"
}
],
"title": "Self Person Id"
}
},
"type": "object",
"title": "UserSelfPersonUpdate"
},
"ValidationError": {
"properties": {
"loc": {