Merge pull request 'Alternate names, self-person link, deletion integrity + dangling people' (#20) from names-deletion-self into main
This commit was merged in pull request #20.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
Vendored
+325
-4
@@ -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
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user