generated from john/python-template
@@ -50,6 +50,7 @@ Optional:
|
|||||||
Rules:
|
Rules:
|
||||||
|
|
||||||
- Exact date must parse as `YYYY-MM-DD`; browser presentation may follow locale.
|
- Exact date must parse as `YYYY-MM-DD`; browser presentation may follow locale.
|
||||||
|
- The exact-date input is labeled **Document date**.
|
||||||
- Existing people appear with disambiguating labels.
|
- Existing people appear with disambiguating labels.
|
||||||
- Tag assignment supports selecting existing tags and adding new labels inline.
|
- Tag assignment supports selecting existing tags and adding new labels inline.
|
||||||
- **Create new person** opens Person creation.
|
- **Create new person** opens Person creation.
|
||||||
@@ -68,7 +69,7 @@ Rules:
|
|||||||
|
|
||||||
- The heading shows name, type, and internal ID.
|
- The heading shows name, type, and internal ID.
|
||||||
- The first Source, when present, appears in the dark-room viewer.
|
- The first Source, when present, appears in the dark-room viewer.
|
||||||
- Archival Metadata shows authors, Document Type, tags, compact Document date, location, and archive identifier. Notes appear in a separate archival-notes block within the same card.
|
- Archival Metadata shows authors, Document Type, tags, Document date (`MM-DD-YYYY` for exact dates), location, and archive identifier. Notes appear in a separate archival-notes block within the same card.
|
||||||
- System Logistics shows created and updated timestamps.
|
- System Logistics shows created and updated timestamps.
|
||||||
- Related People are grouped by role and link to Person Detail.
|
- Related People are grouped by role and link to Person Detail.
|
||||||
- **Sources & Pipeline Jobs** shows counts and actions for filtered Sources, Document Jobs, and adding a Job.
|
- **Sources & Pipeline Jobs** shows counts and actions for filtered Sources, Document Jobs, and adding a Job.
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ People manages reusable historical-person records. A Person may appear in many D
|
|||||||
- The title is **Archival Entities: People**.
|
- The title is **Archival Entities: People**.
|
||||||
- **Create new person** opens the create route.
|
- **Create new person** opens the create route.
|
||||||
- The table defaults to Last Name order and supports search and column sorting.
|
- The table defaults to Last Name order and supports search and column sorting.
|
||||||
- Columns are Last Name, First & Middle, FamilySearch ID, Birth Date, Death Date, and # Documents.
|
- Columns are Last Name, First & Middle, Tags, FamilySearch ID, Birth Date, Death Date, and # Documents.
|
||||||
- Last Name and First & Middle are left-aligned; FamilySearch ID, date columns, and # Documents are centered.
|
- Name and Tags are left-aligned; FamilySearch ID, date columns, and # Documents are centered.
|
||||||
- # Documents reflects how many linked Documents each Person is connected to.
|
- # Documents reflects how many linked Documents each Person is connected to.
|
||||||
- Birth and death values independently prefer exact date, then approximate date, then `Unknown`.
|
- Birth and death values independently prefer exact date, then approximate date, then `Unknown`.
|
||||||
- Selecting a row opens Person Detail.
|
- Selecting a row opens Person Detail.
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ The list accepts optional `document_id` and `job_id` query parameters. Document
|
|||||||
- Rows are ordered by page number and then upload name.
|
- Rows are ordered by page number and then upload name.
|
||||||
- Columns are Document Name, Page Number, Upload Title, Status, and Error Detail.
|
- Columns are Document Name, Page Number, Upload Title, Status, and Error Detail.
|
||||||
- Document Name, Upload Title, and Error Detail are left-aligned; Status is centered.
|
- Document Name, Upload Title, and Error Detail are left-aligned; Status is centered.
|
||||||
|
- Status labels are presented in uppercase for consistency with Jobs.
|
||||||
- Stored Filename is intentionally absent from the list.
|
- Stored Filename is intentionally absent from the list.
|
||||||
- Selecting a row opens Source Detail.
|
- Selecting a row opens Source Detail.
|
||||||
- No records displays `No source asset records found in repository.`
|
- No records displays `No source asset records found in repository.`
|
||||||
@@ -39,7 +40,7 @@ The list accepts optional `document_id` and `job_id` query parameters. Document
|
|||||||
- Wide+narrow landscape images switch to a stacked left layout (image above Editable Revision) with metadata on the right.
|
- Wide+narrow landscape images switch to a stacked left layout (image above Editable Revision) with metadata on the right.
|
||||||
- Editable Revision is seeded from an existing revision or the preferred machine transcription.
|
- Editable Revision is seeded from an existing revision or the preferred machine transcription.
|
||||||
- Source Metadata shows upload name, stored filename, page number, Document Name, Document ID, and stored path. Source ID appears in the page-header subtitle.
|
- Source Metadata shows upload name, stored filename, page number, Document Name, Document ID, and stored path. Source ID appears in the page-header subtitle.
|
||||||
- SourceJob Metadata shows latest status, Job ID, execution time, provider, model, prompt, and failure detail.
|
- SourceJob Metadata shows latest status (uppercase display), Job ID, execution time, provider, model, prompt, and failure detail.
|
||||||
- Revision Logistics shows revised state, last-revised time, and upload time.
|
- Revision Logistics shows revised state, last-revised time, and upload time.
|
||||||
- Candidate Machine Transcriptions appears below the image/revision area, remains compact until expanded, then compares it with the preferred
|
- Candidate Machine Transcriptions appears below the image/revision area, remains compact until expanded, then compares it with the preferred
|
||||||
machine result and requires confirmation before **Use this transcription**.
|
machine result and requires confirmation before **Use this transcription**.
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ class PersonTableRow:
|
|||||||
"""Read model consumed by the people table component."""
|
"""Read model consumed by the people table component."""
|
||||||
|
|
||||||
id: UUID
|
id: UUID
|
||||||
last_name: str
|
name: str
|
||||||
given_names: str
|
tags: str
|
||||||
family_search_id: str
|
family_search_id: str
|
||||||
birth_date: str
|
birth_date: str
|
||||||
death_date: str
|
death_date: str
|
||||||
@@ -31,8 +31,8 @@ def _serialize_rows(rows: Sequence[PersonTableRow]) -> list[dict[str, Any]]:
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"id": str(row.id),
|
"id": str(row.id),
|
||||||
"last_name": row.last_name,
|
"name": row.name,
|
||||||
"given_names": row.given_names,
|
"tags": row.tags or "Not set",
|
||||||
"family_search_id": row.family_search_id or "Not set",
|
"family_search_id": row.family_search_id or "Not set",
|
||||||
"birth_date": row.birth_date or "Unknown",
|
"birth_date": row.birth_date or "Unknown",
|
||||||
"death_date": row.death_date or "Unknown",
|
"death_date": row.death_date or "Unknown",
|
||||||
@@ -53,18 +53,18 @@ def render_people_table(rows: Sequence[PersonTableRow]) -> None:
|
|||||||
rows=_serialize_rows(rows),
|
rows=_serialize_rows(rows),
|
||||||
columns=[
|
columns=[
|
||||||
{
|
{
|
||||||
"name": "last_name",
|
"name": "name",
|
||||||
"label": "Last Name",
|
"label": "Last Name, First & Middle",
|
||||||
"field": "last_name",
|
"field": "name",
|
||||||
"sortable": True,
|
"sortable": True,
|
||||||
"classes": "font-serif font-semibold text-left ui-table-cell-wrap",
|
"classes": "font-serif font-semibold text-left ui-table-cell-wrap",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "given_names",
|
"name": "tags",
|
||||||
"label": "First & Middle",
|
"label": "Tags",
|
||||||
"field": "given_names",
|
"field": "tags",
|
||||||
"sortable": True,
|
"sortable": True,
|
||||||
"classes": "font-serif font-semibold text-left ui-table-cell-wrap",
|
"classes": "text-left ui-table-cell-wrap",
|
||||||
},
|
},
|
||||||
{"name": "family_search_id", "label": "FamilySearch ID", "field": "family_search_id", "sortable": True},
|
{"name": "family_search_id", "label": "FamilySearch ID", "field": "family_search_id", "sortable": True},
|
||||||
{
|
{
|
||||||
@@ -92,14 +92,14 @@ def render_people_table(rows: Sequence[PersonTableRow]) -> None:
|
|||||||
"classes": "font-mono",
|
"classes": "font-mono",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
default_sort_by="last_name",
|
default_sort_by="name",
|
||||||
search_placeholder="Search people by last name, given names, FamilySearch ID, or dates...",
|
search_placeholder="Search people by name, tags, FamilySearch ID, or dates...",
|
||||||
on_row_click_id=lambda person_id: ui.navigate.to(f"/people/{person_id}"),
|
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
|
# Custom column template adding an archival entity icon next to person's name
|
||||||
table.add_slot(
|
table.add_slot(
|
||||||
"body-cell-last_name",
|
"body-cell-name",
|
||||||
r"""
|
r"""
|
||||||
<q-td :props="props">
|
<q-td :props="props">
|
||||||
<div class="row items-center q-gutter-x-xs">
|
<div class="row items-center q-gutter-x-xs">
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ def _serialize_rows(rows: Sequence[SourceTableRow]) -> list[dict[str, Any]]:
|
|||||||
"upload_name": row.upload_name,
|
"upload_name": row.upload_name,
|
||||||
"document_id": str(row.document_id),
|
"document_id": str(row.document_id),
|
||||||
"document_name": row.document_name or "-",
|
"document_name": row.document_name or "-",
|
||||||
"job_source_status": row.job_source_status or "-",
|
"job_source_status": (row.job_source_status or "-").upper(),
|
||||||
"job_source_error_detail": row.job_source_error_detail or "-",
|
"job_source_error_detail": row.job_source_error_detail or "-",
|
||||||
}
|
}
|
||||||
for row in rows
|
for row in rows
|
||||||
@@ -109,7 +109,7 @@ def render_sources_table(rows: Sequence[SourceTableRow]) -> None:
|
|||||||
dense
|
dense
|
||||||
square
|
square
|
||||||
size="sm"
|
size="sm"
|
||||||
:class="`ui-status ui-status--${props.value}`"
|
:class="`ui-status ui-status--${props.value.toLowerCase()}`"
|
||||||
>
|
>
|
||||||
{{ props.value }}
|
{{ props.value }}
|
||||||
</q-chip>
|
</q-chip>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import date
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
@@ -448,7 +449,7 @@ def _render_document_form_fields(
|
|||||||
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-2"):
|
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-2"):
|
||||||
date_input = (
|
date_input = (
|
||||||
ui.input(
|
ui.input(
|
||||||
label="Exact date (YYYY-MM-DD)",
|
label="Document date",
|
||||||
value=document.document_date.isoformat() if document and document.document_date else "",
|
value=document.document_date.isoformat() if document and document.document_date else "",
|
||||||
)
|
)
|
||||||
.props('outlined type="date"')
|
.props('outlined type="date"')
|
||||||
@@ -542,7 +543,7 @@ def _render_bento_metadata_zone(document: Document) -> None:
|
|||||||
key=str.casefold,
|
key=str.casefold,
|
||||||
)
|
)
|
||||||
metadata_row("Tags:", ", ".join(tags) if tags else "Not set")
|
metadata_row("Tags:", ", ".join(tags) if tags else "Not set")
|
||||||
metadata_row("Document Date:", compact_date(document.document_date, document.document_date_raw))
|
metadata_row("Document Date:", _detail_document_date(document.document_date, document.document_date_raw))
|
||||||
metadata_row("Location Created:", document.location_created or "Not set")
|
metadata_row("Location Created:", document.location_created or "Not set")
|
||||||
metadata_row("Archive Identifier:", document.archive_identifier or "Not set")
|
metadata_row("Archive Identifier:", document.archive_identifier or "Not set")
|
||||||
|
|
||||||
@@ -555,6 +556,12 @@ def _render_bento_metadata_zone(document: Document) -> None:
|
|||||||
ui.label(f"Updated: {document.updated_at.isoformat()}").classes("text-[11px] ui-text-muted")
|
ui.label(f"Updated: {document.updated_at.isoformat()}").classes("text-[11px] ui-text-muted")
|
||||||
|
|
||||||
|
|
||||||
|
def _detail_document_date(exact: date | None, approximate: str | None) -> str:
|
||||||
|
if exact is not None:
|
||||||
|
return exact.strftime("%m-%d-%Y")
|
||||||
|
return (approximate or "").strip() or "Unknown"
|
||||||
|
|
||||||
|
|
||||||
def _render_bento_relations_zone(document: Document) -> None:
|
def _render_bento_relations_zone(document: Document) -> None:
|
||||||
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
|
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
|
||||||
_render_related_people_card(document)
|
_render_related_people_card(document)
|
||||||
|
|||||||
@@ -313,7 +313,7 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
|
|
||||||
with archival_card(extra_classes="gap-2"):
|
with archival_card(extra_classes="gap-2"):
|
||||||
ui.label(f"Job ID: {job.id}").classes("text-sm font-semibold font-mono ui-text-primary")
|
ui.label(f"Job ID: {job.id}").classes("text-sm font-semibold font-mono ui-text-primary")
|
||||||
metadata_row("Current Status:", job.status.value)
|
metadata_row("Current Status:", job.status.value.upper())
|
||||||
ui.label(
|
ui.label(
|
||||||
"Cancel stops processing and marks remaining non-transcribed sources as cancelled. "
|
"Cancel stops processing and marks remaining non-transcribed sources as cancelled. "
|
||||||
"Cancelled sources can be resubmitted."
|
"Cancelled sources can be resubmitted."
|
||||||
@@ -369,7 +369,7 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
|
|
||||||
with archival_card(extra_classes="gap-2"):
|
with archival_card(extra_classes="gap-2"):
|
||||||
ui.label(f"Job ID: {job.id}").classes("text-sm font-semibold font-mono ui-text-primary")
|
ui.label(f"Job ID: {job.id}").classes("text-sm font-semibold font-mono ui-text-primary")
|
||||||
metadata_row("Current Status:", job.status.value)
|
metadata_row("Current Status:", job.status.value.upper())
|
||||||
metadata_row("Resubmittable Sources:", str(resubmittable_count))
|
metadata_row("Resubmittable Sources:", str(resubmittable_count))
|
||||||
ui.label(
|
ui.label(
|
||||||
"Resubmit queues failed and cancelled linked sources. "
|
"Resubmit queues failed and cancelled linked sources. "
|
||||||
|
|||||||
@@ -98,8 +98,13 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
rows = [
|
rows = [
|
||||||
PersonTableRow(
|
PersonTableRow(
|
||||||
id=person.id,
|
id=person.id,
|
||||||
last_name=person.last_name,
|
name=f"{person.last_name}, {person.given_names}",
|
||||||
given_names=person.given_names,
|
tags=", ".join(
|
||||||
|
sorted(
|
||||||
|
[link.tag_ref.label for link in person.person_tags if link.tag_ref is not None],
|
||||||
|
key=str.casefold,
|
||||||
|
)
|
||||||
|
),
|
||||||
family_search_id=person.family_search_id or "",
|
family_search_id=person.family_search_id or "",
|
||||||
birth_date=compact_date(person.birth_date, person.birth_date_raw),
|
birth_date=compact_date(person.birth_date, person.birth_date_raw),
|
||||||
death_date=compact_date(person.death_date, person.death_date_raw),
|
death_date=compact_date(person.death_date, person.death_date_raw),
|
||||||
|
|||||||
@@ -456,7 +456,7 @@ def _render_source_job_metadata_zone(
|
|||||||
status = latest_job_source.status.value
|
status = latest_job_source.status.value
|
||||||
with ui.row().classes("w-full justify-between items-center mb-2"):
|
with ui.row().classes("w-full justify-between items-center mb-2"):
|
||||||
ui.label("Latest Status").classes("text-xs ui-text-muted")
|
ui.label("Latest Status").classes("text-xs ui-text-muted")
|
||||||
archival_badge(status)
|
archival_badge(status.upper())
|
||||||
|
|
||||||
metadata_row("Job ID:", str(latest_job_source.job_id))
|
metadata_row("Job ID:", str(latest_job_source.job_id))
|
||||||
metadata_row(
|
metadata_row(
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ async def seed_person_and_document():
|
|||||||
name="Letter from Hig",
|
name="Letter from Hig",
|
||||||
document_type_id=letter_type.id,
|
document_type_id=letter_type.id,
|
||||||
archive_identifier="ZC-1924-001",
|
archive_identifier="ZC-1924-001",
|
||||||
|
document_date=date(1924, 7, 4),
|
||||||
)
|
)
|
||||||
session.add(doc)
|
session.add(doc)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
@@ -148,6 +149,8 @@ class TestDocumentsPageRendering:
|
|||||||
assert "Zenna Cochran" in response.text
|
assert "Zenna Cochran" in response.text
|
||||||
assert "Document Type:" in response.text
|
assert "Document Type:" in response.text
|
||||||
assert "Letter" in response.text
|
assert "Letter" in response.text
|
||||||
|
assert "07-04-1924" in response.text
|
||||||
|
assert "1924-07-04" not in response.text
|
||||||
assert "PIPELINE JOBS" in response.text.upper()
|
assert "PIPELINE JOBS" in response.text.upper()
|
||||||
assert "Edit Document" in response.text
|
assert "Edit Document" in response.text
|
||||||
|
|
||||||
@@ -183,6 +186,8 @@ class TestDocumentsPageRendering:
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "Edit Document Record" in response.text
|
assert "Edit Document Record" in response.text
|
||||||
assert "Letter from Hig" in response.text
|
assert "Letter from Hig" in response.text
|
||||||
|
assert "Document date" in response.text
|
||||||
|
assert "Exact date (YYYY-MM-DD)" not in response.text
|
||||||
assert "ZC-1924-001" in response.text
|
assert "ZC-1924-001" in response.text
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ from transcription.db import session_scope
|
|||||||
from transcription.db.models import Document
|
from transcription.db.models import Document
|
||||||
from transcription.db.models import DocumentPerson
|
from transcription.db.models import DocumentPerson
|
||||||
from transcription.db.models import Person
|
from transcription.db.models import Person
|
||||||
|
from transcription.db.models import PersonTag
|
||||||
from transcription.db.models import PersonRole
|
from transcription.db.models import PersonRole
|
||||||
from transcription.db.models import Photo
|
from transcription.db.models import Photo
|
||||||
from transcription.db.models import Source
|
from transcription.db.models import Source
|
||||||
|
from transcription.db.models import Tag
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
@@ -35,16 +37,20 @@ class TestPeoplePageRendering:
|
|||||||
_, client = app_client
|
_, client = app_client
|
||||||
|
|
||||||
async with session_scope() as session:
|
async with session_scope() as session:
|
||||||
session.add(Person(given_names="Ada", last_name="Lovelace"))
|
person = Person(given_names="Ada", last_name="Lovelace")
|
||||||
|
tag = Tag(label="Pioneer", normalized_label="pioneer")
|
||||||
|
session.add_all([person, tag])
|
||||||
|
await session.flush()
|
||||||
|
session.add(PersonTag(person_id=person.id, tag_id=tag.id))
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
response = client.get("/ui/people")
|
response = client.get("/ui/people")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "Lovelace" in response.text
|
assert "Lovelace, Ada" in response.text
|
||||||
assert "Ada" in response.text
|
assert "Last Name, First & Middle" in response.text or "Last Name, First & Middle" in response.text
|
||||||
assert "Last Name" in response.text
|
assert "Tags" in response.text
|
||||||
assert "First & Middle" in response.text or "First & Middle" in response.text
|
assert "Pioneer" in response.text
|
||||||
assert "FamilySearch ID" in response.text
|
assert "FamilySearch ID" in response.text
|
||||||
assert "# Documents" in response.text
|
assert "# Documents" in response.text
|
||||||
|
|
||||||
|
|||||||
@@ -266,7 +266,7 @@ class TestSourcesPageRendering:
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "job-failed-page.png" in response.text
|
assert "job-failed-page.png" in response.text
|
||||||
assert "failed" in response.text.lower()
|
assert "FAILED" in response.text
|
||||||
assert "Provider timed out" in response.text
|
assert "Provider timed out" in response.text
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
Reference in New Issue
Block a user