V5.1 Modify Person table: split full name into first & last, added tags support
Quality Gate / gate (push) Failing after 11s

This commit is contained in:
Jim Lancaster
2026-08-23 12:23:57 -05:00
parent 141ee1fa85
commit ae3483ec2e
28 changed files with 555 additions and 145 deletions
+2
View File
@@ -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",
]
+23 -3
View File
@@ -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)
+41 -3
View File
@@ -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."""
+57
View File
@@ -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:
+84 -2
View File
@@ -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,
*,
+27
View File
@@ -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">
+97 -36
View File
@@ -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))