generated from john/python-template
V5.1 Modify Person table: split full name into first & last, added tags support
Quality Gate / gate (push) Failing after 11s
Quality Gate / gate (push) Failing after 11s
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
from .operations import create_all
|
||||
from .operations import reconcile_canonical_media_paths
|
||||
from .operations import reconcile_legacy_job_source_columns
|
||||
from .operations import reconcile_person_name_columns
|
||||
from .runtime import dispose_database_runtime
|
||||
from .runtime import initialize_database_runtime
|
||||
from .session import session_scope
|
||||
@@ -12,6 +13,7 @@ __all__ = [
|
||||
"initialize_database_runtime",
|
||||
"reconcile_canonical_media_paths",
|
||||
"reconcile_legacy_job_source_columns",
|
||||
"reconcile_person_name_columns",
|
||||
"session_scope",
|
||||
"transaction_scope",
|
||||
]
|
||||
|
||||
@@ -37,6 +37,7 @@ EXPORT_TABLE_ORDER = (
|
||||
"photo",
|
||||
"document_person",
|
||||
"document_tag",
|
||||
"person_tag",
|
||||
"job",
|
||||
"source",
|
||||
"job_source",
|
||||
@@ -86,9 +87,11 @@ def export_bundle(*, source_db_url: str, source_upload_dir: Path, bundle_dir: Pa
|
||||
|
||||
source_table = metadata.tables[table_name]
|
||||
target_table = current_metadata.tables[table_name]
|
||||
export_columns = [
|
||||
column.name for column in target_table.columns if column.name in source_table.columns
|
||||
]
|
||||
export_columns = [column.name for column in target_table.columns if column.name in source_table.columns]
|
||||
if table_name == "person" and "full_name" in source_table.columns:
|
||||
for legacy_column in ("full_name",):
|
||||
if legacy_column not in export_columns:
|
||||
export_columns.append(legacy_column)
|
||||
if table_name == "person" and "portrait_path" in source_table.columns:
|
||||
legacy_portrait_rows = connection.execute(
|
||||
select(source_table.c["id"], source_table.c["portrait_path"]).where(
|
||||
@@ -213,10 +216,27 @@ def _serialize_row(row: dict[str, Any], *, table_name: str, source_upload_dir: P
|
||||
preferred_prefix="photos/",
|
||||
)
|
||||
continue
|
||||
if table_name == "person" and key == "full_name" and isinstance(serialized_value, str):
|
||||
given_names, last_name = _split_legacy_full_name(serialized_value)
|
||||
serialized["given_names"] = given_names
|
||||
serialized["last_name"] = last_name
|
||||
continue
|
||||
serialized[key] = serialized_value
|
||||
if table_name == "person":
|
||||
serialized["given_names"] = str(serialized.get("given_names") or "").strip()
|
||||
serialized["last_name"] = str(serialized.get("last_name") or "").strip()
|
||||
return serialized
|
||||
|
||||
|
||||
def _split_legacy_full_name(full_name: str) -> tuple[str, str]:
|
||||
tokens = [token for token in full_name.strip().split() if token]
|
||||
if len(tokens) >= 2:
|
||||
return (" ".join(tokens[:-1]), tokens[-1])
|
||||
if len(tokens) == 1:
|
||||
return (tokens[0], tokens[0])
|
||||
return ("Unknown", "Unknown")
|
||||
|
||||
|
||||
def _serialize_value(key: str, value: Any) -> Any:
|
||||
if isinstance(value, UUID):
|
||||
return str(value)
|
||||
|
||||
@@ -139,6 +139,10 @@ class Tag(SQLModel, table=True):
|
||||
back_populates="tag_ref",
|
||||
sa_relationship_kwargs={"lazy": "raise"},
|
||||
)
|
||||
person_tags: list["PersonTag"] = Relationship(
|
||||
back_populates="tag_ref",
|
||||
sa_relationship_kwargs={"lazy": "raise"},
|
||||
)
|
||||
|
||||
|
||||
class Document(SQLModel, table=True):
|
||||
@@ -176,9 +180,8 @@ class Person(SQLModel, table=True):
|
||||
"""A historical person linked to one or more documents."""
|
||||
|
||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||
full_name: str
|
||||
display_name: str | None = None
|
||||
maiden_name: str | None = None
|
||||
last_name: str
|
||||
given_names: str
|
||||
birth_date: date | None = None
|
||||
birth_date_raw: str | None = None
|
||||
birth_place: str | None = None
|
||||
@@ -200,11 +203,20 @@ class Person(SQLModel, table=True):
|
||||
document_people: list["DocumentPerson"] = Relationship(
|
||||
back_populates="person", sa_relationship_kwargs={"lazy": "raise"}
|
||||
)
|
||||
person_tags: list["PersonTag"] = Relationship(
|
||||
back_populates="person",
|
||||
sa_relationship_kwargs={"lazy": "raise"},
|
||||
)
|
||||
photos: list["Photo"] = Relationship(
|
||||
back_populates="person",
|
||||
sa_relationship_kwargs={"lazy": "raise"},
|
||||
)
|
||||
|
||||
@property
|
||||
def full_name(self) -> str:
|
||||
"""Presentation-friendly combined name."""
|
||||
return f"{self.given_names} {self.last_name}".strip()
|
||||
|
||||
|
||||
class Photo(SQLModel, table=True):
|
||||
"""A reusable image record for Person and homepage galleries."""
|
||||
@@ -282,6 +294,32 @@ class DocumentTag(SQLModel, table=True):
|
||||
)
|
||||
|
||||
|
||||
class PersonTag(SQLModel, table=True):
|
||||
"""Associates People with Tags."""
|
||||
|
||||
__tablename__ = "person_tag"
|
||||
|
||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||
person_id: UUID = Field(foreign_key="person.id", index=True)
|
||||
tag_id: UUID = Field(foreign_key="tag.id", index=True)
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
updated_at: datetime = Field(
|
||||
default_factory=lambda: datetime.now(UTC),
|
||||
sa_column_kwargs={"onupdate": lambda: datetime.now(UTC)},
|
||||
)
|
||||
|
||||
__table_args__ = (UniqueConstraint("person_id", "tag_id", name="uq_person_tag"),)
|
||||
|
||||
person: Optional["Person"] = Relationship(
|
||||
back_populates="person_tags",
|
||||
sa_relationship_kwargs={"lazy": "raise"},
|
||||
)
|
||||
tag_ref: Optional["Tag"] = Relationship(
|
||||
back_populates="person_tags",
|
||||
sa_relationship_kwargs={"lazy": "raise"},
|
||||
)
|
||||
|
||||
|
||||
class Job(SQLModel, table=True):
|
||||
"""A transcription job tied to a single document."""
|
||||
|
||||
|
||||
@@ -107,6 +107,7 @@ async def reconcile_canonical_media_paths(*, engine: AsyncEngine | None = None)
|
||||
rows_changed += 1
|
||||
return rows_changed
|
||||
|
||||
|
||||
async with active_engine.begin() as connection:
|
||||
rows_changed = await connection.run_sync(_reconcile)
|
||||
if rows_changed:
|
||||
@@ -114,6 +115,62 @@ async def reconcile_canonical_media_paths(*, engine: AsyncEngine | None = None)
|
||||
return rows_changed
|
||||
|
||||
|
||||
async def reconcile_person_name_columns(*, engine: AsyncEngine | None = None) -> int:
|
||||
"""Backfill V5.1 Person name columns on existing databases."""
|
||||
active_engine = engine or resolve_engine()
|
||||
if not hasattr(active_engine, "begin"):
|
||||
return 0
|
||||
|
||||
def _reconcile(sync_connection) -> int:
|
||||
rows_changed = 0
|
||||
inspector = sqlalchemy_inspect(sync_connection)
|
||||
table_names = set(inspector.get_table_names())
|
||||
if "person" not in table_names:
|
||||
return 0
|
||||
present_columns = {column["name"] for column in inspector.get_columns("person")}
|
||||
if "last_name" not in present_columns:
|
||||
sync_connection.execute(text('alter table "person" add column "last_name" varchar'))
|
||||
if "given_names" not in present_columns:
|
||||
sync_connection.execute(text('alter table "person" add column "given_names" varchar'))
|
||||
|
||||
query = (
|
||||
text('select id, full_name, given_names, last_name from "person"')
|
||||
if "full_name" in present_columns
|
||||
else text('select id, null as full_name, given_names, last_name from "person"')
|
||||
)
|
||||
rows = sync_connection.execute(query).mappings().all()
|
||||
for row in rows:
|
||||
given_names = (str(row.get("given_names") or "")).strip()
|
||||
last_name = (str(row.get("last_name") or "")).strip()
|
||||
if given_names and last_name:
|
||||
continue
|
||||
tokens = [token for token in str(row.get("full_name") or "").split() if token]
|
||||
if len(tokens) >= 2:
|
||||
given_names, last_name = (" ".join(tokens[:-1]), tokens[-1])
|
||||
elif len(tokens) == 1:
|
||||
given_names = tokens[0]
|
||||
last_name = tokens[0]
|
||||
else:
|
||||
given_names = "Unknown"
|
||||
last_name = "Unknown"
|
||||
sync_connection.execute(
|
||||
text('update "person" set given_names = :given_names, last_name = :last_name where id = :id'),
|
||||
{
|
||||
"id": row["id"],
|
||||
"given_names": given_names,
|
||||
"last_name": last_name,
|
||||
},
|
||||
)
|
||||
rows_changed += 1
|
||||
return rows_changed
|
||||
|
||||
async with active_engine.begin() as connection:
|
||||
rows_changed = await connection.run_sync(_reconcile)
|
||||
if rows_changed:
|
||||
logger.warning("Backfilled V5.1 name columns for %s person row(s)", rows_changed)
|
||||
return rows_changed
|
||||
|
||||
|
||||
def _canonical_relative_path(value: str, *, preferred_prefix: str) -> str | None:
|
||||
normalized = value.strip().replace("\\", "/")
|
||||
if not normalized:
|
||||
|
||||
@@ -23,7 +23,9 @@ from ..db.models import Document
|
||||
from ..db.models import DocumentPerson
|
||||
from ..db.models import Photo
|
||||
from ..db.models import Person
|
||||
from ..db.models import PersonTag
|
||||
from ..db.models import PersonRole
|
||||
from ..db.models import Tag
|
||||
from ..errors import AppError
|
||||
from ..errors import ErrorCategory
|
||||
from .base import ServiceBase
|
||||
@@ -43,6 +45,10 @@ class PersonRoleError(PeopleError):
|
||||
"""Raised when Person Role maintenance fails."""
|
||||
|
||||
|
||||
class PersonTagError(PeopleError):
|
||||
"""Raised when Person tag maintenance fails."""
|
||||
|
||||
|
||||
class PersonRoleRegistry(RegistryService[PersonRole]):
|
||||
"""Person Role registry maintenance."""
|
||||
|
||||
@@ -79,6 +85,25 @@ def normalize_family_search_id(value: str | None) -> str | None:
|
||||
type PersonRoleSummary = RegistrySummary
|
||||
|
||||
|
||||
class PersonTagRegistry(RegistryService[Tag]):
|
||||
"""Tag registry maintenance for Person tag assignment."""
|
||||
|
||||
model = Tag
|
||||
error = PersonTagError
|
||||
noun = "Tag"
|
||||
short_noun = "tag"
|
||||
referenced_retainer = "historical People"
|
||||
|
||||
def reference_model(self) -> type[SQLModel]:
|
||||
return PersonTag
|
||||
|
||||
def reference_id_column(self) -> Any:
|
||||
return col(PersonTag.id)
|
||||
|
||||
def reference_key_column(self) -> Any:
|
||||
return col(PersonTag.tag_id)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DocumentPersonInput:
|
||||
"""Complete desired relationship for one Person on a Document."""
|
||||
@@ -97,6 +122,7 @@ class PeopleService(ServiceBase):
|
||||
) -> None:
|
||||
super().__init__(session_factory, settings)
|
||||
self._person_roles = PersonRoleRegistry(self.session_factory, self.settings)
|
||||
self._person_tags = PersonTagRegistry(self.session_factory, self.settings)
|
||||
|
||||
async def create_person(self, person: Person, *, session: AsyncSession | None = None) -> Person:
|
||||
async with self._session_scope(session) as _session:
|
||||
@@ -130,7 +156,7 @@ class PeopleService(ServiceBase):
|
||||
existing = await _session.get(
|
||||
Person,
|
||||
person.id,
|
||||
options=(selectinload(Person.document_people), selectinload(Person.photos)),
|
||||
options=(selectinload(Person.document_people), selectinload(Person.person_tags), selectinload(Person.photos)),
|
||||
)
|
||||
if existing is None:
|
||||
raise self._not_found(f"Person with id {person.id} not found")
|
||||
@@ -142,6 +168,8 @@ class PeopleService(ServiceBase):
|
||||
)
|
||||
for link in list(existing.document_people):
|
||||
await _session.delete(link)
|
||||
for link in list(existing.person_tags):
|
||||
await _session.delete(link)
|
||||
await _session.delete(existing)
|
||||
await self._finalize(session=_session, caller_session=session)
|
||||
|
||||
@@ -205,6 +233,7 @@ class PeopleService(ServiceBase):
|
||||
.selectinload(orm_attribute(DocumentPerson.document))
|
||||
.selectinload(orm_attribute(Document.sources)),
|
||||
selectinload(Person.document_people).selectinload(orm_attribute(DocumentPerson.role_ref)),
|
||||
selectinload(Person.person_tags).selectinload(orm_attribute(PersonTag.tag_ref)),
|
||||
selectinload(Person.photos),
|
||||
)
|
||||
.where(Person.id == person_id)
|
||||
@@ -217,9 +246,62 @@ class PeopleService(ServiceBase):
|
||||
|
||||
async def list_people(self, *, session: AsyncSession | None = None) -> Sequence[Person]:
|
||||
async with self._session_scope(session) as _session:
|
||||
query = select(Person).options(selectinload(Person.document_people))
|
||||
query = select(Person).options(
|
||||
selectinload(Person.document_people),
|
||||
selectinload(Person.person_tags).selectinload(orm_attribute(PersonTag.tag_ref)),
|
||||
)
|
||||
return (await _session.exec(query)).all()
|
||||
|
||||
async def sync_person_tags_by_labels(
|
||||
self,
|
||||
*,
|
||||
person_id: UUID,
|
||||
labels: Sequence[str],
|
||||
session: AsyncSession | None = None,
|
||||
) -> None:
|
||||
"""Replace a Person's tag set using label-based assignment."""
|
||||
normalized_labels = [self._person_tags.normalize_label(label) for label in labels]
|
||||
deduplicated_labels = list(dict.fromkeys(normalized_labels))
|
||||
label_keys = [self._person_tags.label_key(label) for label in deduplicated_labels]
|
||||
|
||||
async with self._session_scope(session) as _session:
|
||||
existing_person = await _session.get(Person, person_id)
|
||||
if existing_person is None:
|
||||
raise PeopleError(
|
||||
f"Person with id {person_id} not found",
|
||||
category=ErrorCategory.NOT_FOUND,
|
||||
suggestion="Refresh and select an existing person.",
|
||||
)
|
||||
|
||||
existing_tags = (
|
||||
(await _session.exec(select(Tag).where(col(Tag.normalized_label).in_(label_keys))))
|
||||
.all()
|
||||
if label_keys
|
||||
else []
|
||||
)
|
||||
tags_by_key = {tag.normalized_label: tag for tag in existing_tags}
|
||||
selected_tag_ids: set[UUID] = set()
|
||||
|
||||
for label in deduplicated_labels:
|
||||
key = self._person_tags.label_key(label)
|
||||
tag = tags_by_key.get(key)
|
||||
if tag is None:
|
||||
tag = await self._person_tags.create_entry(label=label, is_active=True, session=_session)
|
||||
tags_by_key[key] = tag
|
||||
selected_tag_ids.add(tag.id)
|
||||
|
||||
links = (await _session.exec(select(PersonTag).where(PersonTag.person_id == person_id))).all()
|
||||
existing_ids = {link.tag_id for link in links}
|
||||
|
||||
for link in links:
|
||||
if link.tag_id not in selected_tag_ids:
|
||||
await _session.delete(link)
|
||||
|
||||
for tag_id in selected_tag_ids - existing_ids:
|
||||
_session.add(PersonTag(person_id=person_id, tag_id=tag_id))
|
||||
|
||||
await self._finalize(session=_session, caller_session=session)
|
||||
|
||||
async def list_person_roles(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -14,6 +14,7 @@ from ..db.models import JobPurpose
|
||||
from ..db.models import JobSource
|
||||
from ..db.models import JobSourceStatus
|
||||
from ..db.models import JobStatus
|
||||
from ..db.models import Person
|
||||
from ..db.models import Source
|
||||
from ..db.session import transaction_scope
|
||||
from ..errors import AppError
|
||||
@@ -72,6 +73,32 @@ async def update_document_with_people(
|
||||
return updated
|
||||
|
||||
|
||||
async def create_person_with_tags(
|
||||
*,
|
||||
person: Person,
|
||||
tag_labels: list[str],
|
||||
people: PeopleService,
|
||||
) -> Person:
|
||||
"""Create a Person and its complete tag set atomically."""
|
||||
async with transaction_scope(session_factory=people.session_factory) as session:
|
||||
created = await people.create_person(person, session=session)
|
||||
await people.sync_person_tags_by_labels(person_id=created.id, labels=tag_labels, session=session)
|
||||
return created
|
||||
|
||||
|
||||
async def update_person_with_tags(
|
||||
*,
|
||||
person: Person,
|
||||
tag_labels: list[str],
|
||||
people: PeopleService,
|
||||
) -> Person:
|
||||
"""Update a Person and its complete tag set atomically."""
|
||||
async with transaction_scope(session_factory=people.session_factory) as session:
|
||||
updated = await people.update_person(person, session=session)
|
||||
await people.sync_person_tags_by_labels(person_id=updated.id, labels=tag_labels, session=session)
|
||||
return updated
|
||||
|
||||
|
||||
async def create_source_retranscription_job(
|
||||
*,
|
||||
source_id,
|
||||
|
||||
@@ -45,11 +45,7 @@ def compact_date(exact: date | None, approximate: str | None) -> str:
|
||||
|
||||
def person_selector_label(person: Person) -> str:
|
||||
"""Build a readable selector label without treating names as identity."""
|
||||
preferred = (person.display_name or "").strip()
|
||||
full_name = person.full_name.strip()
|
||||
label = preferred if not preferred or preferred == full_name else f"{preferred} - {full_name}"
|
||||
if not label:
|
||||
label = full_name
|
||||
label = person.full_name.strip()
|
||||
if person.birth_date is not None:
|
||||
return f"{label} ({person.birth_date.year})"
|
||||
approximate_year = YEAR_PATTERN.search(person.birth_date_raw or "")
|
||||
|
||||
@@ -19,7 +19,8 @@ class PersonTableRow:
|
||||
"""Read model consumed by the people table component."""
|
||||
|
||||
id: UUID
|
||||
full_name: str
|
||||
last_name: str
|
||||
given_names: str
|
||||
family_search_id: str
|
||||
birth_date: str
|
||||
death_date: str
|
||||
@@ -30,7 +31,8 @@ def _serialize_rows(rows: Sequence[PersonTableRow]) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"id": str(row.id),
|
||||
"full_name": row.full_name,
|
||||
"last_name": row.last_name,
|
||||
"given_names": row.given_names,
|
||||
"family_search_id": row.family_search_id or "Not set",
|
||||
"birth_date": row.birth_date or "Unknown",
|
||||
"death_date": row.death_date or "Unknown",
|
||||
@@ -51,9 +53,16 @@ def render_people_table(rows: Sequence[PersonTableRow]) -> None:
|
||||
rows=_serialize_rows(rows),
|
||||
columns=[
|
||||
{
|
||||
"name": "full_name",
|
||||
"label": "Full Name",
|
||||
"field": "full_name",
|
||||
"name": "last_name",
|
||||
"label": "Last Name",
|
||||
"field": "last_name",
|
||||
"sortable": True,
|
||||
"classes": "font-serif font-semibold text-left ui-table-cell-wrap",
|
||||
},
|
||||
{
|
||||
"name": "given_names",
|
||||
"label": "First & Middle",
|
||||
"field": "given_names",
|
||||
"sortable": True,
|
||||
"classes": "font-serif font-semibold text-left ui-table-cell-wrap",
|
||||
},
|
||||
@@ -83,14 +92,14 @@ def render_people_table(rows: Sequence[PersonTableRow]) -> None:
|
||||
"classes": "font-mono",
|
||||
},
|
||||
],
|
||||
default_sort_by="full_name",
|
||||
search_placeholder="Search people by name, FamilySearch ID, or dates...",
|
||||
default_sort_by="last_name",
|
||||
search_placeholder="Search people by last name, given names, FamilySearch ID, or dates...",
|
||||
on_row_click_id=lambda person_id: ui.navigate.to(f"/people/{person_id}"),
|
||||
)
|
||||
|
||||
# Custom column template adding an archival entity icon next to person's name
|
||||
table.add_slot(
|
||||
"body-cell-full_name",
|
||||
"body-cell-last_name",
|
||||
r"""
|
||||
<q-td :props="props">
|
||||
<div class="row items-center q-gutter-x-xs">
|
||||
|
||||
@@ -12,11 +12,14 @@ from nicegui import ui
|
||||
|
||||
from transcription.config import Settings
|
||||
from transcription.db.models import Person
|
||||
from transcription.services.documents import DocumentService
|
||||
from transcription.errors import ErrorCategory
|
||||
from transcription.services.people import PeopleError
|
||||
from transcription.services.people import PeopleService
|
||||
from transcription.services.photos import PhotoError
|
||||
from transcription.services.photos import PhotosService
|
||||
from transcription.services.workflows import create_person_with_tags
|
||||
from transcription.services.workflows import update_person_with_tags
|
||||
from transcription.ui.components.app_shell import render_navigation_header
|
||||
from transcription.ui.components.cards import archival_card
|
||||
from transcription.ui.components.confirm_delete import render_delete_actions
|
||||
@@ -49,9 +52,8 @@ from ...db.session import SessionFactoryDep
|
||||
class PersonFormFields:
|
||||
"""Bound input widgets for the Person create and edit forms."""
|
||||
|
||||
full_name: ui.input
|
||||
display_name: ui.input
|
||||
maiden_name: ui.input
|
||||
last_name: ui.input
|
||||
given_names: ui.input
|
||||
birth_date: ui.input
|
||||
birth_date_raw: ui.input
|
||||
birth_place: ui.input
|
||||
@@ -60,6 +62,7 @@ class PersonFormFields:
|
||||
death_place: ui.input
|
||||
biography: ui.textarea
|
||||
family_search_id: ui.input
|
||||
tags: ui.select
|
||||
|
||||
|
||||
def register_page() -> None: # noqa: PLR0915
|
||||
@@ -95,7 +98,8 @@ def register_page() -> None: # noqa: PLR0915
|
||||
rows = [
|
||||
PersonTableRow(
|
||||
id=person.id,
|
||||
full_name=person.full_name,
|
||||
last_name=person.last_name,
|
||||
given_names=person.given_names,
|
||||
family_search_id=person.family_search_id or "",
|
||||
birth_date=compact_date(person.birth_date, person.birth_date_raw),
|
||||
death_date=compact_date(person.death_date, person.death_date_raw),
|
||||
@@ -108,19 +112,23 @@ def register_page() -> None: # noqa: PLR0915
|
||||
@ui.page("/people/new")
|
||||
async def person_create_page(request: Request, session_factory: SessionFactoryDep) -> None:
|
||||
people_service = PeopleService(session_factory=session_factory)
|
||||
document_service = DocumentService(session_factory=session_factory)
|
||||
render_navigation_header(current_path="/people")
|
||||
draft_person_id = uuid4()
|
||||
|
||||
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
|
||||
page_header("Create Person Record", subtitle="Full name is required.")
|
||||
page_header("Create Person Record", subtitle="Last name and first/middle names are required.")
|
||||
tag_catalog = await document_service.list_tags(active_only=True)
|
||||
|
||||
form = _render_person_form_fields(
|
||||
tag_options=[tag.label for tag in tag_catalog],
|
||||
)
|
||||
|
||||
async def submit_create() -> None:
|
||||
full_name = (form.full_name.value or "").strip()
|
||||
if not full_name:
|
||||
ui.notify("Full name is required.", type="warning")
|
||||
last_name = (form.last_name.value or "").strip()
|
||||
given_names = (form.given_names.value or "").strip()
|
||||
if not last_name or not given_names:
|
||||
ui.notify("Last name and first/middle names are required.", type="warning")
|
||||
return
|
||||
|
||||
birth_date = parse_iso_date(form.birth_date.value)
|
||||
@@ -128,9 +136,8 @@ def register_page() -> None: # noqa: PLR0915
|
||||
|
||||
candidate = Person(
|
||||
id=draft_person_id,
|
||||
full_name=full_name,
|
||||
display_name=(form.display_name.value or "").strip() or None,
|
||||
maiden_name=(form.maiden_name.value or "").strip() or None,
|
||||
last_name=last_name,
|
||||
given_names=given_names,
|
||||
birth_date=birth_date,
|
||||
birth_date_raw=(form.birth_date_raw.value or "").strip() or None,
|
||||
birth_place=(form.birth_place.value or "").strip() or None,
|
||||
@@ -144,7 +151,11 @@ def register_page() -> None: # noqa: PLR0915
|
||||
create_outcome = await run_ui_action(
|
||||
operation="people.create",
|
||||
title="Create failed",
|
||||
action=lambda: people_service.create_person(candidate),
|
||||
action=lambda: create_person_with_tags(
|
||||
person=candidate,
|
||||
tag_labels=_resolve_selected_tag_labels(form.tags.value),
|
||||
people=people_service,
|
||||
),
|
||||
)
|
||||
if not create_outcome.ok or create_outcome.value is None:
|
||||
return
|
||||
@@ -350,6 +361,7 @@ def register_page() -> None: # noqa: PLR0915
|
||||
@ui.page("/people/{person_id}/edit")
|
||||
async def person_edit_page(person_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
|
||||
people_service = PeopleService(session_factory=session_factory)
|
||||
document_service = DocumentService(session_factory=session_factory)
|
||||
render_navigation_header(current_path="/people")
|
||||
|
||||
parsed_person_id = parsed_record_id(person_id, noun="Person")
|
||||
@@ -366,16 +378,19 @@ def register_page() -> None: # noqa: PLR0915
|
||||
return
|
||||
|
||||
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
|
||||
page_header("Edit Person Record", subtitle="Full name is required.")
|
||||
page_header("Edit Person Record", subtitle="Last name and first/middle names are required.")
|
||||
tag_catalog = await document_service.list_tags(active_only=False)
|
||||
|
||||
form = _render_person_form_fields(
|
||||
person=person,
|
||||
tag_options=[tag.label for tag in tag_catalog],
|
||||
)
|
||||
|
||||
async def submit_edit() -> None:
|
||||
full_name = (form.full_name.value or "").strip()
|
||||
if not full_name:
|
||||
ui.notify("Full name is required.", type="warning")
|
||||
last_name = (form.last_name.value or "").strip()
|
||||
given_names = (form.given_names.value or "").strip()
|
||||
if not last_name or not given_names:
|
||||
ui.notify("Last name and first/middle names are required.", type="warning")
|
||||
return
|
||||
|
||||
birth_date = parse_iso_date(form.birth_date.value)
|
||||
@@ -383,9 +398,8 @@ def register_page() -> None: # noqa: PLR0915
|
||||
|
||||
candidate = Person(
|
||||
id=person.id,
|
||||
full_name=full_name,
|
||||
display_name=(form.display_name.value or "").strip() or None,
|
||||
maiden_name=(form.maiden_name.value or "").strip() or None,
|
||||
last_name=last_name,
|
||||
given_names=given_names,
|
||||
birth_date=birth_date,
|
||||
birth_date_raw=(form.birth_date_raw.value or "").strip() or None,
|
||||
birth_place=(form.birth_place.value or "").strip() or None,
|
||||
@@ -402,7 +416,11 @@ def register_page() -> None: # noqa: PLR0915
|
||||
save_outcome = await run_ui_action(
|
||||
operation="people.edit.save",
|
||||
title="Save failed",
|
||||
action=lambda: people_service.update_person(candidate),
|
||||
action=lambda: update_person_with_tags(
|
||||
person=candidate,
|
||||
tag_labels=_resolve_selected_tag_labels(form.tags.value),
|
||||
people=people_service,
|
||||
),
|
||||
)
|
||||
if not save_outcome.ok:
|
||||
return
|
||||
@@ -479,21 +497,17 @@ def register_page() -> None: # noqa: PLR0915
|
||||
def _render_person_form_fields(
|
||||
*,
|
||||
person: Person | None = None,
|
||||
tag_options: list[str],
|
||||
) -> PersonFormFields:
|
||||
with archival_card(extra_classes="gap-3"):
|
||||
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
|
||||
full_name_input = (
|
||||
ui.input(label="Full name", value=person.full_name if person else "")
|
||||
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-2"):
|
||||
last_name_input = (
|
||||
ui.input(label="Last name", value=person.last_name if person else "")
|
||||
.props("outlined")
|
||||
.classes("ui-form-surface")
|
||||
)
|
||||
display_name_input = (
|
||||
ui.input(label="Display name", value=person.display_name if person and person.display_name else "")
|
||||
.props("outlined")
|
||||
.classes("ui-form-surface")
|
||||
)
|
||||
maiden_name_input = (
|
||||
ui.input(label="Maiden name", value=person.maiden_name if person and person.maiden_name else "")
|
||||
given_names_input = (
|
||||
ui.input(label="First & middle", value=person.given_names if person else "")
|
||||
.props("outlined")
|
||||
.classes("ui-form-surface")
|
||||
)
|
||||
@@ -558,11 +572,30 @@ def _render_person_form_fields(
|
||||
.props("outlined")
|
||||
.classes("w-full ui-form-surface")
|
||||
)
|
||||
selected_tags = (
|
||||
sorted(
|
||||
[
|
||||
link.tag_ref.label
|
||||
for link in (person.person_tags if person is not None else [])
|
||||
if link.tag_ref is not None
|
||||
],
|
||||
key=str.casefold,
|
||||
)
|
||||
if person is not None
|
||||
else []
|
||||
)
|
||||
tags_input = ui.select(
|
||||
sorted(tag_options, key=str.casefold),
|
||||
label="Tags",
|
||||
value=selected_tags,
|
||||
multiple=True,
|
||||
with_input=True,
|
||||
new_value_mode="add-unique",
|
||||
).props("outlined use-chips").classes("w-full ui-form-surface")
|
||||
|
||||
return PersonFormFields(
|
||||
full_name=full_name_input,
|
||||
display_name=display_name_input,
|
||||
maiden_name=maiden_name_input,
|
||||
last_name=last_name_input,
|
||||
given_names=given_names_input,
|
||||
birth_date=birth_date_input,
|
||||
birth_date_raw=birth_date_raw_input,
|
||||
birth_place=birth_place_input,
|
||||
@@ -571,6 +604,7 @@ def _render_person_form_fields(
|
||||
death_place=death_place_input,
|
||||
biography=biography_input,
|
||||
family_search_id=family_search_id_input,
|
||||
tags=tags_input,
|
||||
)
|
||||
|
||||
|
||||
@@ -653,10 +687,9 @@ def _render_photo_viewer_with_navigation(
|
||||
def _render_person_biographical_zone(person: Person) -> None:
|
||||
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
|
||||
with archival_card(title="Biographical Record"):
|
||||
metadata_row("Last Name:", person.last_name)
|
||||
metadata_row("First & Middle:", person.given_names)
|
||||
metadata_row("Full Name:", person.full_name)
|
||||
metadata_row("Display Name:", person.display_name or "Not set")
|
||||
if person.maiden_name:
|
||||
metadata_row("Maiden Name:", person.maiden_name)
|
||||
metadata_row("Birth Date:", compact_date(person.birth_date, person.birth_date_raw))
|
||||
if person.birth_place:
|
||||
metadata_link_row(
|
||||
@@ -681,6 +714,11 @@ def _render_person_biographical_zone(person: Person) -> None:
|
||||
person.family_search_id,
|
||||
family_search_url(person.family_search_id),
|
||||
)
|
||||
tags = sorted(
|
||||
[link.tag_ref.label for link in person.person_tags if link.tag_ref is not None],
|
||||
key=str.casefold,
|
||||
)
|
||||
metadata_row("Tags:", ", ".join(tags) if tags else "Not set")
|
||||
|
||||
with archival_card(title="System Logistics"):
|
||||
ui.label(f"Created: {person.created_at.isoformat()}").classes("text-[11px] ui-text-muted")
|
||||
@@ -763,3 +801,26 @@ def _render_linked_documents(person: Person) -> None:
|
||||
|
||||
|
||||
# --- Utilities ---
|
||||
|
||||
|
||||
def _resolve_selected_tag_labels(value: object) -> list[str]:
|
||||
def flatten(item: object) -> list[str]:
|
||||
if item is None:
|
||||
return []
|
||||
if isinstance(item, str):
|
||||
return [item]
|
||||
if isinstance(item, dict):
|
||||
if "value" in item:
|
||||
return flatten(item.get("value"))
|
||||
if "label" in item:
|
||||
return flatten(item.get("label"))
|
||||
return []
|
||||
if isinstance(item, (list, tuple, set)):
|
||||
values: list[str] = []
|
||||
for child in item:
|
||||
values.extend(flatten(child))
|
||||
return values
|
||||
return [str(item)]
|
||||
|
||||
labels = [candidate.strip() for candidate in flatten(value) if candidate and candidate.strip()]
|
||||
return list(dict.fromkeys(labels))
|
||||
|
||||
Reference in New Issue
Block a user