V4.11 Added tags + lots of little changes to the UI
Quality Gate / gate (push) Failing after 12s

This commit is contained in:
Jim Lancaster
2026-08-22 18:32:52 -05:00
parent 63c21d4a14
commit 0d554c0648
36 changed files with 932 additions and 116 deletions
+26
View File
@@ -5,13 +5,16 @@ from datetime import datetime
from uuid import uuid4
import pytest
from sqlmodel import select
from transcription.config import Settings
from transcription.db.models import Document
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 Source
from transcription.db.models import Tag
from transcription.services.documents import DocumentDeleteBlockedError
from transcription.services.documents import DocumentError
from transcription.services.documents import DocumentService
@@ -311,3 +314,26 @@ async def test_update_document_person_changes_role_id(default_session_factory):
)
assert updated.role_id == recipient_role.id
@pytest.mark.asyncio
async def test_sync_document_tags_by_labels_creates_and_replaces_tags(default_session_factory):
documents = DocumentService(session_factory=default_session_factory)
document = await documents.create_document(Document(id=uuid4(), name="tagged-doc"))
await documents.sync_document_tags_by_labels(document_id=document.id, labels=["Family", "Census"])
await documents.sync_document_tags_by_labels(document_id=document.id, labels=["Census", "Research"])
async with documents._session_scope() as session:
links = (await session.exec(select(DocumentTag).where(DocumentTag.document_id == document.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"}
listed = await documents.list_documents()
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"}
+3
View File
@@ -31,6 +31,7 @@ async def test_create_document_with_people_rolls_back_on_invalid_person(default_
await create_document_with_people(
document=Document(name="Must roll back"),
links=[DocumentPersonInput(person_id=uuid4(), role_id=role.id)],
tag_labels=[],
documents=documents,
people=people,
)
@@ -48,6 +49,7 @@ async def test_update_document_with_people_rolls_back_document_and_links(default
document = await create_document_with_people(
document=Document(name="Original name"),
links=[DocumentPersonInput(person_id=person.id, role_id=role.id)],
tag_labels=[],
documents=documents,
people=people,
)
@@ -63,6 +65,7 @@ async def test_update_document_with_people_rolls_back_document_and_links(default
await update_document_with_people(
document=candidate,
links=[DocumentPersonInput(person_id=person.id, role_id=inactive.id)],
tag_labels=[],
documents=documents,
people=people,
)
+12 -3
View File
@@ -41,9 +41,11 @@ async def test_create_all_creates_expected_tables(tmp_path):
assert "document" in table_names
assert "document_type" in table_names
assert "tag" in table_names
assert "person" in table_names
assert "person_role" in table_names
assert "document_person" in table_names
assert "document_tag" in table_names
assert "job" in table_names
assert "source" in table_names
assert "job_source" in table_names
@@ -130,15 +132,19 @@ 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")
for table in ("job", "source", "job_source", "document", "document_person", "document_tag")
}
job_source_unique = [
constraint["column_names"]
for constraint in database.get_unique_constraints("job_source")
]
return indexes, job_source_unique
document_tag_unique = [
constraint["column_names"]
for constraint in database.get_unique_constraints("document_tag")
]
return indexes, job_source_unique, document_tag_unique
indexes, job_source_unique = await connection.run_sync(collect)
indexes, job_source_unique, document_tag_unique = await connection.run_sync(collect)
assert ["status", "date_created"] in indexes["job"]
assert ["document_id"] in indexes["job"]
@@ -150,6 +156,9 @@ async def test_create_all_declares_hot_path_indexes(tmp_path):
assert ["document_type_id"] in indexes["document"]
for column in ("document_id", "person_id", "role_id"):
assert [column] in indexes["document_person"]
assert ["document_id"] in indexes["document_tag"]
assert ["tag_id"] in indexes["document_tag"]
assert ["document_id", "tag_id"] in document_tag_unique
finally:
await dispose_database_runtime()
+116
View File
@@ -0,0 +1,116 @@
"""Integrity checks for document/source filesystem-to-database reconciliation."""
from __future__ import annotations
from pathlib import Path
from uuid import UUID
from uuid import uuid4
import pytest
from sqlmodel import func
from sqlmodel import select
from transcription.config import Settings
from transcription.db import session_scope
from transcription.db.models import Document
from transcription.db.models import Source
def _document_folder_ids(root: Path) -> set[str]:
documents_root = root / "documents"
if not documents_root.exists():
return set()
return {entry.name for entry in documents_root.iterdir() if entry.is_dir()}
async def _document_ids(settings: Settings) -> set[str]:
async with session_scope(settings=settings) as session:
rows = await session.exec(select(Document.id))
return {str(item) for item in rows.all()}
async def _source_counts_by_document(settings: Settings) -> dict[str, int]:
async with session_scope(settings=settings) as session:
rows = await session.exec(
select(Source.document_id, func.count(Source.id)).group_by(Source.document_id)
)
return {str(document_id): int(count) for document_id, count in rows}
def _source_file_count_for_document(root: Path, document_id: str) -> int:
directory = root / "documents" / document_id
if not directory.exists():
return 0
return sum(1 for entry in directory.iterdir() if entry.is_file())
async def assert_storage_reconciliation(*, upload_dir: Path, settings: Settings) -> None:
folder_ids = _document_folder_ids(upload_dir)
doc_ids = await _document_ids(settings)
missing_in_table = sorted(folder_ids - doc_ids)
missing_in_folders = sorted(doc_ids - folder_ids)
if missing_in_table:
folder = missing_in_table[0]
raise AssertionError(
"Document directory count and document.doc_id count do not agree. "
f"./data/documents/{folder} does not appear in document table"
)
if missing_in_folders:
doc_id = missing_in_folders[0]
raise AssertionError(
"Document directory count and document.doc_id count do not agree. "
f"document.doc_id {doc_id} has no corresponding folder in ./data/documents"
)
source_counts = await _source_counts_by_document(settings)
for document_id in sorted(doc_ids):
db_count = source_counts.get(document_id, 0)
file_count = _source_file_count_for_document(upload_dir, document_id)
if file_count > db_count:
raise AssertionError(
"Source file count and source.source_id count do not agree. "
f"[UPLOAD_DIR]/documents/{document_id}/ contains file(s) with no source row"
)
if db_count > file_count:
raise AssertionError(
"Source file count and source.source_id count do not agree. "
f"source.source_id rows exist without files in [UPLOAD_DIR]/documents/{document_id}"
)
@pytest.mark.asyncio
async def test_storage_reconciliation_passes_for_matching_counts(tmp_path, default_settings: Settings):
upload_dir = tmp_path / "uploads"
document_id = str(uuid4())
document_uuid = UUID(document_id)
file_name = f"{uuid4()}.png"
(upload_dir / "documents" / document_id).mkdir(parents=True, exist_ok=True)
(upload_dir / "documents" / document_id / file_name).write_bytes(b"ok")
async with session_scope(settings=default_settings) as session:
session.add(Document(id=document_uuid, name="Doc"))
session.add(
Source(
document_id=document_uuid,
page_number=1,
upload_name=file_name,
filename=file_name,
file_path=f"documents/{document_id}/{file_name}",
file_hash="a" * 64,
file_size_bytes=2,
)
)
await session.commit()
await assert_storage_reconciliation(upload_dir=upload_dir, settings=default_settings)
@pytest.mark.asyncio
async def test_storage_reconciliation_reports_actionable_mismatch_message(tmp_path, default_settings: Settings):
upload_dir = tmp_path / "uploads"
orphan_dir = upload_dir / "documents" / str(uuid4())
orphan_dir.mkdir(parents=True, exist_ok=True)
with pytest.raises(AssertionError, match="document\\.doc_id count do not agree"):
await assert_storage_reconciliation(upload_dir=upload_dir, settings=default_settings)
+4
View File
@@ -23,6 +23,7 @@ from transcription.db import session as db_session_module
from transcription.db import session_scope
from transcription.db.models import Document
from transcription.db.models import DocumentPerson
from transcription.db.models import DocumentTag
from transcription.db.models import ExecutionAttempt
from transcription.db.models import Job
from transcription.db.models import JobSource
@@ -30,6 +31,7 @@ from transcription.db.models import JobSourceStatus
from transcription.db.models import JobStatus
from transcription.db.models import Person
from transcription.db.models import Source
from transcription.db.models import Tag
@pytest.fixture(scope="session")
@@ -73,11 +75,13 @@ async def clear_ui_database(
)
async with session_scope(session_factory=app.state.runtime.session_factory) as session:
await session.exec(delete(JobSource))
await session.exec(delete(DocumentTag))
await session.exec(delete(DocumentPerson))
await session.exec(delete(Source))
await session.exec(delete(Job))
await session.exec(delete(Document))
await session.exec(delete(Person))
await session.exec(delete(Tag))
await session.commit()
+11 -1
View File
@@ -14,6 +14,7 @@ from transcription.db.models import Job
from transcription.db.models import Person
from transcription.db.models import PersonRole
from transcription.db.models import Source
from transcription.ui.pages.documents_page import _resolve_selected_tag_labels
# --- Helper Fixtures ---
@@ -81,9 +82,10 @@ class TestDocumentsPageRendering:
assert response.status_code == 200
assert "1924 Postcard" in response.text
assert "Postcard" in response.text
assert "PC-001" in response.text
assert "Document Date" in response.text
assert "Author" in response.text
assert "# Sources" in response.text
assert "Archive Ref" not in response.text
def test_document_create_page_renders_form(self, app_client):
_, client = app_client
@@ -95,6 +97,7 @@ class TestDocumentsPageRendering:
assert "Document name" in response.text
assert "Linked People" in response.text
assert "Document type" in response.text
assert "Tags" in response.text
@pytest.mark.asyncio
async def test_document_create_page_preselects_person_with_disambiguating_label(self, app_client):
@@ -209,3 +212,10 @@ class TestDocumentsPageRendering:
assert "Delete Document" in response.text
assert "Delete document permanently" in response.text
assert "Delete is blocked" not in response.text
def test_resolve_selected_tag_labels_handles_multiple_payload_shapes():
assert _resolve_selected_tag_labels("Family") == ["Family"]
assert _resolve_selected_tag_labels(["Family", "Research"]) == ["Family", "Research"]
assert _resolve_selected_tag_labels([{"label": "Family"}, {"value": "Research"}]) == ["Family", "Research"]
assert set(_resolve_selected_tag_labels({"value": {"Family", "Research"}})) == {"Family", "Research"}
+3
View File
@@ -55,6 +55,7 @@ class TestJobsPageRendering:
assert response.status_code == 200
assert "Document Name" in response.text
assert "# Sources" in response.text
assert "Source Filename" not in response.text
assert "Updated" in response.text
assert "Created" not in response.text
@@ -112,6 +113,8 @@ class TestJobsPageRendering:
assert response.status_code == 200
assert "Create Processing Job" in response.text
assert "Preselected Journal Entry" in response.text
assert "Provider" in response.text
assert "Model" in response.text
@pytest.mark.asyncio
async def test_job_detail_page_renders_logistics_and_links(self, app_client, seed_document_with_unlinked_job):
+1
View File
@@ -28,6 +28,7 @@ class TestNavigationAndMounts:
"/ui/homepage",
"/ui/homepage/edit",
"/ui/documents",
"/ui/tags",
"/ui/people",
"/ui/sources",
"/ui/jobs",
+3
View File
@@ -16,6 +16,7 @@ class TestPageRegistration:
people_response = client.get("/ui/people")
sources_response = client.get("/ui/sources")
jobs_response = client.get("/ui/jobs")
tags_response = client.get("/ui/tags")
settings_response = client.get("/ui/settings")
assert homepage_response.status_code == 200
@@ -23,9 +24,11 @@ class TestPageRegistration:
assert people_response.status_code == 200
assert sources_response.status_code == 200
assert jobs_response.status_code == 200
assert tags_response.status_code == 200
assert settings_response.status_code == 200
assert "Document Types" in settings_response.text
assert "Person Roles" in settings_response.text
assert "Tags" in settings_response.text
assert "Prompts" in settings_response.text
assert "Home Page Text" in settings_response.text
assert "README.md" not in settings_response.text
+22 -1
View File
@@ -41,7 +41,28 @@ class TestPeoplePageRendering:
assert response.status_code == 200
assert "Ada Lovelace" in response.text
assert "Ada" 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):
_, client = app_client
async with session_scope() as session:
role = (await session.exec(select(PersonRole).where(PersonRole.semantic_key == "author"))).one()
person = Person(full_name="Counted Person")
document = Document(name="Linked For Count")
session.add_all([person, document])
await session.flush()
session.add(DocumentPerson(document_id=document.id, person_id=person.id, role_id=role.id))
await session.commit()
response = client.get("/ui/people")
assert response.status_code == 200
assert '"document_count":1' in response.text
def test_person_create_page_renders_fields(self, app_client):
_, client = app_client
+1 -2
View File
@@ -261,9 +261,8 @@ class TestSourcesPageRendering:
assert "SOURCE PAGE 1: DETAIL-SOURCE.PNG" in response.text.upper()
assert "SOURCE METADATA" in response.text.upper()
assert "SOURCEJOB METADATA" in response.text.upper()
assert "TRANSCRIPTION TEXT" in response.text.upper()
assert "TRANSCRIPTION TEXT" not in response.text.upper()
assert "EDITABLE REVISION" in response.text.upper()
assert "original transcription text" in response.text
assert "human revision text" in response.text
assert "Save revision" in response.text
assert "Previous Page" in response.text
+43
View File
@@ -0,0 +1,43 @@
"""Tests for the tags page route and grouped filtering behavior."""
import pytest
from transcription.db import session_scope
from transcription.db.models import Document
from transcription.services.documents import DocumentService
@pytest.mark.integration
class TestTagsPageRendering:
def test_tags_page_renders_empty_state_without_tags(self, app_client):
_, client = app_client
response = client.get("/ui/tags")
assert response.status_code == 200
assert "Tags" in response.text
assert "No tags are configured yet." in response.text
@pytest.mark.asyncio
async def test_tags_page_groups_documents_by_tag(self, app_client):
app, client = app_client
documents = DocumentService(session_factory=app.state.runtime.session_factory)
async with session_scope(session_factory=app.state.runtime.session_factory) as session:
first = Document(name="Tagged Letter")
second = Document(name="Tagged Journal")
session.add_all([first, second])
await session.flush()
await documents.sync_document_tags_by_labels(document_id=first.id, labels=["Family"], session=session)
await documents.sync_document_tags_by_labels(
document_id=second.id,
labels=["Family", "Research"],
session=session,
)
await session.commit()
response = client.get("/ui/tags")
assert response.status_code == 200
assert "Filter by tag" in response.text
assert "Tags" in response.text