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
+4 -1
View File
@@ -37,7 +37,10 @@ def _seed_document_and_person(
async def _seed() -> tuple[UUID, UUID]:
async with session_scope(database_url=db_url) as session:
document = Document(name=document_name)
person = Person(full_name=person_name)
tokens = [token for token in person_name.split() if token]
given_names = " ".join(tokens[:-1]) if len(tokens) >= 2 else person_name
last_name = tokens[-1] if len(tokens) >= 2 else person_name
person = Person(given_names=given_names, last_name=last_name)
session.add(document)
session.add(person)
await session.commit()
+33 -9
View File
@@ -13,6 +13,7 @@ from transcription.db.models import DocumentPerson
from transcription.db.models import DocumentTag
from transcription.db.models import Job
from transcription.db.models import Person
from transcription.db.models import PersonTag
from transcription.db.models import Source
from transcription.db.models import Tag
from transcription.db.models import Photo
@@ -132,7 +133,7 @@ async def test_delete_document_removes_person_links(default_session_factory, tmp
name="person-linked-delete",
)
)
person = await people_service.create_person(Person(full_name="Linked Person"))
person = await people_service.create_person(Person(given_names="Linked", last_name="Person"))
author_role = await people_service.create_person_role(label="Author")
await people_service.create_document_person(
DocumentPerson(
@@ -197,7 +198,7 @@ async def test_read_person_detail_loads_document_links(default_session_factory):
name="linked-doc",
)
)
person = await people_service.create_person(Person(full_name="Linked Person"))
person = await people_service.create_person(Person(given_names="Linked", last_name="Person"))
author_role = await people_service.create_person_role(label="Author")
await people_service.create_document_person(
DocumentPerson(
@@ -221,16 +222,17 @@ async def test_update_person_refreshes_updated_timestamp(default_session_factory
created = await service.create_person(
Person(
full_name="timestamp-person",
given_names="timestamp",
last_name="person",
updated_at=datetime(2000, 1, 1, tzinfo=UTC),
)
)
original_updated_at = created.updated_at
created.display_name = "updated"
created.given_names = "updated"
updated = await service.update_person(created)
assert updated.display_name == "updated"
assert updated.given_names == "updated"
assert updated.updated_at >= original_updated_at
@@ -245,7 +247,7 @@ async def test_delete_person_removes_links_when_linked_documents_exist(default_s
name="block-person-delete-doc",
)
)
person = await service.create_person(Person(full_name="Blocked Person"))
person = await service.create_person(Person(given_names="Blocked", last_name="Person"))
author_role = await service.create_person_role(label="Author")
await service.create_document_person(
DocumentPerson(
@@ -268,7 +270,7 @@ async def test_delete_person_removes_links_when_linked_documents_exist(default_s
async def test_delete_person_succeeds_when_unlinked(default_session_factory):
service = PeopleService(session_factory=default_session_factory)
person = await service.create_person(Person(full_name="Free Person"))
person = await service.create_person(Person(given_names="Free", last_name="Person"))
await service.delete_person(person)
@@ -280,7 +282,7 @@ async def test_delete_person_succeeds_when_unlinked(default_session_factory):
async def test_delete_person_blocks_when_photos_exist(default_session_factory):
service = PeopleService(session_factory=default_session_factory)
person = await service.create_person(Person(full_name="Photo Protected Person"))
person = await service.create_person(Person(given_names="Photo Protected", last_name="Person"))
async with service._session_scope() as session:
session.add(
Photo(
@@ -313,7 +315,7 @@ async def test_update_document_person_changes_role_id(default_session_factory):
service = PeopleService(session_factory=default_session_factory)
document = await documents_service.create_document(Document(id=uuid4(), name="role-sync-doc"))
person = await service.create_person(Person(full_name="Role Sync Person"))
person = await service.create_person(Person(given_names="Role Sync", last_name="Person"))
author_role = await service.create_person_role(label="Author")
recipient_role = await service.create_person_role(label="Recipient")
link = await service.create_document_person(
@@ -357,3 +359,25 @@ async def test_sync_document_tags_by_labels_creates_and_replaces_tags(default_se
assert len(listed) == 1
listed_labels = {link.tag_ref.label for link in listed[0].document_tags if link.tag_ref is not None}
assert listed_labels == {"Census", "Research"}
@pytest.mark.asyncio
async def test_sync_person_tags_by_labels_creates_and_replaces_tags(default_session_factory):
people = PeopleService(session_factory=default_session_factory)
person = await people.create_person(Person(given_names="Tagged", last_name="Person"))
await people.sync_person_tags_by_labels(person_id=person.id, labels=["Family", "Census"])
await people.sync_person_tags_by_labels(person_id=person.id, labels=["Census", "Research"])
async with people._session_scope() as session:
links = (await session.exec(select(PersonTag).where(PersonTag.person_id == person.id))).all()
tags = (await session.exec(select(Tag))).all()
assert len(links) == 2
linked_ids = {link.tag_id for link in links}
linked_labels = {tag.label for tag in tags if tag.id in linked_ids}
assert linked_labels == {"Census", "Research"}
detail = await people.read_person_detail(person.id)
listed_labels = {link.tag_ref.label for link in detail.person_tags if link.tag_ref is not None}
assert listed_labels == {"Census", "Research"}
+2 -2
View File
@@ -18,7 +18,7 @@ async def test_create_photo_persists_media_and_primary_state(default_session_fac
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path / "uploads")
people = PeopleService(session_factory=default_session_factory)
photos = PhotosService(session_factory=default_session_factory, settings=settings)
person = await people.create_person(Person(full_name="Photo Person"))
person = await people.create_person(Person(given_names="Photo", last_name="Person"))
first = await photos.create_photo(person_id=person.id, filename="one.png", file_bytes=PNG_BYTES)
second = await photos.create_photo(person_id=person.id, filename="two.png", file_bytes=PNG_BYTES)
@@ -36,7 +36,7 @@ async def test_set_primary_and_delete_promotes_next_photo(default_session_factor
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path / "uploads")
people = PeopleService(session_factory=default_session_factory)
photos = PhotosService(session_factory=default_session_factory, settings=settings)
person = await people.create_person(Person(full_name="Primary Person"))
person = await people.create_person(Person(given_names="Primary", last_name="Person"))
first = await photos.create_photo(person_id=person.id, filename="one.png", file_bytes=PNG_BYTES)
second = await photos.create_photo(person_id=person.id, filename="two.png", file_bytes=PNG_BYTES)
+2 -2
View File
@@ -95,7 +95,7 @@ async def test_person_role_delete_allows_unreferenced_and_blocks_referenced(defa
unused = await people.create_person_role(label="Witness")
referenced = await people.create_person_role(label="Creator")
document = await documents.create_document(Document(name="Role document"))
person = await people.create_person(Person(full_name="Role Person"))
person = await people.create_person(Person(given_names="Role", last_name="Person"))
await people.create_document_person(
DocumentPerson(
document_id=document.id,
@@ -131,7 +131,7 @@ async def test_custom_person_role_can_be_used_for_document_link(default_session_
people = PeopleService(session_factory=default_session_factory)
role = await people.create_person_role(label="Witness")
document = await documents.create_document(Document(name="Witnessed document"))
person = await people.create_person(Person(full_name="Archive Witness"))
person = await people.create_person(Person(given_names="Archive", last_name="Witness"))
link = await people.add_document_person_link(
document_id=document.id,
+3 -2
View File
@@ -34,10 +34,11 @@ async def test_document_update_advances_updated_at(default_session_factory):
@pytest.mark.asyncio
async def test_person_update_advances_updated_at(default_session_factory):
people = PeopleService(session_factory=default_session_factory)
person = await people.create_person(Person(full_name="Grace Hopper"))
person = await people.create_person(Person(given_names="Grace", last_name="Hopper"))
original = person.updated_at
person.full_name = "Rear Adm. Grace Hopper"
person.given_names = "Rear Adm. Grace"
person.last_name = "Hopper"
updated = await people.update_person(person)
assert updated.updated_at > original
+5 -5
View File
@@ -28,7 +28,7 @@ async def test_people_service_handles_person_and_document_person_crud(default_se
people_service = PeopleService(session_factory=default_session_factory)
document = await documents.create_document(Document(id=uuid4(), name="person-doc"))
person = await people_service.create_person(Person(full_name="Ada Lovelace"))
person = await people_service.create_person(Person(given_names="Ada", last_name="Lovelace"))
author_role = await people_service.create_person_role(label="Author")
recipient_role = await people_service.create_person_role(label="Recipient")
@@ -61,15 +61,15 @@ async def test_people_service_handles_person_and_document_person_crud(default_se
async def test_people_service_normalizes_and_rejects_duplicate_family_search_ids(default_session_factory):
people_service = PeopleService(session_factory=default_session_factory)
created = await people_service.create_person(Person(full_name="Hig Higgins", family_search_id=" g8t4-mdq "))
created = await people_service.create_person(Person(given_names="Hig", last_name="Higgins", family_search_id=" g8t4-mdq "))
assert created.family_search_id == "G8T4-MDQ"
with pytest.raises(PeopleError) as duplicate:
await people_service.create_person(Person(full_name="Duplicate Hig", family_search_id="G8T4-MDQ"))
await people_service.create_person(Person(given_names="Duplicate", last_name="Hig", family_search_id="G8T4-MDQ"))
assert duplicate.value.category == ErrorCategory.CONFLICT
with pytest.raises(PeopleError) as malformed:
await people_service.create_person(Person(full_name="Malformed", family_search_id="not-an-id"))
await people_service.create_person(Person(given_names="Malformed", last_name="Person", family_search_id="not-an-id"))
assert malformed.value.category == ErrorCategory.VALIDATION
@@ -228,7 +228,7 @@ async def test_document_detail_loads_linked_person_relationship(default_session_
people_service = PeopleService(session_factory=default_session_factory)
document = await documents.create_document(Document(id=uuid4(), name="detail-person-doc"))
person = await people_service.create_person(Person(full_name="Grace Hopper"))
person = await people_service.create_person(Person(given_names="Grace", last_name="Hopper"))
author_role = await people_service.create_person_role(label="Author")
await people_service.create_document_person(
DocumentPerson(
+3 -3
View File
@@ -45,7 +45,7 @@ async def test_update_document_with_people_rolls_back_document_and_links(default
people = PeopleService(session_factory=default_session_factory)
role = await people.create_person_role(label="Witness")
inactive = await people.create_person_role(label="Former Witness", is_active=False)
person = await people.create_person(Person(full_name="Archive Witness"))
person = await people.create_person(Person(given_names="Archive", last_name="Witness"))
document = await create_document_with_people(
document=Document(name="Original name"),
links=[DocumentPersonInput(person_id=person.id, role_id=role.id)],
@@ -83,7 +83,7 @@ async def test_direct_link_writes_reject_new_inactive_role_assignments(default_s
people = PeopleService(session_factory=default_session_factory)
active = await people.create_person_role(label="Witness")
inactive = await people.create_person_role(label="Former Witness", is_active=False)
person = await people.create_person(Person(full_name="Archive Witness"))
person = await people.create_person(Person(given_names="Archive", last_name="Witness"))
document = await documents.create_document(Document(name="Role rules"))
link = await people.add_document_person_link(
document_id=document.id,
@@ -114,7 +114,7 @@ async def test_document_print_projection_uses_semantic_author_and_current_text(d
document = await documents.create_document(
Document(name="Print Me", notes="Archive note", document_type_id=document_type.id)
)
person = await people.create_person(Person(full_name="Historic Author"))
person = await people.create_person(Person(given_names="Historic", last_name="Author"))
async with people._session_scope() as session:
author = PersonRole(
+52 -6
View File
@@ -20,6 +20,7 @@ from transcription.db import dispose_database_runtime
from transcription.db import initialize_database_runtime
from transcription.db import reconcile_canonical_media_paths
from transcription.db import reconcile_legacy_job_source_columns
from transcription.db import reconcile_person_name_columns
from transcription.db import session_scope
from transcription.db.models import DocumentType
from transcription.db.models import PersonRole
@@ -55,6 +56,7 @@ async def test_create_all_creates_expected_tables(tmp_path):
assert "person_role" in table_names
assert "document_person" in table_names
assert "document_tag" in table_names
assert "person_tag" in table_names
assert "job" in table_names
assert "source" in table_names
assert "job_source" in table_names
@@ -141,7 +143,7 @@ async def test_create_all_declares_hot_path_indexes(tmp_path):
database = inspect(sync_connection)
indexes = {
table: [index["column_names"] for index in database.get_indexes(table)]
for table in ("job", "source", "job_source", "document", "document_person", "document_tag")
for table in ("job", "source", "job_source", "document", "document_person", "document_tag", "person_tag")
}
job_source_unique = [
constraint["column_names"]
@@ -151,9 +153,13 @@ async def test_create_all_declares_hot_path_indexes(tmp_path):
constraint["column_names"]
for constraint in database.get_unique_constraints("document_tag")
]
return indexes, job_source_unique, document_tag_unique
person_tag_unique = [
constraint["column_names"]
for constraint in database.get_unique_constraints("person_tag")
]
return indexes, job_source_unique, document_tag_unique, person_tag_unique
indexes, job_source_unique, document_tag_unique = await connection.run_sync(collect)
indexes, job_source_unique, document_tag_unique, person_tag_unique = await connection.run_sync(collect)
assert ["status", "date_created"] in indexes["job"]
assert ["document_id"] in indexes["job"]
@@ -168,6 +174,46 @@ async def test_create_all_declares_hot_path_indexes(tmp_path):
assert ["document_id"] in indexes["document_tag"]
assert ["tag_id"] in indexes["document_tag"]
assert ["document_id", "tag_id"] in document_tag_unique
assert ["person_id"] in indexes["person_tag"]
assert ["tag_id"] in indexes["person_tag"]
assert ["person_id", "tag_id"] in person_tag_unique
finally:
await dispose_database_runtime()
@pytest.mark.asyncio
async def test_reconcile_person_name_columns_backfills_split_names(tmp_path):
settings = Settings(
openrouter_api_key="test-key",
database=SqliteSettings(path=str(tmp_path / "legacy-person-name.db")),
environment="test",
)
runtime = initialize_database_runtime(settings=settings)
try:
await create_all(engine=runtime.engine)
async with runtime.engine.begin() as connection:
await connection.execute(text('alter table "person" add column "full_name" varchar'))
await connection.execute(
text(
'insert into "person" (id, full_name, given_names, last_name, created_at, updated_at) '
"values (:id, :full_name, '', '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
),
{"id": "55" * 16, "full_name": "Ada Lovelace"},
)
changed = await reconcile_person_name_columns(engine=runtime.engine)
assert changed >= 1
async with runtime.engine.connect() as connection:
row = (
await connection.execute(
text('select given_names, last_name from "person" where id = :id'),
{"id": "55" * 16},
)
).one()
assert row[0] == "Ada"
assert row[1] == "Lovelace"
finally:
await dispose_database_runtime()
@@ -227,10 +273,10 @@ async def test_reconcile_canonical_media_paths_normalizes_source_and_photo_paths
async with runtime.engine.begin() as connection:
await connection.execute(
text(
'insert into "person" (id, full_name, created_at, updated_at) '
'values (:id, :full_name, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)'
'insert into "person" (id, given_names, last_name, created_at, updated_at) '
'values (:id, :given_names, :last_name, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)'
),
{"id": "11" * 16, "full_name": "Portrait"},
{"id": "11" * 16, "given_names": "Portrait", "last_name": "Person"},
)
await connection.execute(
text(
+3 -3
View File
@@ -53,7 +53,7 @@ def _persist_document(session) -> Document:
def _persist_person(session, **overrides: Any) -> Person:
defaults: dict[str, Any] = {"full_name": "Ada Lovelace"}
defaults: dict[str, Any] = {"given_names": "Ada", "last_name": "Lovelace"}
defaults.update(overrides)
person = Person(**defaults)
session.add(person)
@@ -195,10 +195,10 @@ class TestSourceModel:
class TestPersonAndDocumentPersonModel:
def test_family_search_id_is_unique_when_present(self, session):
session.add(Person(full_name="First Person", family_search_id="G8T4-MDQ"))
session.add(Person(given_names="First", last_name="Person", family_search_id="G8T4-MDQ"))
session.commit()
session.add(Person(full_name="Second Person", family_search_id="G8T4-MDQ"))
session.add(Person(given_names="Second", last_name="Person", family_search_id="G8T4-MDQ"))
with pytest.raises(IntegrityError):
session.commit()
@@ -168,9 +168,15 @@ def test_export_import_migration_backfills_legacy_portraits_and_homepage_images(
target_engine = create_engine(target_db_url)
try:
with target_engine.connect() as connection:
person_name = connection.execute(
text('select given_names, last_name from "person" where id = :id'),
{"id": person_id},
).one()
photos = connection.execute(
text('select person_id, path, is_primary from "photo" order by person_id is not null desc, created_at asc')
).all()
assert person_name[0] == "Legacy"
assert person_name[1] == "Portrait"
assert len(photos) == 2
person_photo = next(row for row in photos if row[0] is not None)
homepage_photo = next(row for row in photos if row[0] is None)
+4 -4
View File
@@ -28,7 +28,7 @@ async def seed_person_and_document():
async with session_scope() as session:
letter_type = (await session.exec(select(DocumentType).where(DocumentType.label == "Letter"))).one()
author_role = (await session.exec(select(PersonRole).where(PersonRole.semantic_key == "author"))).one()
person = Person(full_name="Zenna Cochran")
person = Person(given_names="Zenna", last_name="Cochran")
session.add(person)
await session.flush()
@@ -122,8 +122,8 @@ class TestDocumentsPageRendering:
_, client = app_client
async with session_scope() as session:
person = Person(
full_name="Albert Edward Higgins",
display_name="Hig",
given_names="Albert Edward",
last_name="Higgins",
birth_date=date(1885, 1, 2),
)
session.add(person)
@@ -133,7 +133,7 @@ class TestDocumentsPageRendering:
response = client.get(f"/ui/documents/new?person_id={person_id}")
assert response.status_code == 200
assert "Hig - Albert Edward Higgins (1885)" in response.text
assert "Albert Edward Higgins (1885)" in response.text
@pytest.mark.asyncio
async def test_document_detail_page_renders_bento_grid_and_metadata(self, app_client, seed_person_and_document):
+6 -6
View File
@@ -15,18 +15,18 @@ def test_compact_date_prefers_exact_then_approximate_then_unknown():
def test_person_selector_label_disambiguates_without_changing_identity():
person = Person(
full_name="Albert Edward Higgins",
display_name="Hig",
given_names="Albert Edward",
last_name="Higgins",
birth_date=date(1885, 1, 2),
)
assert person_selector_label(person) == "Hig - Albert Edward Higgins (1885)"
assert person_selector_label(person) == "Albert Edward Higgins (1885)"
approximate = Person(
full_name="Albert Edward Higgins",
display_name="Hig",
given_names="Albert Edward",
last_name="Higgins",
birth_date_raw="about 1912",
)
assert person_selector_label(approximate) == "Hig - Albert Edward Higgins (1912)"
assert person_selector_label(approximate) == "Albert Edward Higgins (1912)"
def test_family_search_url_uses_fixed_person_details_route():
+21 -20
View File
@@ -35,17 +35,18 @@ class TestPeoplePageRendering:
_, client = app_client
async with session_scope() as session:
session.add(Person(full_name="Ada Lovelace", display_name="Ada"))
session.add(Person(given_names="Ada", last_name="Lovelace"))
await session.commit()
response = client.get("/ui/people")
assert response.status_code == 200
assert "Ada Lovelace" in response.text
assert "Lovelace" in response.text
assert "Ada" in response.text
assert "Last Name" in response.text
assert "First & Middle" in response.text or "First & Middle" in response.text
assert "FamilySearch ID" in response.text
assert "# Documents" in response.text
assert "Display Name" not in response.text
assert "Maiden Name" not in response.text
@pytest.mark.asyncio
async def test_people_page_shows_document_counts(self, app_client):
@@ -53,7 +54,7 @@ class TestPeoplePageRendering:
async with session_scope() as session:
role = (await session.exec(select(PersonRole).where(PersonRole.semantic_key == "author"))).one()
person = Person(full_name="Counted Person")
person = Person(given_names="Counted", last_name="Person")
document = Document(name="Linked For Count")
session.add_all([person, document])
await session.flush()
@@ -72,7 +73,7 @@ class TestPeoplePageRendering:
assert response.status_code == 200
assert "Create Person Record" in response.text
assert "Full name is required." in response.text
assert "Last name and first/middle names are required." in response.text
assert "Birth date" in response.text
assert "Death date" in response.text
assert "Birth date (YYYY-MM-DD)" not in response.text
@@ -90,9 +91,8 @@ class TestPeoplePageRendering:
async with session_scope() as session:
person = Person(
full_name="Grace Hopper",
display_name="Grace",
maiden_name="Murray",
given_names="Grace",
last_name="Hopper",
birth_date=date(1906, 12, 9),
birth_date_raw="1906",
birth_place="New York",
@@ -111,8 +111,8 @@ class TestPeoplePageRendering:
assert response.status_code == 200
assert "Grace Hopper" in response.text
assert "Full Name:" in response.text
assert "Display Name:" in response.text
assert "Maiden Name:" in response.text
assert "Last Name:" in response.text
assert "First & Middle:" in response.text or "First & Middle:" in response.text
assert "Birth Date:" in response.text
assert "1906-12-09" in response.text
assert "Death Date:" in response.text
@@ -141,7 +141,7 @@ class TestPeoplePageRendering:
photo_file.write_bytes(b"portrait")
async with session_scope() as session:
person = Person(full_name="Portrait Person")
person = Person(given_names="Portrait", last_name="Person")
session.add(person)
await session.flush()
session.add(
@@ -172,7 +172,7 @@ class TestPeoplePageRendering:
photo_file.write_bytes(b"portrait")
async with session_scope() as session:
person = Person(full_name="Gallery Person")
person = Person(given_names="Gallery", last_name="Person")
session.add(person)
await session.flush()
session.add(
@@ -202,7 +202,7 @@ class TestPeoplePageRendering:
async with session_scope() as session:
author_role = (await session.exec(select(PersonRole).where(PersonRole.semantic_key == "author"))).one()
person = Person(full_name="Linked Person")
person = Person(given_names="Linked", last_name="Person")
document = Document(name="Linked Document")
session.add_all([person, document])
await session.flush()
@@ -250,11 +250,11 @@ class TestPeoplePageRendering:
assert "Open" not in response.text
@pytest.mark.asyncio
async def test_person_detail_page_hides_empty_maiden_name(self, app_client):
async def test_person_detail_page_hides_removed_maiden_name_field(self, app_client):
_, client = app_client
async with session_scope() as session:
person = Person(full_name="No Maiden Name")
person = Person(given_names="No Maiden", last_name="Name")
session.add(person)
await session.commit()
person_id = str(person.id)
@@ -285,7 +285,7 @@ class TestPeoplePageRendering:
_, client = app_client
async with session_scope() as session:
person = Person(full_name="Editable Person", display_name="EP")
person = Person(given_names="Editable", last_name="Person")
session.add(person)
await session.commit()
person_id = str(person.id)
@@ -294,8 +294,9 @@ class TestPeoplePageRendering:
assert response.status_code == 200
assert "Edit Person Record" in response.text
assert "Full name is required." in response.text
assert "Editable Person" in response.text
assert "Last name and first/middle names are required." in response.text
assert "Editable" in response.text
assert "Person" in response.text
assert "Save changes" in response.text
@pytest.mark.asyncio
@@ -303,7 +304,7 @@ class TestPeoplePageRendering:
_, client = app_client
async with session_scope() as session:
person = Person(full_name="Safe Delete")
person = Person(given_names="Safe", last_name="Delete")
session.add(person)
await session.commit()
person_id = str(person.id)