5 Commits

Author SHA1 Message Date
justin cf5518c7ec Full-CRUD sweep: update endpoints for tree, source, citation, relationship, media
Closes the rule #8 gap at the API layer: PATCH endpoints + service updates for Tree (name/description/visibility), Source, Citation (page/detail/confidence), Relationship (qualifier/notes), and Media (title/attachment) — editor-gated and audited. Every core entity now has create/read/update/delete. Edit UIs for these land in the frontend batch. 37 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-07 09:53:17 -04:00
justin 26df03cfd7 Merge pull request 'Edit people + events; existing-person picker; full-CRUD rule' (#17) from crud-edits into main
build-backend / build (push) Successful in 27s
build-frontend / build (push) Successful in 1m25s
2026-06-07 09:35:56 -04:00
justin ab064bce6e Edit UI for people and life events; existing-person picker in family view
Person detail: an Edit form for name + gender + living status + privacy, and inline edit of each life event (type + structured date). Family view: the add-relative buttons now search existing people (link the real person) or create new — preventing duplicate spouses/parents — and adding a child to someone with one spouse links both parents.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-07 09:35:55 -04:00
justin 76b7f453c1 Add update (CRUD) for events and people; record the full-CRUD invariant
Events and people are now editable, not write-once: PATCH /events/{id} (type, structured date, place, notes) and PATCH /persons/{id} (vitals, privacy, and the primary name's given/surname). CLAUDE.md gains rule #8: every stored object must support full CRUD in API and UI — historical research is constant correction. Tests cover both updates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-07 09:35:55 -04:00
justin 438d2db2e7 Merge pull request 'Tree layout toggles + fan + card->profile + server search' (#16) from phase2-tree-toggles into main
build-frontend / build (push) Successful in 1m26s
2026-06-07 08:01:32 -04:00
28 changed files with 1300 additions and 83 deletions
+1
View File
@@ -19,6 +19,7 @@ These are product invariants, not preferences. Do not violate them, and flag any
5. **Sources are first-class.** Don't model citations as free-text afterthoughts. A `Source` is a reusable entity; a `Citation` links it to a specific fact.
6. **Only legal data sources.** Ship scrapers/connectors only for permissible sources (FamilySearch API, Find A Grave, WikiTree, BLM/GLO, USGS, public-domain newspapers, public county records). Never add connectors for paywalled/terms-prohibited sites (Ancestry, MyHeritage, 23andMe).
7. **Everything is configurable via environment.** Auth, mail, object storage, database, model providers, scrapers — all twelve-factor. No hard-coded endpoints or keys.
8. **Full CRUD on every object.** Every stored entity (person, name, event, relationship, source, citation, media, tree, …) must support create, read, **update**, and delete — in the API *and* the UI. Historical research is constant correction and new information, so nothing is write-once. Any new feature or data type ships with all four operations; an entity you can create but not edit is a bug.
## Tech stack
+20 -1
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep
from app.schemas.source import CitationCreate, CitationRead
from app.schemas.source import CitationCreate, CitationRead, CitationUpdate
from app.services import citation_service, tree_service
router = APIRouter(prefix="/trees", tags=["citations"])
@@ -31,6 +31,25 @@ async def list_citations(
return [CitationRead.model_validate(c) for c in citations]
@router.patch("/{tree_id}/citations/{citation_id}", response_model=CitationRead)
async def update_citation(
tree_id: uuid.UUID,
citation_id: uuid.UUID,
data: CitationUpdate,
session: SessionDep,
current: CurrentUser,
) -> CitationRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
citation = await citation_service.update_citation(
session,
actor=current,
tree=tree,
citation_id=citation_id,
changes=data.model_dump(exclude_unset=True),
)
return CitationRead.model_validate(citation)
@router.delete("/{tree_id}/citations/{citation_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_citation(
tree_id: uuid.UUID, citation_id: uuid.UUID, session: SessionDep, current: CurrentUser
+20 -1
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep
from app.schemas.event import EventCreate, EventRead
from app.schemas.event import EventCreate, EventRead, EventUpdate
from app.services import event_service, tree_service
router = APIRouter(prefix="/trees", tags=["events"])
@@ -40,6 +40,25 @@ async def list_person_events(
return [EventRead.model_validate(e) for e in events]
@router.patch("/{tree_id}/events/{event_id}", response_model=EventRead)
async def update_event(
tree_id: uuid.UUID,
event_id: uuid.UUID,
data: EventUpdate,
session: SessionDep,
current: CurrentUser,
) -> EventRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
event = await event_service.update_event(
session,
actor=current,
tree=tree,
event_id=event_id,
changes=data.model_dump(exclude_unset=True),
)
return EventRead.model_validate(event)
@router.delete("/{tree_id}/events/{event_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_event(
tree_id: uuid.UUID, event_id: uuid.UUID, session: SessionDep, current: CurrentUser
+21 -1
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, File, Form, Response, UploadFile, status
from app.api.deps import CurrentUser, ObjectStoreDep, SessionDep
from app.schemas.media import MediaRead
from app.schemas.media import MediaRead, MediaUpdate
from app.services import media_service, tree_service
@@ -81,6 +81,26 @@ async def media_content(
)
@router.patch("/{tree_id}/media/{media_id}", response_model=MediaRead)
async def update_media(
tree_id: uuid.UUID,
media_id: uuid.UUID,
data: MediaUpdate,
session: SessionDep,
current: CurrentUser,
store: ObjectStoreDep,
) -> MediaRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
media = await media_service.update_media(
session,
actor=current,
tree=tree,
media_id=media_id,
changes=data.model_dump(exclude_unset=True),
)
return _read(media)
@router.delete("/{tree_id}/media/{media_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_media(
tree_id: uuid.UUID, media_id: uuid.UUID, session: SessionDep, current: CurrentUser
+20 -1
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep
from app.schemas.person import PersonCreate, PersonRead
from app.schemas.person import PersonCreate, PersonRead, PersonUpdate
from app.services import person_service, tree_service
# Persons are nested under their tree (the tenant boundary).
@@ -56,6 +56,25 @@ async def list_persons(
return [PersonRead.model_validate(p) for p in persons]
@router.patch("/{tree_id}/persons/{person_id}", response_model=PersonRead)
async def update_person(
tree_id: uuid.UUID,
person_id: uuid.UUID,
data: PersonUpdate,
session: SessionDep,
current: CurrentUser,
) -> PersonRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
person = await person_service.update_person(
session,
actor=current,
tree=tree,
person_id=person_id,
changes=data.model_dump(exclude_unset=True),
)
return PersonRead.model_validate(person)
@router.delete("/{tree_id}/persons/{person_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_person(
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
+20 -1
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep
from app.schemas.relationship import RelationshipCreate, RelationshipRead
from app.schemas.relationship import RelationshipCreate, RelationshipRead, RelationshipUpdate
from app.services import relationship_service, tree_service
router = APIRouter(prefix="/trees", tags=["relationships"])
@@ -47,6 +47,25 @@ async def list_person_relationships(
return [RelationshipRead.model_validate(r) for r in rels]
@router.patch("/{tree_id}/relationships/{relationship_id}", response_model=RelationshipRead)
async def update_relationship(
tree_id: uuid.UUID,
relationship_id: uuid.UUID,
data: RelationshipUpdate,
session: SessionDep,
current: CurrentUser,
) -> RelationshipRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
rel = await relationship_service.update_relationship(
session,
actor=current,
tree=tree,
relationship_id=relationship_id,
changes=data.model_dump(exclude_unset=True),
)
return RelationshipRead.model_validate(rel)
@router.delete(
"/{tree_id}/relationships/{relationship_id}", status_code=status.HTTP_204_NO_CONTENT
)
+20 -1
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep
from app.schemas.source import SourceCreate, SourceRead
from app.schemas.source import SourceCreate, SourceRead, SourceUpdate
from app.services import source_service, tree_service
router = APIRouter(prefix="/trees", tags=["sources"])
@@ -40,6 +40,25 @@ async def get_source(
return SourceRead.model_validate(source)
@router.patch("/{tree_id}/sources/{source_id}", response_model=SourceRead)
async def update_source(
tree_id: uuid.UUID,
source_id: uuid.UUID,
data: SourceUpdate,
session: SessionDep,
current: CurrentUser,
) -> SourceRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
source = await source_service.update_source(
session,
actor=current,
tree=tree,
source_id=source_id,
changes=data.model_dump(exclude_unset=True),
)
return SourceRead.model_validate(source)
@router.delete("/{tree_id}/sources/{source_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_source(
tree_id: uuid.UUID, source_id: uuid.UUID, session: SessionDep, current: CurrentUser
+11 -1
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep
from app.schemas.tree import TreeCreate, TreeRead
from app.schemas.tree import TreeCreate, TreeRead, TreeUpdate
from app.services import tree_service
router = APIRouter(prefix="/trees", tags=["trees"])
@@ -38,6 +38,16 @@ async def get_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
return TreeRead.model_validate(tree)
@router.patch("/{tree_id}", response_model=TreeRead)
async def update_tree(
tree_id: uuid.UUID, data: TreeUpdate, session: SessionDep, current: CurrentUser
) -> TreeRead:
tree = await tree_service.update_tree(
session, actor=current, tree_id=tree_id, changes=data.model_dump(exclude_unset=True)
)
return TreeRead.model_validate(tree)
@router.delete("/{tree_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> None:
await tree_service.delete_tree(session, actor=current, tree_id=tree_id)
+13
View File
@@ -20,6 +20,19 @@ class EventCreate(BaseModel):
notes: str | None = None
class EventUpdate(BaseModel):
# All optional; only fields explicitly sent are changed (PATCH semantics).
event_type: str | None = None
place_id: uuid.UUID | None = None
date_value: str | None = None
date_start: date | None = None
date_end: date | None = None
date_precision: str | None = None
calendar: str | None = None
detail: str | None = None
notes: str | None = None
class EventRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
+7
View File
@@ -4,6 +4,13 @@ from datetime import datetime
from pydantic import BaseModel, ConfigDict
class MediaUpdate(BaseModel):
title: str | None = None
person_id: uuid.UUID | None = None
event_id: uuid.UUID | None = None
source_id: uuid.UUID | None = None
class MediaRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
+10
View File
@@ -15,6 +15,16 @@ class PersonCreate(BaseModel):
notes: str | None = None
class PersonUpdate(BaseModel):
# Person fields + the primary name's parts; only sent fields are changed.
given: str | None = None
surname: str | None = None
gender: str | None = None
is_living: bool | None = None
privacy: PersonPrivacy | None = None
notes: str | None = None
class PersonRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
+5
View File
@@ -15,6 +15,11 @@ class RelationshipCreate(BaseModel):
notes: str | None = None
class RelationshipUpdate(BaseModel):
qualifier: ParentChildQualifier | None = None
notes: str | None = None
class RelationshipRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
+17
View File
@@ -33,6 +33,23 @@ class SourceRead(BaseModel):
created_at: datetime
class SourceUpdate(BaseModel):
title: str | None = None
author: str | None = None
source_type: str | None = None
repository: str | None = None
url: str | None = None
citation_text: str | None = None
publication_info: str | None = None
quality_note: str | None = None
class CitationUpdate(BaseModel):
page: str | None = None
detail: str | None = None
confidence: CitationConfidence | None = None
class CitationCreate(BaseModel):
source_id: uuid.UUID
# Exactly one target fact.
+6
View File
@@ -12,6 +12,12 @@ class TreeCreate(BaseModel):
visibility: TreeVisibility = TreeVisibility.private
class TreeUpdate(BaseModel):
name: str | None = None
description: str | None = None
visibility: TreeVisibility | None = None
class TreeRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
+32
View File
@@ -113,6 +113,38 @@ async def list_citations(
return list((await session.execute(stmt)).scalars().all())
async def update_citation(
session: AsyncSession, *, actor: User, tree: Tree, citation_id: uuid.UUID, changes: dict
) -> Citation:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
citation = (
await session.execute(
select(Citation).where(
Citation.id == citation_id,
Citation.tree_id == tree.id,
Citation.deleted_at.is_(None),
)
)
).scalar_one_or_none()
if citation is None:
raise NotFound("citation not found")
for key in {"page", "detail", "confidence"} & changes.keys():
setattr(citation, key, changes[key])
record_audit(
session,
action="update",
entity_type="Citation",
entity_id=citation.id,
tree_id=tree.id,
actor_user_id=actor.id,
after=changes,
)
await session.commit()
await session.refresh(citation)
return citation
async def delete_citation(
session: AsyncSession, *, actor: User, tree: Tree, citation_id: uuid.UUID
) -> None:
+38
View File
@@ -122,6 +122,44 @@ async def list_events_for_person(
return list((await session.execute(stmt)).scalars().all())
async def update_event(
session: AsyncSession,
*,
actor: User,
tree: Tree,
event_id: uuid.UUID,
changes: dict,
) -> Event:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
event = (
await session.execute(
select(Event).where(
Event.id == event_id, Event.tree_id == tree.id, Event.deleted_at.is_(None)
)
)
).scalar_one_or_none()
if event is None:
raise NotFound("event not found")
if "place_id" in changes and changes["place_id"] is not None:
if not await _belongs_to_tree(session, Place, changes["place_id"], tree.id):
raise NotFound("place not found in this tree")
for key, value in changes.items():
setattr(event, key, value)
record_audit(
session,
action="update",
entity_type="Event",
entity_id=event.id,
tree_id=tree.id,
actor_user_id=actor.id,
after=changes,
)
await session.commit()
await session.refresh(event)
return event
async def delete_event(
session: AsyncSession, *, actor: User, tree: Tree, event_id: uuid.UUID
) -> None:
+30
View File
@@ -97,6 +97,36 @@ async def get_media(
return media
async def update_media(
session: AsyncSession, *, actor: User, tree: Tree, media_id: uuid.UUID, changes: dict
) -> Media:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
media = (
await session.execute(
select(Media).where(
Media.id == media_id, Media.tree_id == tree.id, Media.deleted_at.is_(None)
)
)
).scalar_one_or_none()
if media is None:
raise NotFound("media not found")
for key in {"title", "person_id", "event_id", "source_id"} & changes.keys():
setattr(media, key, changes[key])
record_audit(
session,
action="update",
entity_type="Media",
entity_id=media.id,
tree_id=tree.id,
actor_user_id=actor.id,
after=changes,
)
await session.commit()
await session.refresh(media)
return media
async def delete_media(
session: AsyncSession, *, actor: User, tree: Tree, media_id: uuid.UUID
) -> None:
+53
View File
@@ -95,6 +95,59 @@ async def create_person(
return person
_PERSON_FIELDS = {"gender", "is_living", "privacy", "notes"}
async def update_person(
session: AsyncSession, *, actor: User, tree: Tree, person_id: uuid.UUID, changes: dict
) -> Person:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
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")
for key in _PERSON_FIELDS & changes.keys():
setattr(person, key, changes[key])
if "given" in changes or "surname" in changes:
name = (
await session.execute(
select(Name)
.where(Name.person_id == person.id, Name.deleted_at.is_(None))
.order_by(Name.is_primary.desc(), Name.sort_order)
)
).scalars().first()
if name is None:
name = Name(tree_id=tree.id, person_id=person.id, name_type="birth", is_primary=True)
session.add(name)
if "given" in changes:
name.given = changes["given"]
if "surname" in changes:
name.surname = changes["surname"]
name.display_name = None # rebuild display from parts
record_audit(
session,
action="update",
entity_type="Person",
entity_id=person.id,
tree_id=tree.id,
actor_user_id=actor.id,
after=changes,
)
await session.commit()
await session.refresh(person)
await _attach_primary_name(session, person)
return person
async def get_person(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID
) -> Person:
@@ -107,6 +107,44 @@ async def list_relationships_for_person(
return list((await session.execute(stmt)).scalars().all())
async def update_relationship(
session: AsyncSession, *, actor: User, tree: Tree, relationship_id: uuid.UUID, changes: dict
) -> Relationship:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
relationship = (
await session.execute(
select(Relationship).where(
Relationship.id == relationship_id,
Relationship.tree_id == tree.id,
Relationship.deleted_at.is_(None),
)
)
).scalar_one_or_none()
if relationship is None:
raise NotFound("relationship not found")
if (
"qualifier" in changes
and changes["qualifier"] is not None
and relationship.type is not RelationshipType.parent_child
):
raise Conflict("qualifier only applies to parent_child relationships")
for key in {"qualifier", "notes"} & changes.keys():
setattr(relationship, key, changes[key])
record_audit(
session,
action="update",
entity_type="Relationship",
entity_id=relationship.id,
tree_id=tree.id,
actor_user_id=actor.id,
after=changes,
)
await session.commit()
await session.refresh(relationship)
return relationship
async def delete_relationship(
session: AsyncSession, *, actor: User, tree: Tree, relationship_id: uuid.UUID
) -> None:
+36
View File
@@ -86,6 +86,42 @@ async def get_source(
return source
_SOURCE_FIELDS = {
"title", "author", "source_type", "repository", "url", "citation_text",
"publication_info", "quality_note",
}
async def update_source(
session: AsyncSession, *, actor: User, tree: Tree, source_id: uuid.UUID, changes: dict
) -> Source:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
source = (
await session.execute(
select(Source).where(
Source.id == source_id, Source.tree_id == tree.id, Source.deleted_at.is_(None)
)
)
).scalar_one_or_none()
if source is None:
raise NotFound("source not found")
for key in _SOURCE_FIELDS & changes.keys():
setattr(source, key, changes[key])
record_audit(
session,
action="update",
entity_type="Source",
entity_id=source.id,
tree_id=tree.id,
actor_user_id=actor.id,
after=changes,
)
await session.commit()
await session.refresh(source)
return source
async def delete_source(
session: AsyncSession, *, actor: User, tree: Tree, source_id: uuid.UUID
) -> None:
+24
View File
@@ -62,6 +62,30 @@ async def get_tree(session: AsyncSession, *, viewer_id: uuid.UUID, tree_id: uuid
return tree
async def update_tree(
session: AsyncSession, *, actor: User, tree_id: uuid.UUID, changes: dict
) -> Tree:
tree = await BaseRepository(session, Tree).get(tree_id)
if tree is None:
raise NotFound("tree not found")
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
for key in {"name", "description", "visibility"} & changes.keys():
setattr(tree, key, changes[key])
record_audit(
session,
action="update",
entity_type="Tree",
entity_id=tree.id,
tree_id=tree.id,
actor_user_id=actor.id,
after=changes,
)
await session.commit()
await session.refresh(tree)
return tree
async def _owned_tree(session: AsyncSession, *, actor: User, tree_id: uuid.UUID) -> Tree:
"""Load a tree (including soft-deleted) and require the actor be its owner."""
tree = await BaseRepository(session, Tree).get(tree_id, include_deleted=True)
+19
View File
@@ -68,6 +68,25 @@ async def test_public_tree_viewable_but_not_editable_by_non_member(client):
assert resp.status_code == 403
async def test_person_update(client):
token = await register(client, "edit@example.com")
h = auth(token)
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": "Jon", "surname": "Smith"}, headers=h
)
).json()["id"]
resp = await client.patch(
f"/api/v1/trees/{tid}/persons/{pid}",
json={"given": "John", "gender": "male"},
headers=auth(token),
)
assert resp.status_code == 200, resp.text
assert resp.json()["primary_name"] == "John Smith"
assert resp.json()["gender"] == "male"
async def test_auth_required_without_token(client):
resp = await client.get("/api/v1/trees")
assert resp.status_code == 401
+79
View File
@@ -0,0 +1,79 @@
"""Update (the U in CRUD) for the remaining entities — rule #8."""
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 test_tree_update(client):
h, tid = await _setup(client, "u-tree@example.com")
r = await client.patch(
f"/api/v1/trees/{tid}", json={"name": "Renamed", "visibility": "unlisted"}, headers=h
)
assert r.status_code == 200
assert r.json()["name"] == "Renamed" and r.json()["visibility"] == "unlisted"
async def test_source_update(client):
h, tid = await _setup(client, "u-src@example.com")
sid = (
await client.post(f"/api/v1/trees/{tid}/sources", json={"title": "Old"}, headers=h)
).json()["id"]
r = await client.patch(
f"/api/v1/trees/{tid}/sources/{sid}",
json={"title": "New", "repository": "NARA"},
headers=h,
)
assert r.status_code == 200
assert r.json()["title"] == "New" and r.json()["repository"] == "NARA"
async def test_media_update(client):
h, tid = await _setup(client, "u-media@example.com")
mid = (
await client.post(
f"/api/v1/trees/{tid}/media",
files={"file": ("a.txt", b"x", "text/plain")},
data={"title": "old"},
headers=h,
)
).json()["id"]
r = await client.patch(f"/api/v1/trees/{tid}/media/{mid}", json={"title": "new"}, headers=h)
assert r.status_code == 200 and r.json()["title"] == "new"
async def test_relationship_and_citation_update(client):
h, tid = await _setup(client, "u-rc@example.com")
async def mk(path, body):
return (await client.post(f"/api/v1/trees/{tid}/{path}", json=body, headers=h)).json()["id"]
p1 = await mk("persons", {"given": "A"})
p2 = await mk("persons", {"given": "B"})
rid = await mk(
"relationships",
{
"type": "parent_child",
"person_from_id": p1,
"person_to_id": p2,
"qualifier": "biological",
},
)
r = await client.patch(
f"/api/v1/trees/{tid}/relationships/{rid}", json={"qualifier": "adoptive"}, headers=h
)
assert r.status_code == 200 and r.json()["qualifier"] == "adoptive"
src = await mk("sources", {"title": "S"})
cid = await mk("citations", {"source_id": src, "person_id": p1})
r2 = await client.patch(
f"/api/v1/trees/{tid}/citations/{cid}",
json={"page": "p.7", "confidence": "high"},
headers=h,
)
assert r2.status_code == 200
assert r2.json()["page"] == "p.7" and r2.json()["confidence"] == "high"
+19
View File
@@ -48,6 +48,25 @@ async def test_event_create_list_delete(client):
assert len(listed.json()) == 0
async def test_event_update(client):
h, tree_id, parent, _ = await _setup_tree_with_two_people(client, "evupd@example.com")
eid = (
await client.post(
f"/api/v1/trees/{tree_id}/events",
json={"event_type": "birth", "person_id": parent, "date_value": "1850"},
headers=h,
)
).json()["id"]
resp = await client.patch(
f"/api/v1/trees/{tree_id}/events/{eid}",
json={"date_value": "ABT 1851", "event_type": "baptism"},
headers=h,
)
assert resp.status_code == 200, resp.text
assert resp.json()["date_value"] == "ABT 1851"
assert resp.json()["event_type"] == "baptism"
async def test_event_requires_exactly_one_subject(client):
h, tree_id, _, _ = await _setup_tree_with_two_people(client, "ev2@example.com")
resp = await client.post(
+65 -27
View File
@@ -122,23 +122,42 @@ export default function FamilyViewPage() {
load();
}
async function postRel(body: components["schemas"]["RelationshipCreate"]) {
await api.POST("/api/v1/trees/{tree_id}/relationships", {
params: { path: { tree_id: treeId } },
body,
});
}
// Create the relationship(s) connecting an (existing or new) person to anchor.
async function createLink(kind: AddKind, anchor: string, personId: string) {
if (kind === "parent") {
await postRel({ type: "parent_child", person_from_id: personId, person_to_id: anchor, qualifier: "biological" });
} else if (kind === "partner") {
await postRel({ type: "partnership", person_from_id: anchor, person_to_id: personId });
} else {
// child: link to anchor, and to anchor's spouse too (so both parents show)
await postRel({ type: "parent_child", person_from_id: anchor, person_to_id: personId, qualifier: "biological" });
const partners = partnersOf(anchor);
if (partners.length === 1) {
await postRel({ type: "parent_child", person_from_id: partners[0], person_to_id: personId, qualifier: "biological" });
}
}
}
async function linkExisting(personId: string) {
if (!adding) return;
await createLink(adding.kind, adding.anchor, personId);
setAdding(null);
setAddName("");
load();
}
async function submitAdd(e: React.FormEvent) {
e.preventDefault();
if (!adding || !addName.trim()) return;
const newId = await addPerson(addName);
if (newId) {
const { kind, anchor } = adding;
const body =
kind === "parent"
? { type: "parent_child" as const, person_from_id: newId, person_to_id: anchor, qualifier: "biological" as const }
: kind === "child"
? { type: "parent_child" as const, person_from_id: anchor, person_to_id: newId, qualifier: "biological" as const }
: { type: "partnership" as const, person_from_id: anchor, person_to_id: newId };
await api.POST("/api/v1/trees/{tree_id}/relationships", {
params: { path: { tree_id: treeId } },
body,
});
}
if (newId) await createLink(adding.kind, adding.anchor, newId);
setAdding(null);
setAddName("");
load();
@@ -210,26 +229,45 @@ export default function FamilyViewPage() {
label: string;
}) =>
adding?.key === formKey ? (
<form onSubmit={submitAdd} className="flex w-44 flex-col gap-1">
<form onSubmit={submitAdd} className="flex w-56 flex-col gap-1">
<Input
autoFocus
className="h-9"
placeholder="Full name"
placeholder="Search existing or type a new name"
value={addName}
onChange={(e) => setAddName(e.target.value)}
/>
<div className="flex gap-1">
<Button type="submit" size="sm">
Add
</Button>
<button
type="button"
onClick={() => setAdding(null)}
className="text-xs text-[var(--muted)]"
>
cancel
</button>
</div>
{addName.trim() && (
<div className="overflow-hidden rounded-md border border-[var(--border)] bg-[var(--surface)] text-sm">
{people
.filter(
(p) =>
p.id !== anchor &&
(p.primary_name ?? "").toLowerCase().includes(addName.trim().toLowerCase()),
)
.slice(0, 6)
.map((p) => (
<button
key={p.id}
type="button"
onClick={() => linkExisting(p.id)}
className="flex w-full items-center justify-between gap-2 px-2 py-1.5 text-left hover:bg-bronze/[0.07]"
>
<span className="truncate">{p.primary_name ?? "Unnamed"}</span>
<span className="shrink-0 text-xs text-[var(--muted)]">{years.get(p.id) ?? ""}</span>
</button>
))}
<button
type="submit"
className="flex w-full items-center gap-1 border-t border-[var(--border)] px-2 py-1.5 text-left text-bronze hover:bg-bronze/[0.07]"
>
+ Create new {addName.trim()}
</button>
</div>
)}
<button type="button" onClick={() => setAdding(null)} className="text-xs text-[var(--muted)]">
cancel
</button>
</form>
) : (
<button
@@ -33,6 +33,53 @@ const GED_MON = ["", "JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SE
const DATE_QUALS: Record<string, string> = { exact: "", about: "ABT", before: "BEF", after: "AFT" };
const pad = (n: number, len: number) => String(n).padStart(len, "0");
function composeDate(qual: string, day: string, month: string, year: string) {
const y = year.trim();
if (!y || Number.isNaN(Number(y))) {
return { date_value: null as string | null, date_start: null as string | null, date_precision: null as string | null };
}
const m = month ? Number(month) : null;
const d = day.trim() ? Number(day) : null;
const parts: string[] = [];
if (d && m) parts.push(String(d));
if (m) parts.push(GED_MON[m]);
parts.push(y);
const prefix = DATE_QUALS[qual];
return {
date_value: (prefix ? `${prefix} ` : "") + parts.join(" "),
date_start: `${pad(Number(y), 4)}-${pad(m ?? 1, 2)}-${pad(d ?? 1, 2)}`,
date_precision: qual,
};
}
// Parse a stored date_value (e.g. "ABT 12 MAR 1900") back into form fields.
function parseDateValue(v: string | null | undefined) {
let qual = "exact";
let day = "";
let month = "";
let year = "";
if (v) {
let s = v.trim();
const up = s.toUpperCase();
for (const [q, pre] of Object.entries(DATE_QUALS)) {
if (pre && up.startsWith(`${pre} `)) {
qual = q;
s = s.slice(pre.length + 1).trim();
break;
}
}
for (const t of s.toUpperCase().split(/\s+/).filter(Boolean)) {
if (/^\d{3,4}$/.test(t) && !year) year = t;
else if (/^\d{1,2}$/.test(t)) day = String(Number(t));
else {
const mi = GED_MON.indexOf(t);
if (mi > 0) month = String(mi);
}
}
}
return { qual, day, month, year };
}
export default function PersonDetailPage() {
const router = useRouter();
const params = useParams<{ id: string; personId: string }>();
@@ -54,6 +101,23 @@ export default function PersonDetailPage() {
const [dateMonth, setDateMonth] = useState("");
const [dateYear, setDateYear] = useState("");
// Inline edit-event form.
const [editId, setEditId] = useState<string | null>(null);
const [edType, setEdType] = useState("birth");
const [edTypeOther, setEdTypeOther] = useState("");
const [edQual, setEdQual] = useState("exact");
const [edDay, setEdDay] = useState("");
const [edMonth, setEdMonth] = useState("");
const [edYear, setEdYear] = useState("");
// Inline edit-person form (name + vitals).
const [editingPerson, setEditingPerson] = useState(false);
const [pGiven, setPGiven] = useState("");
const [pSurname, setPSurname] = useState("");
const [pGender, setPGender] = useState("");
const [pLiving, setPLiving] = useState("unknown");
const [pPrivacy, setPPrivacy] = useState<"inherit" | "private" | "public">("inherit");
const [relKind, setRelKind] = useState<"parent" | "child" | "partner" | "sibling">("parent");
const [relOther, setRelOther] = useState("");
const [relQual, setRelQual] = useState<Qualifier>("biological");
@@ -112,30 +176,16 @@ export default function PersonDetailPage() {
const eventCites = (id: string) => citations.filter((c) => c.event_id === id);
const personCites = citations.filter((c) => c.person_id === personId);
function buildDate() {
const year = dateYear.trim();
if (!year || Number.isNaN(Number(year))) {
return { date_value: null, date_start: null, date_precision: null };
}
const m = dateMonth ? Number(dateMonth) : null;
const d = dateDay.trim() ? Number(dateDay) : null;
const parts: string[] = [];
if (d && m) parts.push(String(d));
if (m) parts.push(GED_MON[m]);
parts.push(year);
const prefix = DATE_QUALS[dateQual];
return {
date_value: (prefix ? `${prefix} ` : "") + parts.join(" "),
date_start: `${pad(Number(year), 4)}-${pad(m ?? 1, 2)}-${pad(d ?? 1, 2)}`,
date_precision: dateQual,
};
}
async function addEvent(e: React.FormEvent) {
e.preventDefault();
const event_type = evType === "other" ? evTypeOther.trim() : evType;
if (!event_type) return;
const { date_value, date_start, date_precision } = buildDate();
const { date_value, date_start, date_precision } = composeDate(
dateQual,
dateDay,
dateMonth,
dateYear,
);
const { error } = await api.POST("/api/v1/trees/{tree_id}/events", {
params: { path: { tree_id: treeId } },
body: { event_type, person_id: personId, date_value, date_start, date_precision },
@@ -156,6 +206,33 @@ export default function PersonDetailPage() {
load();
}
function startEdit(ev: Event) {
setEditId(ev.id);
const known = EVENT_TYPES.includes(ev.event_type);
setEdType(known ? ev.event_type : "other");
setEdTypeOther(known ? "" : ev.event_type);
const parsed = parseDateValue(ev.date_value);
setEdQual(parsed.qual);
setEdDay(parsed.day);
setEdMonth(parsed.month);
setEdYear(parsed.year);
}
async function saveEdit() {
if (!editId) return;
const event_type = edType === "other" ? edTypeOther.trim() : edType;
if (!event_type) return;
const { date_value, date_start, date_precision } = composeDate(edQual, edDay, edMonth, edYear);
const { error } = await api.PATCH("/api/v1/trees/{tree_id}/events/{event_id}", {
params: { path: { tree_id: treeId, event_id: editId } },
body: { event_type, date_value, date_start, date_precision },
});
if (!error) {
setEditId(null);
load();
}
}
async function addRel(e: React.FormEvent) {
e.preventDefault();
if (!relOther) return;
@@ -213,6 +290,33 @@ export default function PersonDetailPage() {
router.push(`/trees/${treeId}`);
}
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] ?? ""));
setPSurname(t.length > 1 ? t[t.length - 1] : "");
setPGender(current.gender ?? "");
setPLiving(current.is_living === true ? "living" : current.is_living === false ? "deceased" : "unknown");
setPPrivacy((current.privacy as "inherit" | "private" | "public") ?? "inherit");
setEditingPerson(true);
}
async function savePerson() {
const { error } = await api.PATCH("/api/v1/trees/{tree_id}/persons/{person_id}", {
params: { path: { tree_id: treeId, person_id: personId } },
body: {
given: pGiven || null,
surname: pSurname || null,
gender: pGender || null,
is_living: pLiving === "living" ? true : pLiving === "deceased" ? false : null,
privacy: pPrivacy,
},
});
if (!error) {
setEditingPerson(false);
load();
}
}
if (!ready) return <p className="text-[var(--muted)]">Loading</p>;
if (!person) return <p className="text-[var(--muted)]">Not found.</p>;
@@ -316,15 +420,56 @@ export default function PersonDetailPage() {
Back to tree
</Link>
<div className="flex flex-wrap items-center justify-between gap-2">
<h1 className="text-3xl font-semibold">{person.primary_name ?? "Unnamed person"}</h1>
<div className="flex items-center gap-3">
{citeControl("p", { person_id: personId }, personCites)}
<Button variant="ghost" size="sm" onClick={removePerson}>
Delete
</Button>
{editingPerson ? (
<form
onSubmit={(e) => {
e.preventDefault();
savePerson();
}}
className="space-y-3 rounded-lg border border-[var(--border)] p-4"
>
<div className="flex flex-wrap gap-2">
<Input className="w-40" placeholder="Given name" value={pGiven} onChange={(e) => setPGiven(e.target.value)} />
<Input className="w-40" placeholder="Surname" value={pSurname} onChange={(e) => setPSurname(e.target.value)} />
<Input className="w-32" placeholder="Gender" value={pGender} onChange={(e) => setPGender(e.target.value)} />
<select className={fieldCls} value={pLiving} onChange={(e) => setPLiving(e.target.value)}>
<option value="unknown">Status: unknown</option>
<option value="living">Living</option>
<option value="deceased">Deceased</option>
</select>
<select
className={fieldCls}
value={pPrivacy}
onChange={(e) => setPPrivacy(e.target.value as "inherit" | "private" | "public")}
>
<option value="inherit">Privacy: default</option>
<option value="private">Private</option>
<option value="public">Public</option>
</select>
</div>
<div className="flex gap-2">
<Button type="submit" size="sm">
Save
</Button>
<button type="button" onClick={() => setEditingPerson(false)} className="text-xs text-[var(--muted)]">
cancel
</button>
</div>
</form>
) : (
<div className="flex flex-wrap items-center justify-between gap-2">
<h1 className="text-3xl font-semibold">{person.primary_name ?? "Unnamed person"}</h1>
<div className="flex items-center gap-3">
{citeControl("p", { person_id: personId }, personCites)}
<Button variant="outline" size="sm" onClick={() => startEditPerson(person)}>
Edit
</Button>
<Button variant="ghost" size="sm" onClick={removePerson}>
Delete
</Button>
</div>
</div>
</div>
)}
<Card>
<CardHeader>
@@ -335,26 +480,98 @@ export default function PersonDetailPage() {
<p className="text-sm text-[var(--muted)]">No events yet.</p>
) : (
<ul className="space-y-2">
{events.map((ev) => (
<li key={ev.id} className="flex flex-wrap items-center justify-between gap-2 text-sm">
<span>
<span className="font-medium capitalize">{ev.event_type}</span>
{ev.date_value ? (
<span className="text-[var(--muted)]"> {ev.date_value}</span>
) : null}
</span>
<span className="flex items-center gap-3">
{citeControl(`e:${ev.id}`, { event_id: ev.id }, eventCites(ev.id))}
<button
onClick={() => removeEvent(ev.id)}
className="text-[var(--muted)] hover:text-bronze"
aria-label="Remove"
{events.map((ev) =>
editId === ev.id ? (
<li key={ev.id}>
<form
onSubmit={(e) => {
e.preventDefault();
saveEdit();
}}
className="flex flex-wrap items-end gap-2"
>
×
</button>
</span>
</li>
))}
<select
className={`${fieldCls} capitalize`}
value={edType}
onChange={(e) => setEdType(e.target.value)}
>
{EVENT_TYPES.map((t) => (
<option key={t} value={t} className="capitalize">
{t}
</option>
))}
</select>
{edType === "other" && (
<Input
className="h-9 w-32"
placeholder="Custom"
value={edTypeOther}
onChange={(e) => setEdTypeOther(e.target.value)}
/>
)}
<select className={fieldCls} value={edQual} onChange={(e) => setEdQual(e.target.value)}>
<option value="exact">on</option>
<option value="about">about</option>
<option value="before">before</option>
<option value="after">after</option>
</select>
<input
className={`${fieldCls} w-14`}
inputMode="numeric"
placeholder="Day"
value={edDay}
onChange={(e) => setEdDay(e.target.value)}
/>
<select className={fieldCls} value={edMonth} onChange={(e) => setEdMonth(e.target.value)}>
<option value=""></option>
{MONTHS.map((m, i) => (i > 0 ? <option key={i} value={i}>{m}</option> : null))}
</select>
<input
className={`${fieldCls} w-20`}
inputMode="numeric"
placeholder="Year"
value={edYear}
onChange={(e) => setEdYear(e.target.value)}
/>
<Button type="submit" size="sm">
Save
</Button>
<button
type="button"
onClick={() => setEditId(null)}
className="text-xs text-[var(--muted)]"
>
cancel
</button>
</form>
</li>
) : (
<li key={ev.id} className="flex flex-wrap items-center justify-between gap-2 text-sm">
<span>
<span className="font-medium capitalize">{ev.event_type}</span>
{ev.date_value ? (
<span className="text-[var(--muted)]"> {ev.date_value}</span>
) : null}
</span>
<span className="flex items-center gap-3">
{citeControl(`e:${ev.id}`, { event_id: ev.id }, eventCites(ev.id))}
<button
onClick={() => startEdit(ev)}
className="text-xs text-bronze hover:underline"
>
edit
</button>
<button
onClick={() => removeEvent(ev.id)}
className="text-[var(--muted)] hover:text-bronze"
aria-label="Remove"
>
×
</button>
</span>
</li>
),
)}
</ul>
)}
<form onSubmit={addEvent} className="flex flex-wrap items-end gap-2">
+111 -2
View File
@@ -243,7 +243,8 @@ export interface paths {
delete: operations["delete_person_api_v1_trees__tree_id__persons__person_id__delete"];
options?: never;
head?: never;
patch?: never;
/** Update Person */
patch: operations["update_person_api_v1_trees__tree_id__persons__person_id__patch"];
trace?: never;
};
"/api/v1/trees/{tree_id}/persons/{person_id}/restore": {
@@ -312,7 +313,8 @@ export interface paths {
delete: operations["delete_event_api_v1_trees__tree_id__events__event_id__delete"];
options?: never;
head?: never;
patch?: never;
/** Update Event */
patch: operations["update_event_api_v1_trees__tree_id__events__event_id__patch"];
trace?: never;
};
"/api/v1/trees/{tree_id}/relationships": {
@@ -676,6 +678,27 @@ export interface components {
*/
created_at: string;
};
/** EventUpdate */
EventUpdate: {
/** Event Type */
event_type?: string | null;
/** Place Id */
place_id?: string | null;
/** Date Value */
date_value?: string | null;
/** Date Start */
date_start?: string | null;
/** Date End */
date_end?: string | null;
/** Date Precision */
date_precision?: string | null;
/** Calendar */
calendar?: string | null;
/** Detail */
detail?: string | null;
/** Notes */
notes?: string | null;
};
/** HTTPValidationError */
HTTPValidationError: {
/** Detail */
@@ -798,6 +821,20 @@ export interface components {
*/
created_at: string;
};
/** PersonUpdate */
PersonUpdate: {
/** Given */
given?: string | null;
/** Surname */
surname?: string | null;
/** Gender */
gender?: string | null;
/** Is Living */
is_living?: boolean | null;
privacy?: components["schemas"]["PersonPrivacy"] | null;
/** Notes */
notes?: string | null;
};
/** RegisterRequest */
RegisterRequest: {
/** Email */
@@ -1539,6 +1576,42 @@ export interface operations {
};
};
};
update_person_api_v1_trees__tree_id__persons__person_id__patch: {
parameters: {
query?: never;
header?: never;
path: {
tree_id: string;
person_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["PersonUpdate"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PersonRead"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
restore_person_api_v1_trees__tree_id__persons__person_id__restore_post: {
parameters: {
query?: never;
@@ -1699,6 +1772,42 @@ export interface operations {
};
};
};
update_event_api_v1_trees__tree_id__events__event_id__patch: {
parameters: {
query?: never;
header?: never;
path: {
tree_id: string;
event_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["EventUpdate"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["EventRead"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
list_relationships_api_v1_trees__tree_id__relationships_get: {
parameters: {
query?: never;
+301
View File
@@ -611,6 +611,67 @@
}
},
"/api/v1/trees/{tree_id}/persons/{person_id}": {
"patch": {
"tags": [
"persons"
],
"summary": "Update Person",
"operationId": "update_person_api_v1_trees__tree_id__persons__person_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"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PersonUpdate"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PersonRead"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
},
"delete": {
"tags": [
"persons"
@@ -916,6 +977,67 @@
}
},
"/api/v1/trees/{tree_id}/events/{event_id}": {
"patch": {
"tags": [
"events"
],
"summary": "Update Event",
"operationId": "update_event_api_v1_trees__tree_id__events__event_id__patch",
"parameters": [
{
"name": "tree_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid",
"title": "Tree Id"
}
},
{
"name": "event_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid",
"title": "Event Id"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/EventUpdate"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/EventRead"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
},
"delete": {
"tags": [
"events"
@@ -2361,6 +2483,114 @@
],
"title": "EventRead"
},
"EventUpdate": {
"properties": {
"event_type": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Event Type"
},
"place_id": {
"anyOf": [
{
"type": "string",
"format": "uuid"
},
{
"type": "null"
}
],
"title": "Place Id"
},
"date_value": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Date Value"
},
"date_start": {
"anyOf": [
{
"type": "string",
"format": "date"
},
{
"type": "null"
}
],
"title": "Date Start"
},
"date_end": {
"anyOf": [
{
"type": "string",
"format": "date"
},
{
"type": "null"
}
],
"title": "Date End"
},
"date_precision": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Date Precision"
},
"calendar": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Calendar"
},
"detail": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Detail"
},
"notes": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Notes"
}
},
"type": "object",
"title": "EventUpdate"
},
"HTTPValidationError": {
"properties": {
"detail": {
@@ -2709,6 +2939,77 @@
],
"title": "PersonRead"
},
"PersonUpdate": {
"properties": {
"given": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Given"
},
"surname": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Surname"
},
"gender": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Gender"
},
"is_living": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "null"
}
],
"title": "Is Living"
},
"privacy": {
"anyOf": [
{
"$ref": "#/components/schemas/PersonPrivacy"
},
{
"type": "null"
}
]
},
"notes": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Notes"
}
},
"type": "object",
"title": "PersonUpdate"
},
"RegisterRequest": {
"properties": {
"email": {