generated from john/python-template
V4.4 Complete
This commit is contained in:
@@ -24,6 +24,7 @@ from transcription.db.engine import get_engine
|
||||
from transcription.db.models import Document
|
||||
from transcription.db.models import DocumentType
|
||||
from transcription.db.models import Person
|
||||
from transcription.db.models import PersonRole
|
||||
from transcription.db.session import dispose_session_factory
|
||||
from transcription.db.session import session_scope
|
||||
from transcription.services.documents import DocumentService
|
||||
@@ -53,6 +54,12 @@ async def _document_type_id(*, db_url: str, label: str) -> UUID:
|
||||
return document_type.id
|
||||
|
||||
|
||||
async def _person_role_id(*, db_url: str, semantic_key: str) -> UUID:
|
||||
async with session_scope(database_url=db_url) as session:
|
||||
role = (await session.exec(select(PersonRole).where(PersonRole.semantic_key == semantic_key))).one()
|
||||
return role.id
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _v4_api_client(tmp_path: Path, *, db_filename: str) -> Generator[tuple[TestClient, str]]:
|
||||
settings = Settings(
|
||||
@@ -97,7 +104,7 @@ def test_list_document_types_returns_seeded_registry(tmp_path):
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
labels = {item["label"] for item in payload}
|
||||
assert {"Letter", "Record", "Memo"}.issubset(labels)
|
||||
assert {"Book", "Letter", "Postcard", "Photo", "Journal", "Form"}.issubset(labels)
|
||||
|
||||
|
||||
def test_list_person_roles_returns_seeded_registry(tmp_path):
|
||||
@@ -106,14 +113,15 @@ def test_list_person_roles_returns_seeded_registry(tmp_path):
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
codes = {item["code"] for item in payload}
|
||||
assert {"author", "recipient", "mentioned"}.issubset(codes)
|
||||
labels = {item["label"] for item in payload}
|
||||
assert {"Author", "Recipient", "Mentioned"}.issubset(labels)
|
||||
assert all("code" not in item and "semantic_key" not in item for item in payload)
|
||||
|
||||
|
||||
def test_set_document_type_by_id_updates_canonical_field(tmp_path):
|
||||
with _v4_api_client(tmp_path, db_filename="api-doc-type.db") as (client, db_url):
|
||||
document_id, _ = _seed_document_and_person(db_url=db_url)
|
||||
type_id = asyncio.run(_document_type_id(db_url=db_url, label="Record"))
|
||||
type_id = asyncio.run(_document_type_id(db_url=db_url, label="Form"))
|
||||
response = client.put(
|
||||
f"/api/v4/documents/{document_id}/type",
|
||||
json={"document_type_id": str(type_id)},
|
||||
@@ -147,32 +155,35 @@ def test_document_type_payload_requires_uuid_only(tmp_path):
|
||||
def test_document_people_role_aware_write_read_and_delete(tmp_path):
|
||||
with _v4_api_client(tmp_path, db_filename="api-links.db") as (client, db_url):
|
||||
document_id, person_id = _seed_document_and_person(db_url=db_url)
|
||||
author_id = asyncio.run(_person_role_id(db_url=db_url, semantic_key="author"))
|
||||
recipient_id = asyncio.run(_person_role_id(db_url=db_url, semantic_key="recipient"))
|
||||
|
||||
create_response = client.post(
|
||||
f"/api/v4/documents/{document_id}/people",
|
||||
json={"person_id": str(person_id), "role_code": "author"},
|
||||
json={"person_id": str(person_id), "role_id": str(author_id)},
|
||||
)
|
||||
assert create_response.status_code == 200
|
||||
created = create_response.json()
|
||||
assert created["document_id"] == str(document_id)
|
||||
assert created["person_id"] == str(person_id)
|
||||
assert created["role_code"] == "author"
|
||||
assert created["role_id"] is not None
|
||||
assert created["role_id"] == str(author_id)
|
||||
assert created["role_label"] == "Author"
|
||||
|
||||
link_id = created["id"]
|
||||
update_response = client.patch(
|
||||
f"/api/v4/document-people/{link_id}",
|
||||
json={"role_code": "recipient"},
|
||||
json={"role_id": str(recipient_id)},
|
||||
)
|
||||
assert update_response.status_code == 200
|
||||
updated = update_response.json()
|
||||
assert updated["role_code"] == "recipient"
|
||||
assert updated["role_id"] == str(recipient_id)
|
||||
assert updated["role_label"] == "Recipient"
|
||||
|
||||
list_response = client.get(f"/api/v4/documents/{document_id}/people")
|
||||
assert list_response.status_code == 200
|
||||
links = list_response.json()["links"]
|
||||
assert len(links) == 1
|
||||
assert links[0]["role_code"] == "recipient"
|
||||
assert links[0]["role_id"] == str(recipient_id)
|
||||
|
||||
delete_response = client.delete(f"/api/v4/document-people/{link_id}")
|
||||
assert delete_response.status_code == 204
|
||||
@@ -182,7 +193,7 @@ def test_document_people_role_aware_write_read_and_delete(tmp_path):
|
||||
assert list_after_delete.json()["links"] == []
|
||||
|
||||
|
||||
def test_document_person_link_defaults_to_author_when_role_is_omitted(tmp_path):
|
||||
def test_document_person_link_requires_role_id(tmp_path):
|
||||
with _v4_api_client(tmp_path, db_filename="api-default-role.db") as (client, db_url):
|
||||
document_id, person_id = _seed_document_and_person(db_url=db_url)
|
||||
|
||||
@@ -191,23 +202,24 @@ def test_document_person_link_defaults_to_author_when_role_is_omitted(tmp_path):
|
||||
json={"person_id": str(person_id)},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["role_code"] == "author"
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_duplicate_document_person_link_returns_conflict_envelope(tmp_path):
|
||||
with _v4_api_client(tmp_path, db_filename="api-dup.db") as (client, db_url):
|
||||
document_id, person_id = _seed_document_and_person(db_url=db_url)
|
||||
author_id = asyncio.run(_person_role_id(db_url=db_url, semantic_key="author"))
|
||||
recipient_id = asyncio.run(_person_role_id(db_url=db_url, semantic_key="recipient"))
|
||||
|
||||
first = client.post(
|
||||
f"/api/v4/documents/{document_id}/people",
|
||||
json={"person_id": str(person_id), "role_code": "author"},
|
||||
json={"person_id": str(person_id), "role_id": str(author_id)},
|
||||
)
|
||||
assert first.status_code == 200
|
||||
|
||||
second = client.post(
|
||||
f"/api/v4/documents/{document_id}/people",
|
||||
json={"person_id": str(person_id), "role_code": "author"},
|
||||
json={"person_id": str(person_id), "role_id": str(recipient_id)},
|
||||
)
|
||||
|
||||
assert second.status_code == 409
|
||||
|
||||
@@ -5,15 +5,12 @@ 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 DocumentPersonRole
|
||||
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.services.documents import DocumentDeleteBlockedError
|
||||
from transcription.services.documents import DocumentError
|
||||
@@ -132,11 +129,12 @@ async def test_delete_document_removes_person_links(default_session_factory, tmp
|
||||
)
|
||||
)
|
||||
person = await people_service.create_person(Person(full_name="Linked Person"))
|
||||
author_role = await people_service.create_person_role(label="Author")
|
||||
await people_service.create_document_person(
|
||||
DocumentPerson(
|
||||
document_id=document.id,
|
||||
person_id=person.id,
|
||||
role=DocumentPersonRole.AUTHOR,
|
||||
role_id=author_role.id,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -144,7 +142,7 @@ async def test_delete_document_removes_person_links(default_session_factory, tmp
|
||||
assert len(links_before_delete) == 1
|
||||
assert links_before_delete[0].role_id is not None
|
||||
assert links_before_delete[0].role_ref is not None
|
||||
assert links_before_delete[0].role_ref.code == "author"
|
||||
assert links_before_delete[0].role_ref.label == "Author"
|
||||
|
||||
document_dir = service.settings.upload_dir / "documents" / str(document.id)
|
||||
document_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -196,11 +194,12 @@ async def test_read_person_detail_loads_document_links(default_session_factory):
|
||||
)
|
||||
)
|
||||
person = await people_service.create_person(Person(full_name="Linked Person"))
|
||||
author_role = await people_service.create_person_role(label="Author")
|
||||
await people_service.create_document_person(
|
||||
DocumentPerson(
|
||||
document_id=document.id,
|
||||
person_id=person.id,
|
||||
role=DocumentPersonRole.AUTHOR,
|
||||
role_id=author_role.id,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -243,11 +242,12 @@ async def test_delete_person_removes_links_when_linked_documents_exist(default_s
|
||||
)
|
||||
)
|
||||
person = await service.create_person(Person(full_name="Blocked Person"))
|
||||
author_role = await service.create_person_role(label="Author")
|
||||
await service.create_document_person(
|
||||
DocumentPerson(
|
||||
document_id=document.id,
|
||||
person_id=person.id,
|
||||
role=DocumentPersonRole.AUTHOR,
|
||||
role_id=author_role.id,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -285,19 +285,19 @@ async def test_create_document_uses_existing_document_type_registry(default_sess
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_document_person_sets_role_id_from_legacy_role(default_session_factory):
|
||||
async def test_update_document_person_changes_role_id(default_session_factory):
|
||||
documents_service = DocumentService(session_factory=default_session_factory)
|
||||
service = PeopleService(session_factory=default_session_factory)
|
||||
|
||||
document = await documents_service.create_document(
|
||||
Document(id=uuid4(), name="role-sync-doc", document_type="letter")
|
||||
)
|
||||
document = await documents_service.create_document(Document(id=uuid4(), name="role-sync-doc"))
|
||||
person = await service.create_person(Person(full_name="Role Sync 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(
|
||||
DocumentPerson(
|
||||
document_id=document.id,
|
||||
person_id=person.id,
|
||||
role=DocumentPersonRole.AUTHOR,
|
||||
role_id=author_role.id,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -306,15 +306,8 @@ async def test_update_document_person_sets_role_id_from_legacy_role(default_sess
|
||||
id=link.id,
|
||||
document_id=document.id,
|
||||
person_id=person.id,
|
||||
role=DocumentPersonRole.RECIPIENT,
|
||||
role_id=None,
|
||||
role_id=recipient_role.id,
|
||||
)
|
||||
)
|
||||
|
||||
assert updated.role == DocumentPersonRole.RECIPIENT
|
||||
assert updated.role_id is not None
|
||||
|
||||
async with service._session_scope() as session:
|
||||
recipient_role = (await session.exec(select(PersonRole).where(PersonRole.code == "recipient"))).first()
|
||||
assert recipient_role is not None
|
||||
assert updated.role_id == recipient_role.id
|
||||
|
||||
@@ -6,8 +6,8 @@ import pytest
|
||||
|
||||
from transcription.db.models import Document
|
||||
from transcription.db.models import DocumentPerson
|
||||
from transcription.db.models import DocumentPersonRole
|
||||
from transcription.db.models import Person
|
||||
from transcription.db.models import PersonRole
|
||||
from transcription.errors import ErrorCategory
|
||||
from transcription.services.documents import DocumentService
|
||||
from transcription.services.documents import DocumentTypeError
|
||||
@@ -73,24 +73,27 @@ async def test_document_type_delete_allows_unreferenced_and_blocks_referenced(de
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_person_role_maintenance_orders_by_label_then_code(default_session_factory):
|
||||
async def test_person_role_maintenance_orders_by_normalized_label(default_session_factory):
|
||||
service = PeopleService(session_factory=default_session_factory)
|
||||
second = await service.create_person_role(code="witness", label="Witness")
|
||||
first = await service.create_person_role(code="author", label="Author")
|
||||
second = await service.create_person_role(label="Witness")
|
||||
first = await service.create_person_role(label="Archivist")
|
||||
|
||||
updated = await service.update_person_role(second.id, label="Attestor", is_active=False)
|
||||
|
||||
assert updated.code == "witness"
|
||||
assert [item.id for item in await service.list_person_roles(active_only=False)] == [second.id, first.id]
|
||||
assert updated.semantic_key is None
|
||||
assert [item.id for item in await service.list_person_roles(active_only=False)] == [first.id, second.id]
|
||||
assert [item.id for item in await service.list_person_roles()] == [first.id]
|
||||
summaries = {item.id: item for item in await service.list_person_role_summaries()}
|
||||
assert summaries[second.id].link_count == 0
|
||||
assert summaries[second.id].is_built_in is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_person_role_delete_allows_unreferenced_and_blocks_referenced(default_session_factory):
|
||||
documents = DocumentService(session_factory=default_session_factory)
|
||||
people = PeopleService(session_factory=default_session_factory)
|
||||
unused = await people.create_person_role(code="witness", label="Witness")
|
||||
referenced = await people.create_person_role(code="author", label="Author")
|
||||
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"))
|
||||
await people.create_document_person(
|
||||
@@ -98,7 +101,6 @@ async def test_person_role_delete_allows_unreferenced_and_blocks_referenced(defa
|
||||
document_id=document.id,
|
||||
person_id=person.id,
|
||||
role_id=referenced.id,
|
||||
role=DocumentPersonRole.AUTHOR,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -113,12 +115,12 @@ async def test_person_role_delete_allows_unreferenced_and_blocks_referenced(defa
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_person_role_duplicate_code_is_conflict(default_session_factory):
|
||||
async def test_person_role_duplicate_normalized_label_is_conflict(default_session_factory):
|
||||
service = PeopleService(session_factory=default_session_factory)
|
||||
await service.create_person_role(code="author", label="Author")
|
||||
await service.create_person_role(label="Witness")
|
||||
|
||||
with pytest.raises(PersonRoleError) as caught:
|
||||
await service.create_person_role(code=" AUTHOR ", label="Duplicate")
|
||||
await service.create_person_role(label=" witness ")
|
||||
|
||||
assert caught.value.category == ErrorCategory.CONFLICT
|
||||
|
||||
@@ -127,7 +129,7 @@ async def test_person_role_duplicate_code_is_conflict(default_session_factory):
|
||||
async def test_custom_person_role_can_be_used_for_document_link(default_session_factory):
|
||||
documents = DocumentService(session_factory=default_session_factory)
|
||||
people = PeopleService(session_factory=default_session_factory)
|
||||
role = await people.create_person_role(code="witness", label="Witness")
|
||||
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"))
|
||||
|
||||
@@ -138,30 +140,24 @@ async def test_custom_person_role_can_be_used_for_document_link(default_session_
|
||||
)
|
||||
loaded = await people.list_document_people(document_id=document.id)
|
||||
|
||||
assert link.role == "witness"
|
||||
assert link.role_id == role.id
|
||||
assert loaded[0].role_ref is not None
|
||||
assert loaded[0].role_ref.code == "witness"
|
||||
assert loaded[0].role_ref.label == "Witness"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_person_role_delete_blocks_legacy_only_reference(default_session_factory):
|
||||
documents = DocumentService(session_factory=default_session_factory)
|
||||
async def test_built_in_person_role_cannot_be_deleted(default_session_factory):
|
||||
people = PeopleService(session_factory=default_session_factory)
|
||||
role = await people.create_person_role(code="witness", label="Witness")
|
||||
document = await documents.create_document(Document(name="Legacy role document"))
|
||||
person = await people.create_person(Person(full_name="Legacy Witness"))
|
||||
async with people._session_scope() as session:
|
||||
session.add(
|
||||
DocumentPerson(
|
||||
document_id=document.id,
|
||||
person_id=person.id,
|
||||
role="witness",
|
||||
role_id=None,
|
||||
)
|
||||
role = PersonRole(
|
||||
semantic_key="author",
|
||||
label="Author",
|
||||
normalized_label="author",
|
||||
)
|
||||
session.add(role)
|
||||
await session.commit()
|
||||
await session.refresh(role)
|
||||
|
||||
assert await people.is_person_role_referenced(role.id) is True
|
||||
with pytest.raises(PersonRoleError) as caught:
|
||||
await people.delete_person_role(role.id)
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import pytest
|
||||
|
||||
from transcription.db.models import Document
|
||||
from transcription.db.models import DocumentPerson
|
||||
from transcription.db.models import DocumentPersonRole
|
||||
from transcription.db.models import Job
|
||||
from transcription.db.models import JobSource
|
||||
from transcription.db.models import JobSourceStatus
|
||||
@@ -27,23 +26,23 @@ async def test_people_service_handles_person_and_document_person_crud(default_se
|
||||
|
||||
document = await documents.create_document(Document(id=uuid4(), name="person-doc"))
|
||||
person = await people_service.create_person(Person(full_name="Ada Lovelace"))
|
||||
author_role = await people_service.create_person_role(label="Author")
|
||||
recipient_role = await people_service.create_person_role(label="Recipient")
|
||||
|
||||
assert document.document_type_id is None
|
||||
|
||||
link = await people_service.create_document_person(
|
||||
DocumentPerson(document_id=document.id, person_id=person.id, role=DocumentPersonRole.AUTHOR)
|
||||
DocumentPerson(document_id=document.id, person_id=person.id, role_id=author_role.id)
|
||||
)
|
||||
|
||||
fetched = await people_service.read_document_person(link.id)
|
||||
assert fetched.id == link.id
|
||||
assert fetched.role == DocumentPersonRole.AUTHOR
|
||||
assert fetched.role_id is not None
|
||||
assert fetched.role_id == author_role.id
|
||||
|
||||
updated_link = await people_service.update_document_person(
|
||||
DocumentPerson(id=link.id, document_id=document.id, person_id=person.id, role=DocumentPersonRole.RECIPIENT)
|
||||
DocumentPerson(id=link.id, document_id=document.id, person_id=person.id, role_id=recipient_role.id)
|
||||
)
|
||||
assert updated_link.role == DocumentPersonRole.RECIPIENT
|
||||
assert updated_link.role_id is not None
|
||||
assert updated_link.role_id == recipient_role.id
|
||||
|
||||
listed = await people_service.list_document_people(document_id=document.id)
|
||||
assert len(listed) == 1
|
||||
@@ -59,21 +58,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(full_name="Hig 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(full_name="Duplicate 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(full_name="Malformed", family_search_id="not-an-id"))
|
||||
assert malformed.value.category == ErrorCategory.VALIDATION
|
||||
|
||||
|
||||
@@ -202,11 +195,12 @@ async def test_document_detail_loads_linked_person_relationship(default_session_
|
||||
|
||||
document = await documents.create_document(Document(id=uuid4(), name="detail-person-doc"))
|
||||
person = await people_service.create_person(Person(full_name="Grace Hopper"))
|
||||
author_role = await people_service.create_person_role(label="Author")
|
||||
await people_service.create_document_person(
|
||||
DocumentPerson(
|
||||
document_id=document.id,
|
||||
person_id=person.id,
|
||||
role=DocumentPersonRole.AUTHOR,
|
||||
role_id=author_role.id,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -216,7 +210,7 @@ async def test_document_detail_loads_linked_person_relationship(default_session_
|
||||
link = detail.document_people[0]
|
||||
assert link.person is not None
|
||||
assert link.person.full_name == "Grace Hopper"
|
||||
assert link.role == DocumentPersonRole.AUTHOR
|
||||
assert link.role_id == author_role.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
from datetime import UTC
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from transcription.db.models import Document
|
||||
from transcription.db.models import DocumentPerson
|
||||
from transcription.db.models import Job
|
||||
from transcription.db.models import JobStatus
|
||||
from transcription.db.models import Person
|
||||
from transcription.db.models import PersonRole
|
||||
from transcription.db.models import Source
|
||||
from transcription.services.documents import DocumentService
|
||||
from transcription.services.jobs import JobService
|
||||
from transcription.services.people import DocumentPersonInput
|
||||
from transcription.services.people import PeopleError
|
||||
from transcription.services.people import PeopleService
|
||||
from transcription.services.sources import SourceService
|
||||
from transcription.services.workflows import create_document_with_people
|
||||
from transcription.services.workflows import update_document_with_people
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_document_with_people_rolls_back_on_invalid_person(default_session_factory):
|
||||
documents = DocumentService(session_factory=default_session_factory)
|
||||
people = PeopleService(session_factory=default_session_factory)
|
||||
role = await people.create_person_role(label="Witness")
|
||||
|
||||
with pytest.raises(PeopleError):
|
||||
await create_document_with_people(
|
||||
document=Document(name="Must roll back"),
|
||||
links=[DocumentPersonInput(person_id=uuid4(), role_id=role.id)],
|
||||
documents=documents,
|
||||
people=people,
|
||||
)
|
||||
|
||||
assert await documents.query_documents(name="Must roll back") == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_document_with_people_rolls_back_document_and_links(default_session_factory):
|
||||
documents = DocumentService(session_factory=default_session_factory)
|
||||
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"))
|
||||
document = await create_document_with_people(
|
||||
document=Document(name="Original name"),
|
||||
links=[DocumentPersonInput(person_id=person.id, role_id=role.id)],
|
||||
documents=documents,
|
||||
people=people,
|
||||
)
|
||||
assert [item.name for item in await documents.list_documents()] == ["Original name"]
|
||||
|
||||
candidate = Document(
|
||||
id=document.id,
|
||||
name="Changed name",
|
||||
created_at=document.created_at,
|
||||
updated_at=document.updated_at,
|
||||
)
|
||||
with pytest.raises(PeopleError):
|
||||
await update_document_with_people(
|
||||
document=candidate,
|
||||
links=[DocumentPersonInput(person_id=person.id, role_id=inactive.id)],
|
||||
documents=documents,
|
||||
people=people,
|
||||
)
|
||||
|
||||
persisted_documents = await documents.list_documents()
|
||||
links = await people.list_document_people(document_id=document.id)
|
||||
assert [item.name for item in persisted_documents] == ["Original name"]
|
||||
assert len(links) == 1
|
||||
assert links[0].role_id == role.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_direct_link_writes_reject_new_inactive_role_assignments(default_session_factory):
|
||||
documents = DocumentService(session_factory=default_session_factory)
|
||||
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"))
|
||||
document = await documents.create_document(Document(name="Role rules"))
|
||||
link = await people.add_document_person_link(
|
||||
document_id=document.id,
|
||||
person_id=person.id,
|
||||
role_id=active.id,
|
||||
)
|
||||
|
||||
with pytest.raises(PeopleError, match="Inactive Person Role"):
|
||||
await people.set_document_person_role(
|
||||
document_person_id=link.id,
|
||||
role_id=inactive.id,
|
||||
)
|
||||
|
||||
unchanged = await people.set_document_person_role(
|
||||
document_person_id=link.id,
|
||||
role_id=active.id,
|
||||
)
|
||||
assert unchanged.role_id == active.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_document_print_projection_uses_semantic_author_and_current_text(default_session_factory):
|
||||
documents = DocumentService(session_factory=default_session_factory)
|
||||
people = PeopleService(session_factory=default_session_factory)
|
||||
sources = SourceService(session_factory=default_session_factory)
|
||||
jobs = JobService(session_factory=default_session_factory)
|
||||
document = await documents.create_document(Document(name="Print Me", notes="Archive note"))
|
||||
person = await people.create_person(Person(full_name="Historic Author"))
|
||||
|
||||
async with people._session_scope() as session:
|
||||
author = PersonRole(
|
||||
semantic_key="author",
|
||||
label="Creator",
|
||||
normalized_label="creator",
|
||||
)
|
||||
session.add(author)
|
||||
await session.flush()
|
||||
session.add(DocumentPerson(document_id=document.id, person_id=person.id, role_id=author.id))
|
||||
await session.commit()
|
||||
|
||||
await sources.create_source(
|
||||
Source(
|
||||
document_id=document.id,
|
||||
page_number=2,
|
||||
upload_name="page-2.png",
|
||||
filename="page-2.png",
|
||||
file_path="managed/page-2.png",
|
||||
file_hash="2" * 64,
|
||||
file_size_bytes=2,
|
||||
raw_transcription="raw second",
|
||||
revised_text="revised second",
|
||||
)
|
||||
)
|
||||
await sources.create_source(
|
||||
Source(
|
||||
document_id=document.id,
|
||||
page_number=1,
|
||||
upload_name="page-1.png",
|
||||
filename="page-1.png",
|
||||
file_path="managed/page-1.png",
|
||||
file_hash="1" * 64,
|
||||
file_size_bytes=1,
|
||||
raw_transcription="raw first",
|
||||
)
|
||||
)
|
||||
await jobs.create_job(
|
||||
Job(
|
||||
document_id=document.id,
|
||||
status=JobStatus.COMPLETED,
|
||||
provider="openrouter",
|
||||
model="model-a",
|
||||
prompt_name="transcribe_document.md",
|
||||
date_created=datetime(2026, 1, 1, tzinfo=UTC),
|
||||
)
|
||||
)
|
||||
|
||||
projection = await documents.read_document_print_projection(document.id)
|
||||
|
||||
assert projection.authors == ("Historic Author",)
|
||||
assert [source.page_number for source in projection.sources] == [1, 2]
|
||||
assert [source.current_text for source in projection.sources] == ["raw first", "revised second"]
|
||||
assert [source.media_type for source in projection.sources] == ["image/png", "image/png"]
|
||||
assert projection.jobs[0].status == "completed"
|
||||
+4
-93
@@ -1,7 +1,5 @@
|
||||
"""Tests for the database runtime and V2 schema bootstrap behavior."""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import inspect
|
||||
from sqlalchemy import text
|
||||
@@ -98,11 +96,11 @@ async def test_create_all_seeds_default_registry_rows(tmp_path):
|
||||
try:
|
||||
await create_all(engine=runtime.engine)
|
||||
async with AsyncSession(runtime.engine, expire_on_commit=False) as session:
|
||||
role_codes = set((await session.exec(select(PersonRole.code))).all())
|
||||
type_labels = set((await session.exec(select(DocumentType.label))).all())
|
||||
role_keys = set((await session.exec(select(PersonRole.semantic_key))).all())
|
||||
type_keys = set((await session.exec(select(DocumentType.semantic_key))).all())
|
||||
|
||||
assert {"author", "recipient", "mentioned"}.issubset(role_codes)
|
||||
assert {"Letter", "Record", "Memo"}.issubset(type_labels)
|
||||
assert {"author", "recipient", "mentioned"}.issubset(role_keys)
|
||||
assert {"book", "letter", "postcard", "photo", "journal", "form"}.issubset(type_keys)
|
||||
finally:
|
||||
await dispose_database_runtime()
|
||||
|
||||
@@ -137,93 +135,6 @@ async def test_create_all_upgrades_existing_person_table_for_family_search(tmp_p
|
||||
await dispose_database_runtime()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upgrade_migrates_document_types_to_uuid_only_identity(tmp_path):
|
||||
settings = Settings(
|
||||
openrouter_api_key="test-key",
|
||||
database=SqliteSettings(path=str(tmp_path / "type-upgrade.db")),
|
||||
environment="test",
|
||||
)
|
||||
runtime = initialize_database_runtime(settings=settings)
|
||||
type_id = uuid4().hex
|
||||
document_id = uuid4().hex
|
||||
|
||||
try:
|
||||
async with runtime.engine.begin() as connection:
|
||||
await connection.execute(
|
||||
text(
|
||||
"CREATE TABLE document_type ("
|
||||
"id CHAR(32) PRIMARY KEY NOT NULL, "
|
||||
"code VARCHAR NOT NULL, "
|
||||
"label VARCHAR NOT NULL, "
|
||||
"is_active BOOLEAN NOT NULL, "
|
||||
"sort_order INTEGER NOT NULL, "
|
||||
"created_at DATETIME NOT NULL, "
|
||||
"updated_at DATETIME NOT NULL"
|
||||
")"
|
||||
)
|
||||
)
|
||||
await connection.execute(text("CREATE UNIQUE INDEX ix_document_type_code ON document_type (code)"))
|
||||
await connection.execute(
|
||||
text(
|
||||
"CREATE TABLE document ("
|
||||
"id CHAR(32) PRIMARY KEY NOT NULL, "
|
||||
"name VARCHAR NOT NULL, "
|
||||
"document_type_id CHAR(32), "
|
||||
"document_type VARCHAR, "
|
||||
"document_date DATE, "
|
||||
"document_date_raw VARCHAR, "
|
||||
"location_created VARCHAR, "
|
||||
"notes VARCHAR, "
|
||||
"archive_identifier VARCHAR, "
|
||||
"created_at DATETIME NOT NULL, "
|
||||
"updated_at DATETIME NOT NULL"
|
||||
")"
|
||||
)
|
||||
)
|
||||
await connection.execute(
|
||||
text(
|
||||
"INSERT INTO document_type "
|
||||
"(id, code, label, is_active, sort_order, created_at, updated_at) "
|
||||
"VALUES (:id, 'letter', 'Letter', 1, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
|
||||
),
|
||||
{"id": type_id},
|
||||
)
|
||||
await connection.execute(
|
||||
text(
|
||||
"INSERT INTO document "
|
||||
"(id, name, document_type_id, document_type, created_at, updated_at) "
|
||||
"VALUES (:id, 'Legacy Letter', NULL, 'letter', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
|
||||
),
|
||||
{"id": document_id},
|
||||
)
|
||||
|
||||
await upgrade_schema(engine=runtime.engine)
|
||||
|
||||
async with runtime.engine.connect() as connection:
|
||||
type_columns, document_columns, migrated_type_id, normalized_label = await connection.run_sync(
|
||||
lambda sync_connection: (
|
||||
{column["name"] for column in inspect(sync_connection).get_columns("document_type")},
|
||||
{column["name"] for column in inspect(sync_connection).get_columns("document")},
|
||||
sync_connection.execute(
|
||||
text("SELECT document_type_id FROM document WHERE id = :id"),
|
||||
{"id": document_id},
|
||||
).scalar_one(),
|
||||
sync_connection.execute(
|
||||
text("SELECT normalized_label FROM document_type WHERE id = :id"),
|
||||
{"id": type_id},
|
||||
).scalar_one(),
|
||||
)
|
||||
)
|
||||
|
||||
assert {"code", "sort_order"}.isdisjoint(type_columns)
|
||||
assert "document_type" not in document_columns
|
||||
assert migrated_type_id == type_id
|
||||
assert normalized_label == "letter"
|
||||
finally:
|
||||
await dispose_database_runtime()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_v42_upgrade_adds_evidence_tables_without_rewriting_legacy_snapshot(tmp_path):
|
||||
settings = Settings(
|
||||
|
||||
@@ -7,7 +7,6 @@ from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from transcription.db.models import Document
|
||||
from transcription.db.models import DocumentPerson
|
||||
from transcription.db.models import DocumentPersonRole
|
||||
from transcription.db.models import DocumentType
|
||||
from transcription.db.models import Job
|
||||
from transcription.db.models import JobSource
|
||||
@@ -35,8 +34,8 @@ def _persist_document_type(session, *, label: str = "Letter") -> DocumentType:
|
||||
return document_type
|
||||
|
||||
|
||||
def _persist_person_role(session, *, code: str = "author", label: str = "Author") -> PersonRole:
|
||||
role = PersonRole(code=code, label=label)
|
||||
def _persist_person_role(session, *, label: str = "Author") -> PersonRole:
|
||||
role = PersonRole(label=label, normalized_label=label.strip().casefold())
|
||||
session.add(role)
|
||||
session.commit()
|
||||
session.refresh(role)
|
||||
@@ -195,7 +194,6 @@ class TestPersonAndDocumentPersonModel:
|
||||
first = DocumentPerson(
|
||||
document_id=document.id,
|
||||
person_id=person.id,
|
||||
role=DocumentPersonRole.AUTHOR,
|
||||
role_id=person_role.id,
|
||||
)
|
||||
session.add(first)
|
||||
@@ -204,7 +202,6 @@ class TestPersonAndDocumentPersonModel:
|
||||
duplicate = DocumentPerson(
|
||||
document_id=document.id,
|
||||
person_id=person.id,
|
||||
role=DocumentPersonRole.AUTHOR,
|
||||
role_id=person_role.id,
|
||||
)
|
||||
session.add(duplicate)
|
||||
@@ -244,7 +241,6 @@ class TestRelationships:
|
||||
link = DocumentPerson(
|
||||
document_id=document.id,
|
||||
person_id=person.id,
|
||||
role=DocumentPersonRole.AUTHOR,
|
||||
role_id=person_role.id,
|
||||
)
|
||||
session.add(link)
|
||||
@@ -257,9 +253,9 @@ class TestRelationships:
|
||||
|
||||
|
||||
class TestRegistryModels:
|
||||
def test_person_role_code_is_unique(self, session):
|
||||
_persist_person_role(session, code="mentioned", label="Mentioned")
|
||||
duplicate = PersonRole(code="mentioned", label="Mentioned Again")
|
||||
def test_person_role_normalized_label_is_unique(self, session):
|
||||
_persist_person_role(session, label="Mentioned")
|
||||
duplicate = PersonRole(label=" mentioned ", normalized_label="mentioned")
|
||||
session.add(duplicate)
|
||||
with pytest.raises(IntegrityError):
|
||||
session.commit()
|
||||
|
||||
@@ -9,10 +9,10 @@ from sqlmodel import select
|
||||
from transcription.db import session_scope
|
||||
from transcription.db.models import Document
|
||||
from transcription.db.models import DocumentPerson
|
||||
from transcription.db.models import DocumentPersonRole
|
||||
from transcription.db.models import DocumentType
|
||||
from transcription.db.models import Job
|
||||
from transcription.db.models import Person
|
||||
from transcription.db.models import PersonRole
|
||||
from transcription.db.models import Source
|
||||
|
||||
# --- Helper Fixtures ---
|
||||
@@ -23,6 +23,7 @@ async def seed_person_and_document():
|
||||
"""Seed a Person and Document linked by DocumentPerson role."""
|
||||
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")
|
||||
session.add(person)
|
||||
await session.flush()
|
||||
@@ -38,7 +39,7 @@ async def seed_person_and_document():
|
||||
link = DocumentPerson(
|
||||
document_id=doc.id,
|
||||
person_id=person.id,
|
||||
role=DocumentPersonRole.AUTHOR,
|
||||
role_id=author_role.id,
|
||||
)
|
||||
session.add(link)
|
||||
await session.commit()
|
||||
@@ -92,7 +93,7 @@ class TestDocumentsPageRendering:
|
||||
assert response.status_code == 200
|
||||
assert "Create Document" in response.text
|
||||
assert "Document name" in response.text
|
||||
assert "Linked People by Role" in response.text
|
||||
assert "Linked People" in response.text
|
||||
assert "Document type" in response.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -132,7 +133,7 @@ class TestDocumentsPageRendering:
|
||||
_, client = app_client
|
||||
|
||||
async with session_scope() as session:
|
||||
doc = Document(name="Doc With Job", document_type="letter")
|
||||
doc = Document(name="Doc With Job")
|
||||
session.add(doc)
|
||||
await session.flush()
|
||||
|
||||
@@ -165,7 +166,7 @@ class TestDocumentsPageRendering:
|
||||
_, client = app_client
|
||||
|
||||
async with session_scope() as session:
|
||||
doc = Document(name="Doc With Source", document_type="letter")
|
||||
doc = Document(name="Doc With Source")
|
||||
session.add(doc)
|
||||
await session.flush()
|
||||
|
||||
@@ -194,7 +195,7 @@ class TestDocumentsPageRendering:
|
||||
_, client = app_client
|
||||
|
||||
async with session_scope() as session:
|
||||
doc = Document(name="Orphan Document", document_type="note")
|
||||
doc = Document(name="Orphan Document")
|
||||
session.add(doc)
|
||||
await session.commit()
|
||||
doc_id = str(doc.id)
|
||||
|
||||
@@ -15,7 +15,7 @@ from transcription.db.models import JobStatus
|
||||
async def seed_document_with_unlinked_job():
|
||||
"""Seed a document and a queued job for testing route actions."""
|
||||
async with session_scope() as session:
|
||||
document = Document(name="Test Archival Letter", document_type="letter")
|
||||
document = Document(name="Test Archival Letter")
|
||||
session.add(document)
|
||||
await session.flush()
|
||||
|
||||
@@ -72,7 +72,7 @@ class TestJobsPageRendering:
|
||||
_, client = app_client
|
||||
|
||||
async with session_scope() as session:
|
||||
doc = Document(name="Preselected Journal Entry", document_type="journal")
|
||||
doc = Document(name="Preselected Journal Entry")
|
||||
session.add(doc)
|
||||
await session.commit()
|
||||
doc_id = str(doc.id)
|
||||
@@ -133,7 +133,7 @@ class TestJobsPageRendering:
|
||||
_, client = app_client
|
||||
|
||||
async with session_scope() as session:
|
||||
doc = Document(name="Processing Doc", document_type="letter")
|
||||
doc = Document(name="Processing Doc")
|
||||
session.add(doc)
|
||||
await session.flush()
|
||||
job = Job(document_id=doc.id, status=JobStatus.PROCESSING)
|
||||
|
||||
@@ -4,12 +4,13 @@ from datetime import date
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlmodel import select
|
||||
|
||||
from transcription.db import session_scope
|
||||
from transcription.db.models import Document
|
||||
from transcription.db.models import DocumentPerson
|
||||
from transcription.db.models import DocumentPersonRole
|
||||
from transcription.db.models import Person
|
||||
from transcription.db.models import PersonRole
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@@ -122,8 +123,9 @@ class TestPeoplePageRendering:
|
||||
_, client = app_client
|
||||
|
||||
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")
|
||||
document = Document(name="Linked Document", document_type="letter")
|
||||
document = Document(name="Linked Document")
|
||||
session.add_all([person, document])
|
||||
await session.flush()
|
||||
|
||||
@@ -131,7 +133,7 @@ class TestPeoplePageRendering:
|
||||
DocumentPerson(
|
||||
document_id=document.id,
|
||||
person_id=person.id,
|
||||
role=DocumentPersonRole.AUTHOR,
|
||||
role_id=author_role.id,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
@@ -141,7 +143,7 @@ class TestPeoplePageRendering:
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Linked Document" in response.text
|
||||
assert "Role: author" in response.text
|
||||
assert "Role: Author" in response.text
|
||||
|
||||
def test_person_detail_page_handles_invalid_id(self, app_client):
|
||||
_, client = app_client
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from transcription.db import session_scope
|
||||
from transcription.db.models import Document
|
||||
from transcription.db.models import Source
|
||||
from transcription.ui.pages.print_preview_page import reflow_transcription
|
||||
|
||||
|
||||
def test_reflow_transcription_preserves_paragraph_boundaries():
|
||||
assert reflow_transcription("first line\nsecond line\n\nnext paragraph") == [
|
||||
"first line second line",
|
||||
"next paragraph",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_document_print_preview_and_safe_media_route(app_client):
|
||||
app, client = app_client
|
||||
media_path = app.state.settings.upload_dir / "documents" / "print-page.png"
|
||||
pdf_path = app.state.settings.upload_dir / "documents" / "print-page.pdf"
|
||||
media_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
media_path.write_bytes(b"\x89PNG\r\n\x1a\n")
|
||||
pdf_path.write_bytes(b"%PDF-1.4\n%%EOF")
|
||||
|
||||
async with session_scope() as session:
|
||||
document = Document(name="<Print & Preserve>", notes="<script>unsafe()</script>")
|
||||
session.add(document)
|
||||
await session.flush()
|
||||
source = Source(
|
||||
document_id=document.id,
|
||||
page_number=1,
|
||||
upload_name="print-page.png",
|
||||
filename="print-page.png",
|
||||
file_path=str(media_path),
|
||||
file_hash="a" * 64,
|
||||
file_size_bytes=media_path.stat().st_size,
|
||||
raw_transcription="line one\nline two",
|
||||
)
|
||||
session.add(source)
|
||||
session.add(
|
||||
Source(
|
||||
document_id=document.id,
|
||||
page_number=2,
|
||||
upload_name="print-page.pdf",
|
||||
filename="print-page.pdf",
|
||||
file_path=str(pdf_path),
|
||||
file_hash="c" * 64,
|
||||
file_size_bytes=pdf_path.stat().st_size,
|
||||
raw_transcription="PDF source",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
document_id = document.id
|
||||
source_id = source.id
|
||||
|
||||
response = client.get(f"/ui/documents/{document_id}/print")
|
||||
assert response.status_code == 200
|
||||
assert "Print & Preserve" in response.text
|
||||
assert "<script>unsafe()</script>" in response.text
|
||||
assert "Facsimile" in response.text
|
||||
assert "Text only" in response.text
|
||||
assert "print-page-break" in response.text
|
||||
assert "print-source-pdf" in response.text
|
||||
assert str(media_path) not in response.text
|
||||
assert str(pdf_path) not in response.text
|
||||
|
||||
media_response = client.get(f"/api/v4/documents/{document_id}/sources/{source_id}/media")
|
||||
assert media_response.status_code == 200
|
||||
assert media_response.headers["content-type"] == "image/png"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_document_source_media_rejects_cross_document_access(app_client):
|
||||
app, client = app_client
|
||||
media_path = Path(app.state.settings.upload_dir) / "documents" / "other.png"
|
||||
media_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
media_path.write_bytes(b"\x89PNG\r\n\x1a\n")
|
||||
|
||||
async with session_scope() as session:
|
||||
owner = Document(name="Owner")
|
||||
other = Document(name="Other")
|
||||
session.add_all([owner, other])
|
||||
await session.flush()
|
||||
source = Source(
|
||||
document_id=owner.id,
|
||||
page_number=1,
|
||||
upload_name="other.png",
|
||||
filename="other.png",
|
||||
file_path=str(media_path),
|
||||
file_hash="b" * 64,
|
||||
file_size_bytes=media_path.stat().st_size,
|
||||
)
|
||||
session.add(source)
|
||||
await session.commit()
|
||||
other_id = other.id
|
||||
source_id = source.id
|
||||
|
||||
response = client.get(f"/api/v4/documents/{other_id}/sources/{source_id}/media")
|
||||
assert response.status_code == 404
|
||||
@@ -80,7 +80,7 @@ class TestSourcesPageRendering:
|
||||
_, client = app_client
|
||||
|
||||
async with session_scope() as session:
|
||||
document = Document(name="Source Document", document_type="letter")
|
||||
document = Document(name="Source Document")
|
||||
session.add(document)
|
||||
await session.flush()
|
||||
session.add(
|
||||
@@ -108,8 +108,8 @@ class TestSourcesPageRendering:
|
||||
_, client = app_client
|
||||
|
||||
async with session_scope() as session:
|
||||
target = Document(name="Target", document_type="letter")
|
||||
other = Document(name="Other", document_type="record")
|
||||
target = Document(name="Target")
|
||||
other = Document(name="Other")
|
||||
session.add_all([target, other])
|
||||
await session.flush()
|
||||
|
||||
@@ -274,7 +274,7 @@ class TestSourcesPageRendering:
|
||||
_, client = app_client
|
||||
|
||||
async with session_scope() as session:
|
||||
document = Document(name="Unlinked Source Doc", document_type="memo")
|
||||
document = Document(name="Unlinked Source Doc")
|
||||
session.add(document)
|
||||
await session.flush()
|
||||
source = Source(
|
||||
|
||||
Reference in New Issue
Block a user