Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ab064bce6e | |||
| 76b7f453c1 | |||
| 438d2db2e7 |
@@ -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.
|
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).
|
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.
|
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
|
## Tech stack
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import uuid
|
|||||||
from fastapi import APIRouter, status
|
from fastapi import APIRouter, status
|
||||||
|
|
||||||
from app.api.deps import CurrentUser, SessionDep
|
from app.api.deps import CurrentUser, SessionDep
|
||||||
from app.schemas.event import EventCreate, EventRead
|
from app.schemas.event import EventCreate, EventRead, EventUpdate
|
||||||
from app.services import event_service, tree_service
|
from app.services import event_service, tree_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/trees", tags=["events"])
|
router = APIRouter(prefix="/trees", tags=["events"])
|
||||||
@@ -40,6 +40,25 @@ async def list_person_events(
|
|||||||
return [EventRead.model_validate(e) for e in 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)
|
@router.delete("/{tree_id}/events/{event_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def delete_event(
|
async def delete_event(
|
||||||
tree_id: uuid.UUID, event_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
tree_id: uuid.UUID, event_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import uuid
|
|||||||
from fastapi import APIRouter, status
|
from fastapi import APIRouter, status
|
||||||
|
|
||||||
from app.api.deps import CurrentUser, SessionDep
|
from app.api.deps import CurrentUser, SessionDep
|
||||||
from app.schemas.person import PersonCreate, PersonRead
|
from app.schemas.person import PersonCreate, PersonRead, PersonUpdate
|
||||||
from app.services import person_service, tree_service
|
from app.services import person_service, tree_service
|
||||||
|
|
||||||
# Persons are nested under their tree (the tenant boundary).
|
# 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]
|
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)
|
@router.delete("/{tree_id}/persons/{person_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def delete_person(
|
async def delete_person(
|
||||||
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
|||||||
@@ -20,6 +20,19 @@ class EventCreate(BaseModel):
|
|||||||
notes: str | None = None
|
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):
|
class EventRead(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,16 @@ class PersonCreate(BaseModel):
|
|||||||
notes: str | None = None
|
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):
|
class PersonRead(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|||||||
@@ -122,6 +122,44 @@ async def list_events_for_person(
|
|||||||
return list((await session.execute(stmt)).scalars().all())
|
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(
|
async def delete_event(
|
||||||
session: AsyncSession, *, actor: User, tree: Tree, event_id: uuid.UUID
|
session: AsyncSession, *, actor: User, tree: Tree, event_id: uuid.UUID
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -95,6 +95,59 @@ async def create_person(
|
|||||||
return 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(
|
async def get_person(
|
||||||
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID
|
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID
|
||||||
) -> Person:
|
) -> Person:
|
||||||
|
|||||||
@@ -68,6 +68,25 @@ async def test_public_tree_viewable_but_not_editable_by_non_member(client):
|
|||||||
assert resp.status_code == 403
|
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):
|
async def test_auth_required_without_token(client):
|
||||||
resp = await client.get("/api/v1/trees")
|
resp = await client.get("/api/v1/trees")
|
||||||
assert resp.status_code == 401
|
assert resp.status_code == 401
|
||||||
|
|||||||
@@ -48,6 +48,25 @@ async def test_event_create_list_delete(client):
|
|||||||
assert len(listed.json()) == 0
|
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):
|
async def test_event_requires_exactly_one_subject(client):
|
||||||
h, tree_id, _, _ = await _setup_tree_with_two_people(client, "ev2@example.com")
|
h, tree_id, _, _ = await _setup_tree_with_two_people(client, "ev2@example.com")
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
|
|||||||
@@ -122,23 +122,42 @@ export default function FamilyViewPage() {
|
|||||||
load();
|
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) {
|
async function submitAdd(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!adding || !addName.trim()) return;
|
if (!adding || !addName.trim()) return;
|
||||||
const newId = await addPerson(addName);
|
const newId = await addPerson(addName);
|
||||||
if (newId) {
|
if (newId) await createLink(adding.kind, adding.anchor, 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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setAdding(null);
|
setAdding(null);
|
||||||
setAddName("");
|
setAddName("");
|
||||||
load();
|
load();
|
||||||
@@ -210,26 +229,45 @@ export default function FamilyViewPage() {
|
|||||||
label: string;
|
label: string;
|
||||||
}) =>
|
}) =>
|
||||||
adding?.key === formKey ? (
|
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
|
<Input
|
||||||
autoFocus
|
autoFocus
|
||||||
className="h-9"
|
className="h-9"
|
||||||
placeholder="Full name"
|
placeholder="Search existing or type a new name"
|
||||||
value={addName}
|
value={addName}
|
||||||
onChange={(e) => setAddName(e.target.value)}
|
onChange={(e) => setAddName(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-1">
|
{addName.trim() && (
|
||||||
<Button type="submit" size="sm">
|
<div className="overflow-hidden rounded-md border border-[var(--border)] bg-[var(--surface)] text-sm">
|
||||||
Add
|
{people
|
||||||
</Button>
|
.filter(
|
||||||
<button
|
(p) =>
|
||||||
type="button"
|
p.id !== anchor &&
|
||||||
onClick={() => setAdding(null)}
|
(p.primary_name ?? "").toLowerCase().includes(addName.trim().toLowerCase()),
|
||||||
className="text-xs text-[var(--muted)]"
|
)
|
||||||
>
|
.slice(0, 6)
|
||||||
cancel
|
.map((p) => (
|
||||||
</button>
|
<button
|
||||||
</div>
|
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>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<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 DATE_QUALS: Record<string, string> = { exact: "", about: "ABT", before: "BEF", after: "AFT" };
|
||||||
const pad = (n: number, len: number) => String(n).padStart(len, "0");
|
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() {
|
export default function PersonDetailPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams<{ id: string; personId: string }>();
|
const params = useParams<{ id: string; personId: string }>();
|
||||||
@@ -54,6 +101,23 @@ export default function PersonDetailPage() {
|
|||||||
const [dateMonth, setDateMonth] = useState("");
|
const [dateMonth, setDateMonth] = useState("");
|
||||||
const [dateYear, setDateYear] = 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 [relKind, setRelKind] = useState<"parent" | "child" | "partner" | "sibling">("parent");
|
||||||
const [relOther, setRelOther] = useState("");
|
const [relOther, setRelOther] = useState("");
|
||||||
const [relQual, setRelQual] = useState<Qualifier>("biological");
|
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 eventCites = (id: string) => citations.filter((c) => c.event_id === id);
|
||||||
const personCites = citations.filter((c) => c.person_id === personId);
|
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) {
|
async function addEvent(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const event_type = evType === "other" ? evTypeOther.trim() : evType;
|
const event_type = evType === "other" ? evTypeOther.trim() : evType;
|
||||||
if (!event_type) return;
|
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", {
|
const { error } = await api.POST("/api/v1/trees/{tree_id}/events", {
|
||||||
params: { path: { tree_id: treeId } },
|
params: { path: { tree_id: treeId } },
|
||||||
body: { event_type, person_id: personId, date_value, date_start, date_precision },
|
body: { event_type, person_id: personId, date_value, date_start, date_precision },
|
||||||
@@ -156,6 +206,33 @@ export default function PersonDetailPage() {
|
|||||||
load();
|
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) {
|
async function addRel(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!relOther) return;
|
if (!relOther) return;
|
||||||
@@ -213,6 +290,33 @@ export default function PersonDetailPage() {
|
|||||||
router.push(`/trees/${treeId}`);
|
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 (!ready) return <p className="text-[var(--muted)]">Loading…</p>;
|
||||||
if (!person) return <p className="text-[var(--muted)]">Not found.</p>;
|
if (!person) return <p className="text-[var(--muted)]">Not found.</p>;
|
||||||
|
|
||||||
@@ -316,15 +420,56 @@ export default function PersonDetailPage() {
|
|||||||
← Back to tree
|
← Back to tree
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
{editingPerson ? (
|
||||||
<h1 className="text-3xl font-semibold">{person.primary_name ?? "Unnamed person"}</h1>
|
<form
|
||||||
<div className="flex items-center gap-3">
|
onSubmit={(e) => {
|
||||||
{citeControl("p", { person_id: personId }, personCites)}
|
e.preventDefault();
|
||||||
<Button variant="ghost" size="sm" onClick={removePerson}>
|
savePerson();
|
||||||
Delete
|
}}
|
||||||
</Button>
|
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>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -335,26 +480,98 @@ export default function PersonDetailPage() {
|
|||||||
<p className="text-sm text-[var(--muted)]">No events yet.</p>
|
<p className="text-sm text-[var(--muted)]">No events yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{events.map((ev) => (
|
{events.map((ev) =>
|
||||||
<li key={ev.id} className="flex flex-wrap items-center justify-between gap-2 text-sm">
|
editId === ev.id ? (
|
||||||
<span>
|
<li key={ev.id}>
|
||||||
<span className="font-medium capitalize">{ev.event_type}</span>
|
<form
|
||||||
{ev.date_value ? (
|
onSubmit={(e) => {
|
||||||
<span className="text-[var(--muted)]"> — {ev.date_value}</span>
|
e.preventDefault();
|
||||||
) : null}
|
saveEdit();
|
||||||
</span>
|
}}
|
||||||
<span className="flex items-center gap-3">
|
className="flex flex-wrap items-end gap-2"
|
||||||
{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"
|
|
||||||
>
|
>
|
||||||
×
|
<select
|
||||||
</button>
|
className={`${fieldCls} capitalize`}
|
||||||
</span>
|
value={edType}
|
||||||
</li>
|
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>
|
</ul>
|
||||||
)}
|
)}
|
||||||
<form onSubmit={addEvent} className="flex flex-wrap items-end gap-2">
|
<form onSubmit={addEvent} className="flex flex-wrap items-end gap-2">
|
||||||
|
|||||||
Vendored
+111
-2
@@ -243,7 +243,8 @@ export interface paths {
|
|||||||
delete: operations["delete_person_api_v1_trees__tree_id__persons__person_id__delete"];
|
delete: operations["delete_person_api_v1_trees__tree_id__persons__person_id__delete"];
|
||||||
options?: never;
|
options?: never;
|
||||||
head?: never;
|
head?: never;
|
||||||
patch?: never;
|
/** Update Person */
|
||||||
|
patch: operations["update_person_api_v1_trees__tree_id__persons__person_id__patch"];
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/api/v1/trees/{tree_id}/persons/{person_id}/restore": {
|
"/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"];
|
delete: operations["delete_event_api_v1_trees__tree_id__events__event_id__delete"];
|
||||||
options?: never;
|
options?: never;
|
||||||
head?: never;
|
head?: never;
|
||||||
patch?: never;
|
/** Update Event */
|
||||||
|
patch: operations["update_event_api_v1_trees__tree_id__events__event_id__patch"];
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/api/v1/trees/{tree_id}/relationships": {
|
"/api/v1/trees/{tree_id}/relationships": {
|
||||||
@@ -676,6 +678,27 @@ export interface components {
|
|||||||
*/
|
*/
|
||||||
created_at: string;
|
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 */
|
||||||
HTTPValidationError: {
|
HTTPValidationError: {
|
||||||
/** Detail */
|
/** Detail */
|
||||||
@@ -798,6 +821,20 @@ export interface components {
|
|||||||
*/
|
*/
|
||||||
created_at: string;
|
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 */
|
||||||
RegisterRequest: {
|
RegisterRequest: {
|
||||||
/** Email */
|
/** 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: {
|
restore_person_api_v1_trees__tree_id__persons__person_id__restore_post: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
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: {
|
list_relationships_api_v1_trees__tree_id__relationships_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -611,6 +611,67 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/v1/trees/{tree_id}/persons/{person_id}": {
|
"/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": {
|
"delete": {
|
||||||
"tags": [
|
"tags": [
|
||||||
"persons"
|
"persons"
|
||||||
@@ -916,6 +977,67 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/v1/trees/{tree_id}/events/{event_id}": {
|
"/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": {
|
"delete": {
|
||||||
"tags": [
|
"tags": [
|
||||||
"events"
|
"events"
|
||||||
@@ -2361,6 +2483,114 @@
|
|||||||
],
|
],
|
||||||
"title": "EventRead"
|
"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": {
|
"HTTPValidationError": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"detail": {
|
"detail": {
|
||||||
@@ -2709,6 +2939,77 @@
|
|||||||
],
|
],
|
||||||
"title": "PersonRead"
|
"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": {
|
"RegisterRequest": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"email": {
|
"email": {
|
||||||
|
|||||||
Reference in New Issue
Block a user